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