Add curses dashboard and XIAO bridge examples; enhance stream parser for UTF-8 logging

This commit is contained in:
pdxlocations
2026-08-01 00:46:04 -07:00
parent d96435b7c4
commit d242a82d1d
8 changed files with 988 additions and 65 deletions
+398 -64
View File
@@ -1,128 +1,462 @@
# MicroMesh # MicroMesh 📻
MicroMesh is a small, dependency-free Meshtastic client for MicroPython. It talks to a **A tiny, dependency-free Meshtastic client for MicroPython.**
Meshtastic device over its serial API and implements the protobuf wire format directly,
so `google.protobuf`, threads, and `pyserial` are not required.
The initial release supports: MicroMesh lets a small MicroPython board talk to a Meshtastic radio over UART. Use it
to receive messages, send text and position packets, inspect the node list, or power a
full-screen terminal dashboard.
- serial `ToRadio` / `FromRadio` framing and resynchronization; No protobuf compiler runs on the board. No threads. No `google.protobuf`. Just a UART,
- the configuration handshake and a small in-memory node list; three wires, and regular Python.
- text, arbitrary data, and position packets;
- routing acknowledgements, node/user data, waypoints, and device metrics;
- familiar generated-protobuf methods: `SerializeToString`, `ParseFromString`,
`CopyFrom`, `HasField`, `ClearField`, and `WhichOneof`;
- preservation of unknown protobuf fields for forward compatibility.
This is intentionally not a complete replacement for the desktop > [!NOTE]
[`meshtastic`](https://github.com/meshtastic/python) package. Configuration and admin > MicroMesh is young and intentionally small. It supports the most useful Meshtastic
messages are currently returned as raw encoded bytes. > client operations, but it is not yet a complete replacement for the official desktop
> [`meshtastic`](https://github.com/meshtastic/python) package.
## Install ## What can it do?
From a checkout of this repository, install with a current `mpremote`: - Connect to a Meshtastic radio using its framed serial API
- Download and track the radio's node database
- Receive and send text messages
- Send binary data and positions
- Read user, position, signal, battery, and hop information
- Preserve unknown protobuf fields for forward compatibility
- Recover from serial noise and skip newer messages it cannot decode
- Run a curses dashboard with live stats, nodes, and messages
- Work on MicroPython without third-party runtime dependencies
```sh ## Pick your setup
mpremote mip install package.json
| I want to… | Use this |
| --- | --- |
| Run a simple listener on a XIAO RP2040 | [XIAO quick start](#xiao-rp2040-quick-start) |
| View nodes and messages through my XIAO | [Curses dashboard](#curses-dashboard-through-the-xiao) |
| Connect my computer directly to the Meshtastic radio | [Direct desktop dashboard](#direct-to-radio-dashboard) |
| Use a Pico, Pico W, or ESP32 | [Other boards](#other-boards) |
| Write my own program | [Python API](#python-api) |
## How the XIAO setup works
The XIAO is the computer running MicroMesh. The Meshtastic device is the LoRa radio.
```text
Mac / PC XIAO RP2040 Meshtastic radio
┌─────────┐ USB/REPL ┌────────────────┐ UART ┌─────────────────┐
│ terminal│◀─────────────▶│ MicroPython + │◀────────▶│ Meshtastic │◀──▶ LoRa
│ or UI │ │ MicroMesh │ 3 wires │ firmware │
└─────────┘ └────────────────┘ └─────────────────┘
``` ```
Alternatively, copy the `micromesh` directory to `/lib` on the board. After this The XIAO does not contain a LoRa radio. It controls a separate Meshtastic device over
repository is published on GitHub it can also be installed as UART.
`mpremote mip install github:ORG/micromesh`.
It can also be installed on CPython for development: ## Before you begin
```sh You need:
python -m pip install .
- A Seeed Studio XIAO RP2040 with MicroPython installed
- A separate Meshtastic-compatible radio running Meshtastic firmware
- Three jumper wires for TX, RX, and GND
- A data-capable USB cable for the XIAO
- Python 3 on your Mac, Linux computer, or Windows PC
Configure the Meshtastic radio's serial interface for:
```text
Enabled: yes
Mode: PROTO
Baud: 115200
``` ```
After the first PyPI release, install it by name with: The radio's UART pin names depend on its model. Consult that board's pinout before
connecting wires.
```sh > [!CAUTION]
python -m pip install micromesh > Use **3.3 V UART logic** and always connect the grounds. Do not connect the boards'
> power pins unless you have verified their voltage and current requirements.
## XIAO RP2040 quick start
### 1. Wire the boards
UART wires cross: TX goes to RX, and RX goes to TX.
```text
XIAO RP2040 Meshtastic radio
────────────────────────────────────────────────────
D6 / GPIO0 / UART0 TX ──────────▶ UART RX
D7 / GPIO1 / UART0 RX ◀────────── UART TX
GND ─────────── GND
``` ```
## Quick start The XIAO D6/D7 mapping comes from the
[`XIAO RP2040 pinout`](https://wiki.seeedstudio.com/XIAO-RP2040/).
### 2. Prepare a Python environment
Run these commands from the MicroMesh repository directory:
```sh
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade mpremote
```
Windows PowerShell uses this activation command instead:
```powershell
.venv\Scripts\Activate.ps1
```
### 3. Find the XIAO serial port
Connect the XIAO over USB, then run:
```sh
mpremote connect list
```
On macOS it will usually look similar to `/dev/cu.usbmodem2101`. Linux commonly uses
`/dev/ttyACM0`; Windows uses a name such as `COM4`.
The commands below use `/dev/cu.usbmodem2101`. Replace it with your port.
### 4. Install MicroMesh on the XIAO
```sh
mpremote connect /dev/cu.usbmodem2101 mip install package.json
```
This installs the `micromesh` package under `/lib` on the board.
### 5. Run the example
The example broadcasts one `hello from XIAO RP2040` message after each successful
configuration download. Comment out its `mesh.sendText(...)` line first if you want a
receive-only test.
```sh
mpremote connect /dev/cu.usbmodem2101 run examples/xiao_rp2040.py
```
You should immediately see:
```text
MicroMesh starting on XIAO RP2040
UART0: D6/GPIO0 TX -> radio RX
UART0: D7/GPIO1 RX <- radio TX
UART0: 115200 baud; common GND required
configuration requested; id=...
```
When the radio finishes sending its configuration and node database:
```text
radio configuration complete; sending greeting
connected; 17 nodes known
```
The number of nodes will be different for your radio. Press `Ctrl-C` to stop.
### 6. Start it automatically
Once the temporary run works, save it as the XIAO boot program:
```sh
mpremote connect /dev/cu.usbmodem2101 fs cp examples/xiao_rp2040.py :main.py
mpremote connect /dev/cu.usbmodem2101 reset
```
To stop or replace an automatically running script, connect the XIAO, press `Ctrl-C`
in `mpremote repl`, and upload a different `main.py`.
## Curses dashboard through the XIAO
The desktop curses dashboard provides keyboard message composition and a resizable UI.
It displays live connection stats, known nodes, battery levels, SNR, hops, last-seen
times, and incoming messages. The XIAO runs a small bridge between the radio UART and
the dashboard over USB.
The dashboard needs a small bridge program on the XIAO. The regular
`xiao_rp2040.py` example prints human-readable logs; it does not expose structured data
to desktop applications.
### 1. Install the desktop dependencies
```sh
source .venv/bin/activate
python -m pip install -e . pyserial
```
Windows users also need:
```powershell
python -m pip install windows-curses
```
### 2. Install the bridge on the XIAO
This replaces the current `main.py`:
```sh
mpremote connect /dev/cu.usbmodem2101 fs cp examples/xiao_dashboard_bridge.py :main.py
mpremote connect /dev/cu.usbmodem2101 reset
```
### 3. Close `mpremote`, then launch the dashboard
```sh
python examples/curses_dashboard.py --xiao-bridge /dev/cu.usbmodem2101
```
Only one application can own a serial port. Close `mpremote`, Arduino Serial Monitor,
screen, minicom, and other serial tools before launching the dashboard.
The message field is always active. Type a message and press Enter—there is no separate
compose mode.
| Key | Action |
| --- | --- |
| Type + Enter | Broadcast the text in the message field |
| Escape | Clear the message field |
| `F5` (or `Ctrl-R`) | Request the configuration and node database again |
| `F10` (or `Ctrl-Q`) | Quit cleanly |
To switch back to the regular logging example:
```sh
mpremote connect /dev/cu.usbmodem2101 fs cp examples/xiao_rp2040.py :main.py
mpremote connect /dev/cu.usbmodem2101 reset
```
## Direct-to-radio dashboard
If the Meshtastic radio itself is connected to your computer over USB, the desktop can
talk directly to it. Do not use `--xiao-bridge` in this mode.
```sh
python -m pip install -e . pyserial
python -m serial.tools.list_ports -v
python examples/curses_dashboard.py /dev/cu.YOUR_RADIO_PORT
```
The XIAO's USB port identifies as a MicroPython board. Direct mode will not work with
that port; use `--xiao-bridge` when the radio is wired through the XIAO.
## Other boards
Ready-to-run examples are included for common MicroPython boards:
| Board | Example | UART | TX | RX |
| --- | --- | --- | --- | --- |
| Seeed Studio XIAO RP2040 | [`xiao_rp2040.py`](examples/xiao_rp2040.py) | UART0 | D6 / GPIO0 | D7 / GPIO1 |
| Raspberry Pi Pico / Pico W | [`raspberry_pi_pico.py`](examples/raspberry_pi_pico.py) | UART0 | GP0 | GP1 |
| Generic ESP32 | [`esp32.py`](examples/esp32.py) | UART2 | GPIO17 | GPIO16 |
Change the UART and pin numbers if your board uses a different mapping. See the
complete [examples guide](examples/README.md) for wiring notes.
## Troubleshooting
### `could not enter raw repl`
Use the exact port instead of `connect auto`:
```sh
mpremote connect list
mpremote connect /dev/cu.usbmodem2101 exec "print('MicroPython REPL OK')"
```
If it still fails, close every other serial application, unplug and reconnect the
XIAO, and try again.
### It repeatedly says `waiting for radio data`
The XIAO is running, but no complete Meshtastic frame has arrived. Check:
1. XIAO D6/TX goes to radio RX—not radio TX.
2. XIAO D7/RX goes to radio TX.
3. Both boards share GND.
4. The radio UART is enabled in `PROTO` mode at 115200 baud.
5. The configured radio UART pins match the pins you physically connected.
### The desktop curses dashboard stays at `configuration requested`
If the radio is wired through the XIAO, install `xiao_dashboard_bridge.py` as
`main.py` and include `--xiao-bridge` in the dashboard command. Without the bridge,
the desktop sends Meshtastic data to the MicroPython REPL instead of UART0.
Also confirm that `mpremote` or another serial monitor is not holding the port.
### The node count appears, but no messages appear
The node list is stored data downloaded from the radio. It is not a message history.
Send a **new** text from another Meshtastic node while MicroMesh is running.
### A frame is skipped or reports a decode error
Meshtastic's protobuf schema evolves. MicroMesh reports and skips newer messages that
its compact schema does not yet understand. Text and other supported packets continue
processing. Update the installed files after pulling a newer MicroMesh version:
```sh
mpremote connect /dev/cu.usbmodem2101 mip install package.json
```
### The serial port is busy
One port can have only one owner. Quit the dashboard before using `mpremote`, and close
`mpremote` before restarting the dashboard.
## Python API
Here is the smallest useful program:
```python ```python
from machine import UART, Pin from machine import Pin, UART
from time import sleep_ms
from micromesh import PortNum, SerialInterface from micromesh import PortNum, SerialInterface
uart = UART(1, baudrate=115200, tx=Pin(17), rx=Pin(16), timeout=0)
def received(packet): def received(packet):
if packet.WhichOneof("payload_variant") != "decoded": if packet.WhichOneof("payload_variant") != "decoded":
return return
if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP: if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP:
print("from !%08x:" % packet.from_, packet.decoded.payload.decode()) print("from !%08x: %s" % (
packet.from_,
packet.decoded.payload.decode("utf-8"),
))
uart = UART(0, 115200, tx=Pin(0), rx=Pin(1), timeout=0, rxbuf=1024)
mesh = SerialInterface(uart, on_packet=received) mesh = SerialInterface(uart, on_packet=received)
mesh.connect() mesh.connect()
mesh.sendText("hello mesh")
while True: while True:
mesh.poll() mesh.poll()
sleep_ms(10)
``` ```
The UART baud rate and pins depend on the attached Meshtastic device. `poll()` is ### Send packets
non-blocking when the UART is configured with `timeout=0`.
Ready-to-run wiring and code for the Seeed Studio XIAO RP2040, Raspberry Pi Pico, and
generic ESP32 boards are in [`examples/`](examples/README.md).
Direct messages accept either an integer or Meshtastic's hexadecimal node ID:
```python ```python
# Broadcast text on the primary channel
mesh.sendText("hello mesh")
# Direct message with an acknowledgement
mesh.sendText("hello", destinationId="!a1b2c3d4", wantAck=True) mesh.sendText("hello", destinationId="!a1b2c3d4", wantAck=True)
# Position packet
mesh.sendPosition(45.5152, -122.6784, altitude=15) mesh.sendPosition(45.5152, -122.6784, altitude=15)
# Custom application data
mesh.sendData(b"custom", portNum=PortNum.PRIVATE_APP) mesh.sendData(b"custom", portNum=PortNum.PRIVATE_APP)
``` ```
## Protobufs Payloads may be at most 233 bytes. `poll()` is non-blocking when the UART uses
`timeout=0`, so call it frequently from your main loop.
Messages can be used without a radio: ### Inspect connection state
```python
if mesh.config_complete:
print("connected to", mesh.my_info.my_node_num)
print("known nodes:", len(mesh.nodes))
for number, info in mesh.nodes.items():
name = info.user.long_name if info.HasField("user") else "unknown"
print("!%08x %s" % (number, name))
```
### Use the lightweight protobuf codec
```python ```python
from micromesh import Data, PortNum from micromesh import Data, PortNum
data = Data(portnum=PortNum.TEXT_MESSAGE_APP, payload=b"hello") message = Data(portnum=PortNum.TEXT_MESSAGE_APP, payload=b"hello")
encoded = data.SerializeToString() encoded = message.SerializeToString()
decoded = Data().ParseFromString(encoded) decoded = Data().ParseFromString(encoded)
print(decoded.payload)
``` ```
Generated-module-style imports are available as `micromesh.mesh_pb2` and Generated-module-style imports are available as `micromesh.mesh_pb2` and
`micromesh.portnums_pb2` for easier porting from the desktop library. `micromesh.portnums_pb2`. A protobuf field named `from` is accessed as `from_` because
`from` is a Python keyword.
Only the commonly useful schemas are modeled. Unknown fields survive a decode/encode ## Installation alternatives
round trip, but their contents are not interpreted. A field named `from` is accessed as
`packet.from_` because `from` is a Python keyword. Install from a local checkout with MicroPython's package installer:
```sh
mpremote mip install package.json
```
Or copy the package directory manually:
```sh
mpremote fs cp -r micromesh :lib/
```
For CPython development:
```sh
python -m pip install -e .
```
After the first PyPI release:
```sh
python -m pip install micromesh
```
## Supported scope
MicroMesh currently models the most useful portions of the Meshtastic API:
- `ToRadio` / `FromRadio` framing and configuration handshake
- `MeshPacket`, `Data`, `Position`, `User`, and `NodeInfo`
- Device metrics, waypoints, routing, queue status, and log records
- Unknown-field preservation and stream resynchronization
Large configuration, admin, metadata, and module-configuration messages are retained
as encoded bytes rather than fully interpreted.
The protocol definitions come from
[`meshtastic/protobufs`](https://github.com/meshtastic/protobufs).
## Development ## Development
Set up the project and run the tests:
```sh ```sh
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -e .
python -m unittest discover -s tests python -m unittest discover -s tests
``` ```
The test suite covers encoding, decoding, framing, serial recovery, sending, and
configuration state.
## Releasing to PyPI ## Releasing to PyPI
PyPI publishing uses GitHub Trusted Publishing, so no API token is stored in the Maintainers publish with GitHub Trusted Publishing; no long-lived PyPI token is stored
repository. Configure a pending publisher at in the repository. Configure the pending publisher with:
[`pypi.org/manage/account/publishing`](https://pypi.org/manage/account/publishing/)
with these values:
- PyPI project name: `micromesh` | Setting | Value |
- GitHub owner: `pdxlocations` | --- | --- |
- Repository: `micromesh` | PyPI project | `micromesh` |
- Workflow: `release.yaml` | GitHub owner | `pdxlocations` |
- Environment: `pypi` | Repository | `micromesh` |
| Workflow | `release.yaml` |
| Environment | `pypi` |
Keep the version in `pyproject.toml`, `package.json`, and `micromesh/__init__.py` in Keep the version in `pyproject.toml`, `package.json`, and `micromesh/__init__.py` in
sync. Push the change, create a matching GitHub release such as `v0.1.0`, and publish sync. Publishing a matching GitHub release, such as `v0.1.0`, builds, validates, and
the release. The workflow builds, validates, and uploads both the wheel and source uploads the wheel and source distribution.
distribution.
The API protocol is defined by the ## License
[`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) project. Update the
field declarations in `micromesh/messages.py` when adopting newer schemas.
`manifest.py` is also included for freezing the package into custom MicroPython firmware. MicroMesh is released under the [GNU General Public License v3.0 or later](LICENSE).
+29
View File
@@ -10,6 +10,35 @@ Meshtastic radio. The radio must have its serial module enabled in `PROTO` mode
| Raspberry Pi Pico / Pico W | `raspberry_pi_pico.py` | UART0 | GP0 | GP1 | | Raspberry Pi Pico / Pico W | `raspberry_pi_pico.py` | UART0 | GP0 | GP1 |
| Generic ESP32 | `esp32.py` | UART2 | GPIO17 | GPIO16 | | Generic ESP32 | `esp32.py` | UART2 | GPIO17 | GPIO16 |
## Desktop curses dashboard
`curses_dashboard.py` is a host-side dashboard for macOS or Linux. It displays
connection statistics, known nodes, and received messages, and it can send broadcast
messages. It can connect directly to a radio or through a XIAO RP2040 UART bridge.
```sh
python -m pip install -e . pyserial
python examples/curses_dashboard.py /dev/cu.usbmodem123456
```
For a radio wired to a XIAO RP2040, first install the bridge as the XIAO's boot script:
```sh
mpremote connect /dev/cu.usbmodem2101 fs cp examples/xiao_dashboard_bridge.py :main.py
mpremote connect /dev/cu.usbmodem2101 reset
python examples/curses_dashboard.py --xiao-bridge /dev/cu.usbmodem2101
```
Do not run `mpremote` at the same time as the dashboard; only one program can own the
XIAO USB serial port. To return to the regular example, replace `main.py` with
`xiao_rp2040.py`.
Use `mpremote connect list`, `python -m serial.tools.list_ports`, or your operating
system's device list to find the radio port. The message field is always active: type
and press Enter to send. Use Escape to clear it, F5 to refresh, and F10 to quit.
`Ctrl-R` and `Ctrl-Q` are also supported. On Windows, install `windows-curses` and use
a port such as `COM4`.
## Wiring ## Wiring
UART signals cross between the two boards: UART signals cross between the two boards:
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""Curses dashboard for a Meshtastic radio connected to a desktop computer.
Install pyserial, then pass the radio's serial device:
python -m pip install -e . pyserial
python examples/curses_dashboard.py /dev/cu.usbmodem123456
The message field is always active. Enter sends, Escape clears, F5 refreshes, and
F10 quits. Ctrl-R and Ctrl-Q are also supported.
"""
import argparse
import curses
import json
import time
from collections import deque
from micromesh import PortNum, SerialInterface
EVENT_PREFIX = "MMEVT "
COMMAND_PREFIX = "MMCMD "
class PySerialUART:
"""Give pyserial the small UART interface expected by MicroMesh."""
def __init__(self, port):
self.port = port
def any(self):
return self.port.in_waiting
def read(self, count=None):
return self.port.read(count or 1)
def write(self, data):
return self.port.write(data)
class DashboardState:
def __init__(self):
self.started = time.monotonic()
self.frames = 0
self.packets = 0
self.text_received = 0
self.text_sent = 0
self.decode_errors = 0
self.messages = deque(maxlen=200)
self.status = "starting"
self.draft = ""
def add_message(self, source, destination, text):
self.messages.append((time.strftime("%H:%M:%S"), source, destination, text))
class BridgeRecord:
"""Small generated-message lookalike used by bridge events."""
def __init__(self, **values):
self._present = set(values)
for name, value in values.items():
setattr(self, name, value)
def HasField(self, name):
return name in self._present
def __getattr__(self, name):
return 0
class XiaoBridge:
"""Desktop side of the JSON-lines bridge running on the XIAO."""
def __init__(self, port, state):
self.port = port
self.state = state
self.my_info = None
self.nodes = {}
self.config_complete = False
self._buffer = bytearray()
def _command(self, kind, **values):
command = {"type": kind}
command.update(values)
line = COMMAND_PREFIX + json.dumps(command, separators=(",", ":")) + "\n"
self.port.write(line.encode("utf-8"))
def connect(self):
self._command("refresh")
def request_config(self):
self.config_complete = False
self._command("refresh")
def sendText(self, text):
self._command("send", text=text)
def poll(self):
waiting = self.port.in_waiting
if waiting:
self._buffer.extend(self.port.read(waiting))
while True:
newline = self._buffer.find(b"\n")
if newline < 0:
break
raw = bytes(self._buffer[:newline])
del self._buffer[:newline + 1]
line = raw.decode("utf-8", "replace")
marker = line.find(EVENT_PREFIX)
if marker < 0:
continue
try:
self._event(json.loads(line[marker + len(EVENT_PREFIX):]))
except (ValueError, TypeError) as error:
self.state.decode_errors += 1
self.state.status = "bad bridge event: %s" % error
def _event(self, event):
kind = event.get("type")
if kind == "frame":
self.state.frames += 1
self.state.status = "received %s" % event.get("variant", "frame")
elif kind == "local":
self.my_info = BridgeRecord(my_node_num=event["num"])
elif kind == "config":
self.config_complete = bool(event.get("complete"))
self.state.status = "configuration complete"
elif kind == "node":
user = BridgeRecord(
id=event.get("user_id", ""),
long_name=event.get("name", "unknown"),
short_name=event.get("short", "--"),
)
values = {"num": event["num"], "user": user}
for source, target in (
("snr", "snr"), ("last_heard", "last_heard"),
("hops", "hops_away"),
):
if source in event:
values[target] = event[source]
if "battery" in event:
values["device_metrics"] = BridgeRecord(battery_level=event["battery"])
self.nodes[event["num"]] = BridgeRecord(**values)
elif kind == "packet":
self.state.packets += 1
if event.get("is_text"):
self.state.text_received += 1
self.state.add_message(
node_id(event.get("source", 0)),
node_id(event.get("destination", 0)),
event.get("text", "[packet]"),
)
elif kind == "error":
self.state.decode_errors += 1
self.state.status = event.get("text", "bridge error")
elif kind == "status":
self.state.status = event.get("text", "bridge status")
elif kind == "heartbeat":
self.config_complete = bool(event.get("connected"))
self.state.status = "bridge connected" if self.config_complete else "bridge waiting"
def node_id(number):
return "!%08x" % int(number)
def age(timestamp):
if not timestamp:
return "--"
seconds = max(0, int(time.time()) - int(timestamp))
if seconds < 60:
return "%ds" % seconds
if seconds < 3600:
return "%dm" % (seconds // 60)
if seconds < 86400:
return "%dh" % (seconds // 3600)
return "%dd" % (seconds // 86400)
def clip(value, width):
value = str(value)
if width <= 0:
return ""
if len(value) <= width:
return value
if width == 1:
return value[:1]
return value[:width - 1] + "…"
def node_values(info):
name = "unknown"
short = "--"
if info.HasField("user"):
name = info.user.long_name or info.user.id or name
short = info.user.short_name or short
snr = "%.1f" % info.snr if info.HasField("snr") else "--"
battery = "--"
if info.HasField("device_metrics") and info.device_metrics.HasField("battery_level"):
battery = "%d%%" % info.device_metrics.battery_level
hops = str(info.hops_away) if info.HasField("hops_away") else "--"
heard = age(info.last_heard) if info.HasField("last_heard") else "--"
return node_id(info.num), name, short, snr, battery, hops, heard
def add_line(screen, row, text, style=0):
height, width = screen.getmaxyx()
if 0 <= row < height and width > 1:
try:
screen.addnstr(row, 0, text, width - 1, style)
except curses.error:
pass
def draw(screen, mesh, state):
screen.erase()
height, width = screen.getmaxyx()
title_style = curses.A_BOLD
if curses.has_colors():
title_style |= curses.color_pair(1)
add_line(screen, 0, " MicroMesh dashboard ", title_style)
local = node_id(mesh.my_info.my_node_num) if mesh.my_info else "unknown"
connected = "yes" if mesh.config_complete else "waiting"
uptime = int(time.monotonic() - state.started)
add_line(
screen,
1,
"Local: %s Config: %s Uptime: %ds Nodes: %d" % (
local, connected, uptime, len(mesh.nodes)
),
)
add_line(
screen,
2,
"Frames: %d Packets: %d Text RX/TX: %d/%d Decode errors: %d" % (
state.frames,
state.packets,
state.text_received,
state.text_sent,
state.decode_errors,
),
)
nodes_top = 4
messages_height = max(5, height // 3)
messages_top = max(nodes_top + 3, height - messages_height - 3)
node_rows = max(1, messages_top - nodes_top - 2)
add_line(screen, nodes_top, "Nodes", title_style)
add_line(screen, nodes_top + 1, "ID Name Sh SNR Batt Hops Seen")
nodes = sorted(mesh.nodes.values(), key=lambda item: item.last_heard, reverse=True)
for offset, info in enumerate(nodes[:node_rows]):
values = node_values(info)
line = "%-10s %-20s %-4s %5s %5s %4s %4s" % (
values[0], clip(values[1], 20), clip(values[2], 4),
values[3], values[4], values[5], values[6],
)
add_line(screen, nodes_top + 2 + offset, line)
add_line(screen, messages_top, "Messages", title_style)
visible = max(1, height - messages_top - 4)
for offset, message in enumerate(list(state.messages)[-visible:]):
when, source, destination, text = message
prefix = "%s %s>%s " % (when, source, destination)
add_line(screen, messages_top + 1 + offset, prefix + clip(text, width - len(prefix) - 1))
add_line(screen, height - 3, "Status: " + state.status)
add_line(screen, height - 2, "Message: " + state.draft + "_", curses.A_REVERSE)
add_line(
screen,
height - 1,
"Type + Enter: send | Esc: clear | F5: refresh | F10: quit",
)
screen.refresh()
def run(screen, port_name, baudrate, xiao_bridge):
try:
import serial
except ImportError:
raise SystemExit("pyserial is required: python -m pip install pyserial")
state = DashboardState()
screen.nodelay(True)
screen.keypad(True)
try:
curses.curs_set(0)
except curses.error:
pass
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
serial_port = serial.Serial(port_name, baudrate=baudrate, timeout=0)
def on_receive(message):
state.frames += 1
state.status = "received %s" % (message.WhichOneof("payload_variant") or "frame")
def on_packet(packet):
state.packets += 1
if packet.WhichOneof("payload_variant") != "decoded":
state.add_message(node_id(packet.from_), node_id(packet.to), "[encrypted]")
return
data = packet.decoded
if data.portnum == PortNum.TEXT_MESSAGE_APP:
try:
text = data.payload.decode("utf-8")
except UnicodeError:
text = repr(data.payload)
state.text_received += 1
else:
text = "[port %d, %d bytes]" % (data.portnum, len(data.payload))
state.add_message(node_id(packet.from_), node_id(packet.to), text)
def on_error(error, payload):
state.decode_errors += 1
state.status = "skipped frame: %s" % error
if xiao_bridge:
mesh = XiaoBridge(serial_port, state)
else:
mesh = SerialInterface(
PySerialUART(serial_port),
on_receive=on_receive,
on_packet=on_packet,
on_log=lambda line: setattr(state, "status", "radio: " + line),
on_error=on_error,
)
try:
mesh.connect()
state.status = "configuration requested"
while True:
mesh.poll()
draw(screen, mesh, state)
key = screen.getch()
if key in (17, curses.KEY_F10): # Ctrl-Q or F10
break
if key in (18, curses.KEY_F5): # Ctrl-R or F5
mesh.request_config()
state.status = "configuration requested"
elif key in (10, 13, curses.KEY_ENTER):
text = state.draft.strip()
state.draft = ""
if text:
try:
mesh.sendText(text)
state.text_sent += 1
state.add_message("local", "all", text)
state.status = "message queued"
except (ValueError, OSError) as error:
state.status = "send failed: %s" % error
elif key == 27: # Escape
state.draft = ""
state.status = "message cleared"
elif key in (8, 127, curses.KEY_BACKSPACE):
state.draft = state.draft[:-1]
elif 32 <= key < 127 and len(state.draft) < 200:
state.draft += chr(key)
time.sleep(0.05)
finally:
serial_port.close()
def main():
parser = argparse.ArgumentParser(description="MicroMesh curses dashboard")
parser.add_argument("port", help="Meshtastic serial port, such as /dev/cu.usbmodem1234")
parser.add_argument("--baud", type=int, default=115200, help="UART baud rate (default: 115200)")
parser.add_argument(
"--xiao-bridge",
action="store_true",
help="connect through xiao_dashboard_bridge.py running on a XIAO RP2040",
)
args = parser.parse_args()
curses.wrapper(run, args.port, args.baud, args.xiao_bridge)
if __name__ == "__main__":
main()
+162
View File
@@ -0,0 +1,162 @@
"""USB-to-Meshtastic bridge for the XIAO RP2040 curses dashboard.
Install this file as ``main.py`` on the XIAO. UART0 talks to the Meshtastic
radio on D6/D7, while newline-delimited JSON events and commands use USB stdio.
"""
import sys
try:
import ujson as json
except ImportError:
import json
try:
import uselect as select
except ImportError:
import select
from machine import Pin, UART
from time import sleep_ms, ticks_diff, ticks_ms
from micromesh import PortNum, SerialInterface
EVENT_PREFIX = "MMEVT "
COMMAND_PREFIX = "MMCMD "
def emit(event):
print(EVENT_PREFIX + json.dumps(event))
flush = getattr(sys.stdout, "flush", None)
if flush:
flush()
def on_packet(packet):
event = {
"type": "packet",
"source": packet.from_,
"destination": packet.to,
"portnum": 0,
"text": "[encrypted]",
"is_text": False,
}
if packet.WhichOneof("payload_variant") == "decoded":
data = packet.decoded
event["portnum"] = data.portnum
if data.portnum == PortNum.TEXT_MESSAGE_APP:
try:
event["text"] = data.payload.decode("utf-8")
except UnicodeError:
event["text"] = repr(data.payload)
event["is_text"] = True
else:
event["text"] = "[port %d, %d bytes]" % (
data.portnum, len(data.payload)
)
emit(event)
def node_event(info):
event = {"type": "node", "num": info.num}
if info.HasField("user"):
event["name"] = info.user.long_name or info.user.id or "unknown"
event["short"] = info.user.short_name or "--"
event["user_id"] = info.user.id
if info.HasField("snr"):
event["snr"] = info.snr
if info.HasField("last_heard"):
event["last_heard"] = info.last_heard
if info.HasField("hops_away"):
event["hops"] = info.hops_away
if info.HasField("device_metrics") and info.device_metrics.HasField("battery_level"):
event["battery"] = info.device_metrics.battery_level
return event
def on_receive(message):
variant = message.WhichOneof("payload_variant") or "unknown"
emit({"type": "frame", "variant": variant})
if variant == "my_info":
emit({"type": "local", "num": message.my_info.my_node_num})
elif variant == "node_info":
emit(node_event(message.node_info))
elif variant == "config_complete_id":
emit({
"type": "config",
"complete": message.config_complete_id == mesh.config_id,
"nodes": len(mesh.nodes),
})
def on_error(error, payload):
emit({"type": "error", "text": str(error), "bytes": len(payload)})
uart = UART(
0,
baudrate=115200,
tx=Pin(0), # XIAO D6
rx=Pin(1), # XIAO D7
timeout=0,
rxbuf=2048,
)
mesh = SerialInterface(
uart,
on_receive=on_receive,
on_packet=on_packet,
on_log=lambda line: emit({"type": "status", "text": "radio: " + line}),
on_error=on_error,
)
command_poll = select.poll()
command_poll.register(sys.stdin, select.POLLIN)
def handle_commands():
for _ in command_poll.poll(0):
first = sys.stdin.read(1)
if first == "\x03":
# Never consume the interrupt mpremote uses to regain the REPL.
raise KeyboardInterrupt
# Dashboard commands are always newline-terminated. Reading the rest
# as one line also drains bytes MicroPython buffered after the first
# character but no longer reports through select.poll().
line = first + sys.stdin.readline()
line = line.strip()
if not line.startswith(COMMAND_PREFIX):
continue
try:
command = json.loads(line[len(COMMAND_PREFIX):])
kind = command.get("type")
if kind == "send":
text = command.get("text", "")
if text:
mesh.sendText(text)
emit({"type": "status", "text": "message queued"})
elif kind == "refresh":
mesh.request_config()
emit({"type": "status", "text": "configuration requested"})
except Exception as error:
emit({"type": "error", "text": "command: " + str(error)})
emit({"type": "status", "text": "XIAO bridge starting"})
mesh.connect()
emit({"type": "status", "text": "configuration requested"})
last_status = ticks_ms()
while True:
mesh.poll()
handle_commands()
now = ticks_ms()
if ticks_diff(now, last_status) >= 5000:
emit({
"type": "heartbeat",
"connected": mesh.config_complete,
"nodes": len(mesh.nodes),
})
last_status = now
sleep_ms(10)
Binary file not shown.
+7 -1
View File
@@ -60,7 +60,13 @@ class StreamParser:
try: try:
line = bytes(self._log).decode("utf-8") line = bytes(self._log).decode("utf-8")
except UnicodeError: except UnicodeError:
line = bytes(self._log).decode("utf-8", "replace") # Some MicroPython ports do not implement the optional
# ``errors`` argument to bytes.decode(). Preserve readable
# ASCII and replace other bytes without invoking a codec.
line = "".join(
chr(value) if 32 <= value < 127 else "?"
for value in self._log
)
self.on_log(line) self.on_log(line)
self._log = bytearray() self._log = bytearray()
elif len(self._log) < 256: elif len(self._log) < 256:
Binary file not shown.
+6
View File
@@ -77,6 +77,12 @@ class MicroMeshTests(unittest.TestCase):
self.assertEqual(packets, [b"one", b"two"]) self.assertEqual(packets, [b"one", b"two"])
self.assertEqual(logs, ["debug line"]) self.assertEqual(logs, ["debug line"])
def test_stream_parser_sanitizes_invalid_utf8_logs(self):
logs = []
parser = StreamParser(on_log=logs.append)
self.assertEqual(parser.feed(b"bad:\xff\xfe\n"), [])
self.assertEqual(logs, ["bad:??"])
def test_send_text_builds_to_radio_frame(self): def test_send_text_builds_to_radio_frame(self):
import micromesh.interface as interface_module import micromesh.interface as interface_module
old_packet_id = interface_module._packet_id old_packet_id = interface_module._packet_id