diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a8237f8..55ae724 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,6 +25,9 @@ jobs: - name: Install build tools run: python -m pip install --upgrade build twine + - name: Run tests + run: python -m unittest discover -s tests + - name: Build wheel and source distribution run: python -m build diff --git a/README.md b/README.md index 2b0dffa..dbac078 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ three wires, and regular Python. - 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 +- Recover from serial noise and optionally report and skip undecodable frames - Run a curses dashboard with live stats, nodes, and messages - Work on MicroPython without third-party runtime dependencies @@ -59,16 +59,21 @@ You need: - 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 +- Python 3.8 or newer on your Mac, Linux computer, or Windows PC -Configure the Meshtastic radio's serial interface for: +For the UART-through-microcontroller setups, configure the Meshtastic radio's serial +module for: ```text Enabled: yes Mode: PROTO -Baud: 115200 +Baud: 115200 (must match the MicroPython UART) ``` +Meshtastic's serial-module default is 38400 baud; these examples deliberately use +115200. Set both ends to the same value. A direct USB connection to the radio does not +use this external-UART setup. + The radio's UART pin names depend on its model. Consult that board's pinout before connecting wires. @@ -168,8 +173,14 @@ 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`. +To replace an automatically running script, upload a different `main.py`; `mpremote` +stops the running program before filesystem commands. To disable automatic startup +without replacing it: + +```sh +mpremote connect /dev/cu.usbmodem2101 rm :main.py +mpremote connect /dev/cu.usbmodem2101 reset +``` ## Curses dashboard through the XIAO @@ -178,10 +189,17 @@ It displays live connection stats, known nodes, battery levels, SNR, hops, last- times, and incoming messages. The XIAO runs a small bridge between the radio UART and the dashboard over USB. +The header shows `Config: complete` after a matching configuration handshake. Some +radios do not return the matching completion ID; once local and node data are usable, +the dashboard shows `Config: ready` instead. + 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. +Complete the XIAO quick-start installation through step 4 first, so the `micromesh` +package is present on the board. + ### 1. Install the desktop dependencies ```sh @@ -214,7 +232,8 @@ Only one application can own a serial port. Close `mpremote`, Arduino Serial Mon 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. +compose mode. `message queued` means the radio accepted the packet for transmission; +it is not a delivery receipt. | Key | Action | | --- | --- | @@ -296,9 +315,11 @@ 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: +Meshtastic's protobuf schema evolves. Unknown fields are retained automatically. When +an `on_error` callback is configured—as it is in the board examples—MicroMesh reports +and skips a frame if it is malformed or conflicts with the compact schema; later +frames continue processing. Without that callback, `poll()` raises the decoding error. +Update the installed files after pulling a newer MicroMesh version: ```sh mpremote connect /dev/cu.usbmodem2101 mip install package.json @@ -324,14 +345,19 @@ def received(packet): if packet.WhichOneof("payload_variant") != "decoded": return if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP: - print("from !%08x: %s" % ( - packet.from_, - packet.decoded.payload.decode("utf-8"), - )) + try: + text = packet.decoded.payload.decode("utf-8") + except UnicodeError: + text = repr(packet.decoded.payload) + print("from !%08x: %s" % (packet.from_, text)) + + +def decode_error(error, payload): + print("skipping undecodable frame:", error, "(%d bytes)" % len(payload)) 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, on_error=decode_error) mesh.connect() while True: @@ -355,8 +381,9 @@ mesh.sendPosition(45.5152, -122.6784, altitude=15) mesh.sendData(b"custom", portNum=PortNum.PRIVATE_APP) ``` -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. +Data payloads may be at most 233 bytes, including UTF-8 encoded text bytes rather than +characters. `poll()` is non-blocking when the UART uses `timeout=0`, so call it +frequently from your main loop. ### Inspect connection state @@ -437,8 +464,8 @@ python -m pip install -e . python -m unittest discover -s tests ``` -The test suite covers encoding, decoding, framing, serial recovery, sending, and -configuration state. +The test suite covers encoding, decoding, field presence, framing, serial recovery, +sending, input validation, and configuration state. ## Releasing to PyPI diff --git a/examples/README.md b/examples/README.md index d71e889..40cb223 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,8 +1,9 @@ # Board examples These examples connect a MicroPython board to the hardware UART exposed by a -Meshtastic radio. The radio must have its serial module enabled in `PROTO` mode at -115200 baud. +Meshtastic radio. The radio must have its serial module enabled in `PROTO` mode. The +examples choose 115200 baud, so configure the radio to match; Meshtastic's serial +module defaults to 38400 baud. | Board | Example | MicroPython UART | TX pin | RX pin | | --- | --- | --- | --- | --- | @@ -21,9 +22,11 @@ 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: +For a radio wired to a XIAO RP2040, install MicroMesh and then install the bridge as +the XIAO's boot script: ```sh +mpremote connect /dev/cu.usbmodem2101 mip install package.json 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 @@ -37,7 +40,7 @@ Use `mpremote connect list`, `python -m serial.tools.list_ports`, or your operat 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`. +a port such as `COM4`. A `message queued` status is not a delivery receipt. ## Wiring diff --git a/examples/curses_dashboard.py b/examples/curses_dashboard.py index 32eebbb..3f5f1ce 100644 --- a/examples/curses_dashboard.py +++ b/examples/curses_dashboard.py @@ -21,6 +21,7 @@ from micromesh import PortNum, SerialInterface EVENT_PREFIX = "MMEVT " COMMAND_PREFIX = "MMCMD " +MAX_BRIDGE_BUFFER = 8192 class PySerialUART: @@ -85,7 +86,10 @@ class XiaoBridge: command = {"type": kind} command.update(values) line = COMMAND_PREFIX + json.dumps(command, separators=(",", ":")) + "\n" - self.port.write(line.encode("utf-8")) + data = line.encode("utf-8") + written = self.port.write(data) + if written is not None and written != len(data): + raise OSError("short serial write") def connect(self): self._command("refresh") @@ -101,6 +105,11 @@ class XiaoBridge: waiting = self.port.in_waiting if waiting: self._buffer.extend(self.port.read(waiting)) + if len(self._buffer) > MAX_BRIDGE_BUFFER and b"\n" not in self._buffer: + self._buffer = bytearray() + self.state.decode_errors += 1 + self.state.status = "discarded oversized bridge line" + return while True: newline = self._buffer.find(b"\n") if newline < 0: @@ -112,8 +121,11 @@ class XiaoBridge: if marker < 0: continue try: - self._event(json.loads(line[marker + len(EVENT_PREFIX):])) - except (ValueError, TypeError) as error: + event = json.loads(line[marker + len(EVENT_PREFIX):]) + if not isinstance(event, dict): + raise ValueError("event is not an object") + self._event(event) + except (KeyError, ValueError, TypeError) as error: self.state.decode_errors += 1 self.state.status = "bad bridge event: %s" % error @@ -206,6 +218,17 @@ def node_values(info): return node_id(info.num), name, short, snr, battery, hops, heard +def config_status(mesh, state): + """Describe useful progress even when the completion ID never matches.""" + if mesh.config_complete: + return "complete" + if mesh.my_info is not None and mesh.nodes: + return "ready" + if state.frames or mesh.my_info is not None or mesh.nodes: + return "receiving" + return "waiting" + + def add_line(screen, row, text, style=0): height, width = screen.getmaxyx() if 0 <= row < height and width > 1: @@ -225,13 +248,13 @@ def draw(screen, mesh, state): 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" + config = config_status(mesh, state) uptime = int(time.monotonic() - state.started) add_line( screen, 1, "Local: %s Config: %s Uptime: %ds Nodes: %d" % ( - local, connected, uptime, len(mesh.nodes) + local, config, uptime, len(mesh.nodes) ), ) add_line( @@ -348,10 +371,10 @@ def run(screen, port_name, baudrate, xiao_bridge): 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.draft = "" state.text_sent += 1 state.add_message("local", "all", text) state.status = "message queued" @@ -372,7 +395,10 @@ def run(screen, port_name, baudrate, xiao_bridge): 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( + "--baud", type=int, default=115200, + help="UART baud rate (default: 115200)", + ) parser.add_argument( "--xiao-bridge", action="store_true", diff --git a/examples/esp32.py b/examples/esp32.py index 98ae97c..6fa12e8 100644 --- a/examples/esp32.py +++ b/examples/esp32.py @@ -18,13 +18,19 @@ def on_packet(packet): if packet.WhichOneof("payload_variant") != "decoded": return if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP: - print("from !%08x: %s" % ( - packet.from_, packet.decoded.payload.decode("utf-8", "replace") - )) + try: + text = packet.decoded.payload.decode("utf-8") + except UnicodeError: + text = repr(packet.decoded.payload) + print("from !%08x: %s" % (packet.from_, text)) + + +def on_error(error, payload): + print("skipping undecodable radio frame:", error, "(%d bytes)" % len(payload)) uart = UART(2, baudrate=115200, tx=Pin(17), rx=Pin(16), timeout=0, rxbuf=1024) -mesh = SerialInterface(uart, on_packet=on_packet, on_log=print) +mesh = SerialInterface(uart, on_packet=on_packet, on_log=print, on_error=on_error) mesh.connect() hello_sent = False diff --git a/examples/raspberry_pi_pico.py b/examples/raspberry_pi_pico.py index 6cbf932..9f0a13d 100644 --- a/examples/raspberry_pi_pico.py +++ b/examples/raspberry_pi_pico.py @@ -16,13 +16,19 @@ def on_packet(packet): if packet.WhichOneof("payload_variant") != "decoded": return if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP: - print("from !%08x: %s" % ( - packet.from_, packet.decoded.payload.decode("utf-8", "replace") - )) + try: + text = packet.decoded.payload.decode("utf-8") + except UnicodeError: + text = repr(packet.decoded.payload) + print("from !%08x: %s" % (packet.from_, text)) + + +def on_error(error, payload): + print("skipping undecodable radio frame:", error, "(%d bytes)" % len(payload)) uart = UART(0, 115200, tx=Pin(0), rx=Pin(1), timeout=0, rxbuf=1024) -mesh = SerialInterface(uart, on_packet=on_packet, on_log=print) +mesh = SerialInterface(uart, on_packet=on_packet, on_log=print, on_error=on_error) mesh.connect() hello_sent = False diff --git a/micromesh/__pycache__/__init__.cpython-311.pyc b/micromesh/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index d1612a5..0000000 Binary files a/micromesh/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/micromesh/__pycache__/interface.cpython-311.pyc b/micromesh/__pycache__/interface.cpython-311.pyc deleted file mode 100644 index 3f45c8f..0000000 Binary files a/micromesh/__pycache__/interface.cpython-311.pyc and /dev/null differ diff --git a/micromesh/__pycache__/mesh_pb2.cpython-311.pyc b/micromesh/__pycache__/mesh_pb2.cpython-311.pyc deleted file mode 100644 index 9346c38..0000000 Binary files a/micromesh/__pycache__/mesh_pb2.cpython-311.pyc and /dev/null differ diff --git a/micromesh/__pycache__/messages.cpython-311.pyc b/micromesh/__pycache__/messages.cpython-311.pyc deleted file mode 100644 index 5aec2a8..0000000 Binary files a/micromesh/__pycache__/messages.cpython-311.pyc and /dev/null differ diff --git a/micromesh/__pycache__/portnums.cpython-311.pyc b/micromesh/__pycache__/portnums.cpython-311.pyc deleted file mode 100644 index fc4ce52..0000000 Binary files a/micromesh/__pycache__/portnums.cpython-311.pyc and /dev/null differ diff --git a/micromesh/__pycache__/portnums_pb2.cpython-311.pyc b/micromesh/__pycache__/portnums_pb2.cpython-311.pyc deleted file mode 100644 index b60fbfd..0000000 Binary files a/micromesh/__pycache__/portnums_pb2.cpython-311.pyc and /dev/null differ diff --git a/micromesh/__pycache__/protobuf.cpython-311.pyc b/micromesh/__pycache__/protobuf.cpython-311.pyc deleted file mode 100644 index 2ca5be2..0000000 Binary files a/micromesh/__pycache__/protobuf.cpython-311.pyc and /dev/null differ diff --git a/micromesh/__pycache__/stream.cpython-311.pyc b/micromesh/__pycache__/stream.cpython-311.pyc deleted file mode 100644 index 0404023..0000000 Binary files a/micromesh/__pycache__/stream.cpython-311.pyc and /dev/null differ diff --git a/micromesh/interface.py b/micromesh/interface.py index b4d9ff3..1f20135 100644 --- a/micromesh/interface.py +++ b/micromesh/interface.py @@ -34,14 +34,21 @@ def node_num(value): value = value.strip() if value.startswith("!"): value = value[1:] - return int(value, 16) - return int(value) + value = int(value, 16) + else: + value = int(value) + if not 0 <= value <= 0xFFFFFFFF: + raise ValueError("node number must fit in 32 bits") + return value class SerialInterface: """A non-blocking Meshtastic client around a ``machine.UART``-like object. - ``poll()`` should be called regularly. Callbacks receive ``FromRadio`` objects. + ``poll()`` should be called regularly. ``on_receive`` receives each + ``FromRadio`` message, ``on_packet`` receives its ``MeshPacket`` payload, + ``on_log`` receives a text line, and ``on_error`` receives an exception and + the undecodable frame payload. """ def __init__(self, uart, on_receive=None, on_packet=None, on_log=None, @@ -102,6 +109,8 @@ class SerialInterface: def sendText(self, text, destinationId=BROADCAST_ADDR, wantAck=False, wantResponse=False, channelIndex=0, hopLimit=None): + if not isinstance(text, str): + raise TypeError("text must be a string") return self.sendData(text.encode("utf-8"), destinationId, PortNum.TEXT_MESSAGE_APP, wantAck, wantResponse, channelIndex, hopLimit) @@ -109,10 +118,15 @@ class SerialInterface: def sendPosition(self, latitude, longitude, altitude=0, destinationId=BROADCAST_ADDR, wantAck=False, channelIndex=0, hopLimit=None): + latitude = float(latitude) + longitude = float(longitude) + if not -90 <= latitude <= 90: + raise ValueError("latitude must be between -90 and 90") + if not -180 <= longitude <= 180: + raise ValueError("longitude must be between -180 and 180") position = Position(latitude_i=int(latitude * 10000000), - longitude_i=int(longitude * 10000000)) - if altitude: - position.altitude = int(altitude) + longitude_i=int(longitude * 10000000), + altitude=int(altitude)) return self.sendData(position, destinationId, PortNum.POSITION_APP, wantAck, False, channelIndex, hopLimit) diff --git a/micromesh/messages.py b/micromesh/messages.py index cc78897..75424d4 100644 --- a/micromesh/messages.py +++ b/micromesh/messages.py @@ -17,7 +17,8 @@ class Position(ProtoMessage): altitude_hae=Field(9, "sint", optional=True), altitude_geoidal_separation=Field(10, "sint", optional=True), PDOP=Field(11), HDOP=Field(12), VDOP=Field(13), gps_accuracy=Field(14), - ground_speed=Field(15, optional=True), ground_track=Field(16, optional=True), fix_quality=Field(17), + ground_speed=Field(15, optional=True), ground_track=Field(16, optional=True), + fix_quality=Field(17), fix_type=Field(18), sats_in_view=Field(19), sensor_id=Field(20), next_update=Field(21), seq_number=Field(22), precision_bits=Field(23), ) @@ -55,6 +56,12 @@ class Routing(ProtoMessage): DUTY_CYCLE_LIMIT = 9 BAD_REQUEST = 32 NOT_AUTHORIZED = 33 + PKI_FAILED = 34 + PKI_UNKNOWN_PUBKEY = 35 + ADMIN_BAD_SESSION_KEY = 36 + ADMIN_PUBLIC_KEY_UNAUTHORIZED = 37 + RATE_LIMIT_EXCEEDED = 38 + PKI_SEND_FAIL_PUBLIC_KEY = 39 FIELDS = fields( route_request=Field(1, "message", RouteDiscovery), @@ -68,6 +75,7 @@ class Data(ProtoMessage): portnum=Field(1), payload=Field(2, "bytes"), want_response=Field(3, "bool"), dest=Field(4, "fixed32"), source=Field(5, "fixed32"), request_id=Field(6, "fixed32"), reply_id=Field(7, "fixed32"), emoji=Field(8, "fixed32"), bitfield=Field(9, optional=True), + xeddsa_signature=Field(10, "bytes"), ) @@ -91,11 +99,12 @@ class MeshPacket(ProtoMessage): "encrypted": Field(5, "bytes"), "id": Field(6, "fixed32"), "rx_time": Field(7, "fixed32", optional=True), "rx_snr": Field(8, "float"), "hop_limit": Field(9), "want_ack": Field(10, "bool"), - "priority": Field(11), "rx_rssi": Field(12, "int", optional=True), "delayed": Field(13), + "priority": Field(11), "rx_rssi": Field(12, "int", optional=True), + "delayed": Field(13), "via_mqtt": Field(14, "bool"), "hop_start": Field(15), "public_key": Field(16, "bytes"), "pki_encrypted": Field(17, "bool"), "next_hop": Field(18), "relay_node": Field(19), "tx_after": Field(20), - "transport_mechanism": Field(21), + "transport_mechanism": Field(21), "xeddsa_signed": Field(22, "bool"), } ) ONEOFS = {"payload_variant": ("decoded", "encrypted")} @@ -103,8 +112,9 @@ class MeshPacket(ProtoMessage): class DeviceMetrics(ProtoMessage): FIELDS = fields( - battery_level=Field(1), voltage=Field(2, "float"), channel_utilization=Field(3, "float"), - air_util_tx=Field(4, "float"), uptime_seconds=Field(5), + battery_level=Field(1, optional=True), voltage=Field(2, "float", optional=True), + channel_utilization=Field(3, "float", optional=True), + air_util_tx=Field(4, "float", optional=True), uptime_seconds=Field(5, optional=True), ) @@ -113,9 +123,10 @@ class NodeInfo(ProtoMessage): num=Field(1), user=Field(2, "message", User), position=Field(3, "message", Position), snr=Field(4, "float"), last_heard=Field(5, "fixed32"), device_metrics=Field(6, "message", DeviceMetrics), channel=Field(7), - via_mqtt=Field(8, "bool"), hops_away=Field(9, optional=True), is_favorite=Field(10, "bool"), + via_mqtt=Field(8, "bool"), hops_away=Field(9, optional=True), + is_favorite=Field(10, "bool"), is_ignored=Field(11, "bool"), is_key_manually_verified=Field(12, "bool"), - is_muted=Field(13, "bool"), + is_muted=Field(13, "bool"), has_xeddsa_signed=Field(14, "bool"), ) diff --git a/micromesh/protobuf.py b/micromesh/protobuf.py index 0698815..98f35cd 100644 --- a/micromesh/protobuf.py +++ b/micromesh/protobuf.py @@ -24,6 +24,8 @@ def encode_varint(value): value = int(value) if value < 0: value &= 0xFFFFFFFFFFFFFFFF + if value > 0xFFFFFFFFFFFFFFFF: + raise ValueError("varint exceeds 64 bits") out = bytearray() while value > 0x7F: out.append((value & 0x7F) | 0x80) @@ -39,6 +41,8 @@ def decode_varint(buf, offset=0): while offset < length and shift < 70: byte = buf[offset] offset += 1 + if shift == 63 and byte > 1: + raise DecodeError("invalid 64-bit varint") value |= (byte & 0x7F) << shift if not byte & 0x80: return value, offset @@ -65,7 +69,8 @@ def _skip(buf, offset, wire): class Field: - def __init__(self, number, kind="uint", message=None, repeated=False, packed=False, optional=False): + def __init__(self, number, kind="uint", message=None, repeated=False, + packed=False, optional=False): self.number = number self.kind = kind self.message = message @@ -153,9 +158,13 @@ class ProtoMessage: self._values[name] = value def HasField(self, name): + if name.endswith("_") and name[:-1] in type(self).FIELDS: + name = name[:-1] return name in self._values def ClearField(self, name): + if name.endswith("_") and name[:-1] in type(self).FIELDS: + name = name[:-1] self._values.pop(name, None) def WhichOneof(self, name): @@ -174,26 +183,30 @@ class ProtoMessage: continue value = self._values[name] in_oneof = any(name in members for members in type(self).ONEOFS.values()) - if not in_oneof and not field.optional and not field.repeated and field.kind != "message": + implicit_scalar = ( + not in_oneof and not field.optional and not field.repeated + and field.kind != "message" + ) + if implicit_scalar: if value in (0, False, b"", "", 0.0): continue values = value if field.repeated else (value,) if field.packed and values: body = bytearray() for item in values: - body.extend(self._encode_scalar(field, item, include_tag=False)) + body.extend(self._encode_scalar(field, item)) out.extend(encode_varint((field.number << 3) | BYTES)) out.extend(encode_varint(len(body))) out.extend(body) else: for item in values: out.extend(encode_varint((field.number << 3) | field.wire)) - out.extend(self._encode_scalar(field, item, include_tag=False)) + out.extend(self._encode_scalar(field, item)) for raw in self._unknown: out.extend(raw) return bytes(out) - def _encode_scalar(self, field, value, include_tag=False): + def _encode_scalar(self, field, value): kind = field.kind if kind == "message": data = value.SerializeToString() @@ -225,6 +238,8 @@ class ProtoMessage: start = offset tag, offset = decode_varint(data, offset) number, wire = tag >> 3, tag & 7 + if number == 0: + raise DecodeError("invalid protobuf field number: 0") entry = by_number.get(number) if entry is None: offset = _skip(data, offset, wire) @@ -238,7 +253,10 @@ class ProtoMessage: raise DecodeError("truncated length-delimited field") raw = data[offset:end] offset = end - if field.packed and field.wire != BYTES: + if field.wire != BYTES: + if not field.packed: + self._unknown.append(data[start:offset]) + continue values = self._values.setdefault(name, []) inner = 0 while inner < len(raw): @@ -303,7 +321,10 @@ class ProtoMessage: if isinstance(value, ProtoMessage): value = value.to_dict() elif isinstance(value, list): - value = [item.to_dict() if isinstance(item, ProtoMessage) else item for item in value] + value = [ + item.to_dict() if isinstance(item, ProtoMessage) else item + for item in value + ] result[name] = value return result diff --git a/pyproject.toml b/pyproject.toml index 9e6d831..474dbf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ license-files = ["LICENSE"] authors = [{name = "MicroMesh contributors"}] keywords = ["meshtastic", "micropython", "lora", "protobuf"] classifiers = [ + "Development Status :: 3 - Alpha", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: Implementation :: MicroPython", "Topic :: Communications :: Ham Radio", diff --git a/tests/__pycache__/test_meshmicro.cpython-311.pyc b/tests/__pycache__/test_meshmicro.cpython-311.pyc deleted file mode 100644 index aca38b2..0000000 Binary files a/tests/__pycache__/test_meshmicro.cpython-311.pyc and /dev/null differ diff --git a/tests/__pycache__/test_micromesh.cpython-311.pyc b/tests/__pycache__/test_micromesh.cpython-311.pyc deleted file mode 100644 index 801fb5a..0000000 Binary files a/tests/__pycache__/test_micromesh.cpython-311.pyc and /dev/null differ diff --git a/tests/test_micromesh.py b/tests/test_micromesh.py index 3a563e9..e34e9df 100644 --- a/tests/test_micromesh.py +++ b/tests/test_micromesh.py @@ -1,6 +1,9 @@ import unittest -from micromesh import Data, FromRadio, MeshPacket, PortNum, Position, SerialInterface, ToRadio +from micromesh import ( + Data, DeviceMetrics, FromRadio, MeshPacket, PortNum, Position, + SerialInterface, ToRadio, node_num, +) from micromesh.protobuf import DecodeError, decode_varint, encode_varint from micromesh.stream import StreamParser, frame @@ -30,10 +33,17 @@ class MicroMeshTests(unittest.TestCase): encoded = encode_varint(value) self.assertEqual(decode_varint(encoded), (value, len(encoded))) + def test_varint_rejects_values_wider_than_64_bits(self): + with self.assertRaises(ValueError): + encode_varint(1 << 64) + with self.assertRaises(DecodeError): + decode_varint(b"\xff" * 9 + b"\x02") + def test_known_data_wire_bytes(self): message = Data(portnum=PortNum.TEXT_MESSAGE_APP, payload=b"hello", want_response=True) self.assertEqual(message.SerializeToString(), b"\x08\x01\x12\x05hello\x18\x01") - self.assertEqual(Data().ParseFromString(message.SerializeToString()).to_dict(), message.to_dict()) + decoded = Data().ParseFromString(message.SerializeToString()) + self.assertEqual(decoded.to_dict(), message.to_dict()) def test_generated_style_imports(self): from micromesh import mesh_pb2, portnums_pb2 @@ -41,10 +51,17 @@ class MicroMeshTests(unittest.TestCase): self.assertEqual(portnums_pb2.PortNum.TEXT_MESSAGE_APP, 1) def test_proto3_scalar_defaults_are_omitted(self): - self.assertEqual(Data(portnum=0, payload=b"", want_response=False).SerializeToString(), b"") + empty = Data(portnum=0, payload=b"", want_response=False) + self.assertEqual(empty.SerializeToString(), b"") self.assertEqual(ToRadio(disconnect=False).SerializeToString(), b"\x20\x00") self.assertEqual(Position(latitude_i=0).SerializeToString(), b"\x0d\x00\x00\x00\x00") + def test_optional_device_metric_zero_is_preserved(self): + metrics = DeviceMetrics(battery_level=0, voltage=0.0) + decoded = DeviceMetrics().ParseFromString(metrics.SerializeToString()) + self.assertTrue(decoded.HasField("battery_level")) + self.assertTrue(decoded.HasField("voltage")) + def test_mesh_packet_fixed_fields_and_nested_message(self): packet = MeshPacket(**{"from": 0x12345678, "to": 0xFFFFFFFF, "id": 42}) packet.decoded.portnum = PortNum.TEXT_MESSAGE_APP @@ -55,18 +72,45 @@ class MicroMeshTests(unittest.TestCase): self.assertEqual(decoded.id, 42) self.assertEqual(decoded.decoded.payload, b"hi") + def test_current_signature_fields_round_trip(self): + data = Data(xeddsa_signature=b"signature") + packet = MeshPacket(decoded=data, xeddsa_signed=True) + decoded = MeshPacket().ParseFromString(packet.SerializeToString()) + self.assertEqual(decoded.decoded.xeddsa_signature, b"signature") + self.assertTrue(decoded.xeddsa_signed) + def test_signed_position(self): original = Position(latitude_i=-1220000000, longitude_i=455000000, altitude=-12) decoded = Position().ParseFromString(original.SerializeToString()) self.assertEqual(decoded.latitude_i, -1220000000) self.assertEqual(decoded.altitude, -12) + def test_send_position_keeps_zero_altitude_and_validates_coordinates(self): + uart = FakeUART() + packet = SerialInterface(uart).sendPosition(0, 0, altitude=0) + self.assertTrue(Position().ParseFromString(packet.decoded.payload).HasField("altitude")) + with self.assertRaises(ValueError): + SerialInterface(FakeUART()).sendPosition(91, 0) + + def test_node_numbers_must_fit_fixed32(self): + self.assertEqual(node_num("!ffffffff"), 0xFFFFFFFF) + with self.assertRaises(ValueError): + node_num(-1) + with self.assertRaises(ValueError): + node_num(1 << 32) + def test_unknown_fields_are_preserved(self): raw = b"\x08\x01" + encode_varint(99 << 3) + b"\x07" parsed = Data().ParseFromString(raw) self.assertEqual(parsed.portnum, 1) self.assertEqual(parsed.SerializeToString(), raw) + def test_known_field_with_wrong_wire_type_is_preserved_as_unknown(self): + raw = b"\x0a\x01x" + parsed = Data().ParseFromString(raw) + self.assertFalse(parsed.HasField("portnum")) + self.assertEqual(parsed.SerializeToString(), raw) + def test_stream_parser_handles_chunks_logs_and_resync(self): logs = [] parser = StreamParser(on_log=logs.append) @@ -132,6 +176,10 @@ class MicroMeshTests(unittest.TestCase): with self.assertRaises(DecodeError): Data().ParseFromString(b"\x12\x05no") + def test_field_number_zero_raises(self): + with self.assertRaises(DecodeError): + Data().ParseFromString(b"\x00\x00") + if __name__ == "__main__": unittest.main()