Add curses dashboard and XIAO bridge examples; enhance stream parser for UTF-8 logging
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user