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
+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 |
| 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
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)