commit 9eaf3ce1c48bd9416e8a94680cb973232de1a76e Author: pdxlocations Date: Fri Jul 31 23:16:45 2026 -0700 init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..288e96b --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +This project is licensed under the GNU General Public License version 3 or any later +version. The full license text is available at https://www.gnu.org/licenses/gpl-3.0.txt. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b2977a --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# MicroMesh + +MicroMesh is a small, dependency-free Meshtastic client for MicroPython. It talks to a +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: + +- serial `ToRadio` / `FromRadio` framing and resynchronization; +- the configuration handshake and a small in-memory node list; +- 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 +[`meshtastic`](https://github.com/meshtastic/python) package. Configuration and admin +messages are currently returned as raw encoded bytes. + +## Install + +From a checkout of this repository, install with a current `mpremote`: + +```sh +mpremote mip install package.json +``` + +Alternatively, copy the `micromesh` directory to `/lib` on the board. After this +repository is published on GitHub it can also be installed as +`mpremote mip install github:ORG/micromesh`. + +It can also be installed on CPython for development: + +```sh +python -m pip install . +``` + +## Quick start + +```python +from machine import UART, Pin +from micromesh import PortNum, SerialInterface + +uart = UART(1, baudrate=115200, tx=Pin(17), rx=Pin(16), timeout=0) + +def received(packet): + if packet.WhichOneof("payload_variant") != "decoded": + return + if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP: + print("from !%08x:" % packet.from_, packet.decoded.payload.decode()) + +mesh = SerialInterface(uart, on_packet=received) +mesh.connect() +mesh.sendText("hello mesh") + +while True: + mesh.poll() +``` + +The UART baud rate and pins depend on the attached Meshtastic device. `poll()` is +non-blocking when the UART is configured with `timeout=0`. + +Direct messages accept either an integer or Meshtastic's hexadecimal node ID: + +```python +mesh.sendText("hello", destinationId="!a1b2c3d4", wantAck=True) +mesh.sendPosition(45.5152, -122.6784, altitude=15) +mesh.sendData(b"custom", portNum=PortNum.PRIVATE_APP) +``` + +## Protobufs + +Messages can be used without a radio: + +```python +from micromesh import Data, PortNum + +data = Data(portnum=PortNum.TEXT_MESSAGE_APP, payload=b"hello") +encoded = data.SerializeToString() +decoded = Data().ParseFromString(encoded) +``` + +Generated-module-style imports are available as `micromesh.mesh_pb2` and +`micromesh.portnums_pb2` for easier porting from the desktop library. + +Only the commonly useful schemas are modeled. Unknown fields survive a decode/encode +round trip, but their contents are not interpreted. A field named `from` is accessed as +`packet.from_` because `from` is a Python keyword. + +## Development + +```sh +python -m unittest discover -s tests +``` + +The API protocol is defined by the +[`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. diff --git a/manifest.py b/manifest.py new file mode 100644 index 0000000..8ef9ee4 --- /dev/null +++ b/manifest.py @@ -0,0 +1,2 @@ +metadata(version="0.1.0", description="Small Meshtastic client for MicroPython") +package("micromesh") diff --git a/micromesh/__init__.py b/micromesh/__init__.py new file mode 100644 index 0000000..8c8c0c2 --- /dev/null +++ b/micromesh/__init__.py @@ -0,0 +1,20 @@ +"""MicroMesh: a dependency-free Meshtastic client for MicroPython.""" + +from .interface import BROADCAST_ADDR, DATA_PAYLOAD_LEN, SerialInterface, node_num +from .messages import ( + Constants, Data, DeviceMetrics, FromRadio, Heartbeat, LogRecord, MeshPacket, + MyNodeInfo, NodeInfo, Position, QueueStatus, RouteDiscovery, Routing, + ToRadio, User, Waypoint, +) +from .portnums import PortNum +from .protobuf import DecodeError, ProtoMessage, decode_varint, encode_varint + +__version__ = "0.1.0" + +__all__ = ( + "BROADCAST_ADDR", "Constants", "DATA_PAYLOAD_LEN", "Data", "DecodeError", "DeviceMetrics", + "FromRadio", "Heartbeat", "LogRecord", "MeshPacket", "MyNodeInfo", "NodeInfo", + "PortNum", "Position", "ProtoMessage", "QueueStatus", "RouteDiscovery", "Routing", + "SerialInterface", "ToRadio", "User", "Waypoint", "decode_varint", "encode_varint", + "node_num", +) diff --git a/micromesh/__pycache__/__init__.cpython-311.pyc b/micromesh/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d1612a5 Binary files /dev/null and b/micromesh/__pycache__/__init__.cpython-311.pyc differ diff --git a/micromesh/__pycache__/interface.cpython-311.pyc b/micromesh/__pycache__/interface.cpython-311.pyc new file mode 100644 index 0000000..6827323 Binary files /dev/null and b/micromesh/__pycache__/interface.cpython-311.pyc differ diff --git a/micromesh/__pycache__/mesh_pb2.cpython-311.pyc b/micromesh/__pycache__/mesh_pb2.cpython-311.pyc new file mode 100644 index 0000000..9346c38 Binary files /dev/null and b/micromesh/__pycache__/mesh_pb2.cpython-311.pyc differ diff --git a/micromesh/__pycache__/messages.cpython-311.pyc b/micromesh/__pycache__/messages.cpython-311.pyc new file mode 100644 index 0000000..5aec2a8 Binary files /dev/null and b/micromesh/__pycache__/messages.cpython-311.pyc differ diff --git a/micromesh/__pycache__/portnums.cpython-311.pyc b/micromesh/__pycache__/portnums.cpython-311.pyc new file mode 100644 index 0000000..fc4ce52 Binary files /dev/null and b/micromesh/__pycache__/portnums.cpython-311.pyc differ diff --git a/micromesh/__pycache__/portnums_pb2.cpython-311.pyc b/micromesh/__pycache__/portnums_pb2.cpython-311.pyc new file mode 100644 index 0000000..b60fbfd Binary files /dev/null and b/micromesh/__pycache__/portnums_pb2.cpython-311.pyc differ diff --git a/micromesh/__pycache__/protobuf.cpython-311.pyc b/micromesh/__pycache__/protobuf.cpython-311.pyc new file mode 100644 index 0000000..d7fd1e2 Binary files /dev/null and b/micromesh/__pycache__/protobuf.cpython-311.pyc differ diff --git a/micromesh/__pycache__/stream.cpython-311.pyc b/micromesh/__pycache__/stream.cpython-311.pyc new file mode 100644 index 0000000..1d91164 Binary files /dev/null and b/micromesh/__pycache__/stream.cpython-311.pyc differ diff --git a/micromesh/interface.py b/micromesh/interface.py new file mode 100644 index 0000000..104f5d3 --- /dev/null +++ b/micromesh/interface.py @@ -0,0 +1,149 @@ +"""Polling, UART-based MicroPython interface to a Meshtastic radio.""" + +try: + import time +except ImportError: # pragma: no cover + import utime as time + +try: + import os +except ImportError: # pragma: no cover + import uos as os + +from .messages import FromRadio, MeshPacket, Position, ToRadio +from .portnums import PortNum +from .stream import START2, StreamParser, frame + +BROADCAST_ADDR = 0xFFFFFFFF +DATA_PAYLOAD_LEN = 233 + + +def _packet_id(): + try: + return int.from_bytes(os.urandom(4), "little") or 1 + except (AttributeError, OSError): + ticks = getattr(time, "ticks_ms", lambda: int(time.time() * 1000))() + return ((ticks * 1103515245 + 12345) & 0xFFFFFFFF) or 1 + + +def node_num(value): + if value is None: + return BROADCAST_ADDR + if isinstance(value, str): + value = value.strip() + if value.startswith("!"): + value = value[1:] + return int(value, 16) + return int(value) + + +class SerialInterface: + """A non-blocking Meshtastic client around a ``machine.UART``-like object. + + ``poll()`` should be called regularly. Callbacks receive ``FromRadio`` objects. + """ + + def __init__(self, uart, on_receive=None, on_packet=None, on_log=None): + self.uart = uart + self.on_receive = on_receive + self.on_packet = on_packet + self.parser = StreamParser(on_log=on_log) + self.my_info = None + self.nodes = {} + self.config_complete = False + self.config_id = None + + def connect(self, want_config=True, config_id=None, wake=True): + if wake: + self.uart.write(bytes((START2,)) * 32) + sleep_ms = getattr(time, "sleep_ms", None) + sleep_ms(100) if sleep_ms else time.sleep(0.1) + if want_config: + self.request_config(config_id) + return self + + def request_config(self, config_id=None): + self.config_id = _packet_id() if config_id is None else int(config_id) + self.config_complete = False + self._send(ToRadio(want_config_id=self.config_id)) + return self.config_id + + def _send(self, message): + data = frame(message.SerializeToString()) + written = self.uart.write(data) + if written is not None and written != len(data): + raise OSError("short UART write") + return message + + def sendData(self, data, destinationId=BROADCAST_ADDR, portNum=PortNum.PRIVATE_APP, + wantAck=False, wantResponse=False, channelIndex=0, hopLimit=None, + replyId=None, priority=MeshPacket.Priority.RELIABLE): + if hasattr(data, "SerializeToString"): + data = data.SerializeToString() + data = bytes(data) + if len(data) > DATA_PAYLOAD_LEN: + raise ValueError("payload exceeds %d bytes" % DATA_PAYLOAD_LEN) + if not portNum: + raise ValueError("portNum must be non-zero") + packet = MeshPacket(to=node_num(destinationId), channel=channelIndex, + id=_packet_id(), want_ack=wantAck, priority=priority) + packet.decoded.portnum = portNum + packet.decoded.payload = data + packet.decoded.want_response = wantResponse + if hopLimit is not None: + packet.hop_limit = hopLimit + if replyId is not None: + packet.decoded.reply_id = replyId + self._send(ToRadio(packet=packet)) + return packet + + def sendText(self, text, destinationId=BROADCAST_ADDR, wantAck=False, + wantResponse=False, channelIndex=0, hopLimit=None): + return self.sendData(text.encode("utf-8"), destinationId, + PortNum.TEXT_MESSAGE_APP, wantAck, wantResponse, + channelIndex, hopLimit) + + def sendPosition(self, latitude, longitude, altitude=0, + destinationId=BROADCAST_ADDR, wantAck=False, + channelIndex=0, hopLimit=None): + position = Position(latitude_i=int(latitude * 10000000), + longitude_i=int(longitude * 10000000)) + if altitude: + position.altitude = int(altitude) + return self.sendData(position, destinationId, PortNum.POSITION_APP, + wantAck, False, channelIndex, hopLimit) + + def heartbeat(self): + from .messages import Heartbeat + return self._send(ToRadio(heartbeat=Heartbeat())) + + def disconnect(self): + return self._send(ToRadio(disconnect=True)) + + def poll(self): + """Read all currently available bytes and return decoded messages.""" + available = getattr(self.uart, "any", None) + if available: + count = available() + data = self.uart.read(count) if count else b"" + else: + data = self.uart.read() + messages = [] + for payload in self.parser.feed(data or b""): + message = FromRadio().ParseFromString(payload) + messages.append(message) + self._handle(message) + return messages + + def _handle(self, message): + variant = message.WhichOneof("payload_variant") + if variant == "my_info": + self.my_info = message.my_info + elif variant == "node_info": + self.nodes[message.node_info.num] = message.node_info + elif variant == "config_complete_id": + self.config_complete = message.config_complete_id == self.config_id + if self.on_receive: + self.on_receive(message) + if variant == "packet" and self.on_packet: + self.on_packet(message.packet) diff --git a/micromesh/mesh_pb2.py b/micromesh/mesh_pb2.py new file mode 100644 index 0000000..984e01d --- /dev/null +++ b/micromesh/mesh_pb2.py @@ -0,0 +1,3 @@ +"""Generated-module-style compatibility imports for core mesh messages.""" + +from .messages import * # noqa: F401,F403 diff --git a/micromesh/messages.py b/micromesh/messages.py new file mode 100644 index 0000000..cc78897 --- /dev/null +++ b/micromesh/messages.py @@ -0,0 +1,178 @@ +"""Small generated-style subset of Meshtastic protobuf messages.""" + +from .protobuf import Field, ProtoMessage, fields + + +class Constants: + DATA_PAYLOAD_LEN = 233 + + +class Position(ProtoMessage): + FIELDS = fields( + latitude_i=Field(1, "sfixed32", optional=True), + longitude_i=Field(2, "sfixed32", optional=True), + altitude=Field(3, "int", optional=True), time=Field(4, "fixed32"), + location_source=Field(5), altitude_source=Field(6), + timestamp=Field(7, "fixed32"), timestamp_millis_adjust=Field(8, "int"), + 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), + 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), + ) + + +class User(ProtoMessage): + FIELDS = fields( + id=Field(1, "string"), long_name=Field(2, "string"), short_name=Field(3, "string"), + macaddr=Field(4, "bytes"), hw_model=Field(5), is_licensed=Field(6, "bool"), + role=Field(7), public_key=Field(8, "bytes"), + is_unmessagable=Field(9, "bool", optional=True), + ) + + +class RouteDiscovery(ProtoMessage): + FIELDS = fields( + route=Field(1, "fixed32", repeated=True, packed=True), + snr_towards=Field(2, "int", repeated=True, packed=True), + route_back=Field(3, "fixed32", repeated=True, packed=True), + snr_back=Field(4, "int", repeated=True, packed=True), + ) + + +class Routing(ProtoMessage): + class Error: + NONE = 0 + NO_ROUTE = 1 + GOT_NAK = 2 + TIMEOUT = 3 + NO_INTERFACE = 4 + MAX_RETRANSMIT = 5 + NO_CHANNEL = 6 + TOO_LARGE = 7 + NO_RESPONSE = 8 + DUTY_CYCLE_LIMIT = 9 + BAD_REQUEST = 32 + NOT_AUTHORIZED = 33 + + FIELDS = fields( + route_request=Field(1, "message", RouteDiscovery), + route_reply=Field(2, "message", RouteDiscovery), error_reason=Field(3), + ) + ONEOFS = {"variant": ("route_request", "route_reply", "error_reason")} + + +class Data(ProtoMessage): + FIELDS = fields( + 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), + ) + + +class MeshPacket(ProtoMessage): + class Priority: + UNSET = 0 + MIN = 1 + BACKGROUND = 10 + DEFAULT = 64 + RELIABLE = 70 + RESPONSE = 80 + HIGH = 100 + ALERT = 110 + ACK = 120 + MAX = 127 + + FIELDS = fields( + **{ + "from": Field(1, "fixed32"), "to": Field(2, "fixed32"), + "channel": Field(3), "decoded": Field(4, "message", Data), + "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), + "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), + } + ) + ONEOFS = {"payload_variant": ("decoded", "encrypted")} + + +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), + ) + + +class NodeInfo(ProtoMessage): + FIELDS = fields( + 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"), + is_ignored=Field(11, "bool"), is_key_manually_verified=Field(12, "bool"), + is_muted=Field(13, "bool"), + ) + + +class MyNodeInfo(ProtoMessage): + FIELDS = fields( + my_node_num=Field(1), reboot_count=Field(8), min_app_version=Field(11), + device_id=Field(12, "bytes"), pio_env=Field(13, "string"), + firmware_edition=Field(14), nodedb_count=Field(15), + ) + + +class LogRecord(ProtoMessage): + FIELDS = fields( + message=Field(1, "string"), time=Field(2, "fixed32"), + source=Field(3, "string"), level=Field(4), + ) + + +class QueueStatus(ProtoMessage): + FIELDS = fields(res=Field(1, "int"), free=Field(2), maxlen=Field(3), mesh_packet_id=Field(4)) + + +class Waypoint(ProtoMessage): + FIELDS = fields( + id=Field(1), latitude_i=Field(2, "sfixed32", optional=True), + longitude_i=Field(3, "sfixed32", optional=True), + expire=Field(4), locked_to=Field(5), name=Field(6, "string"), + description=Field(7, "string"), icon=Field(8, "fixed32"), geofence_radius=Field(9), + notify_on_enter=Field(11, "bool"), notify_on_exit=Field(12, "bool"), + notify_favorites_only=Field(13, "bool"), + ) + + +class Heartbeat(ProtoMessage): + FIELDS = {} + + +class FromRadio(ProtoMessage): + # Large configuration messages are exposed as encoded bytes to keep the runtime small. + FIELDS = fields( + id=Field(1), packet=Field(2, "message", MeshPacket), + my_info=Field(3, "message", MyNodeInfo), node_info=Field(4, "message", NodeInfo), + config=Field(5, "bytes"), log_record=Field(6, "message", LogRecord), + config_complete_id=Field(7), rebooted=Field(8, "bool"), moduleConfig=Field(9, "bytes"), + channel=Field(10, "bytes"), queueStatus=Field(11, "message", QueueStatus), + xmodemPacket=Field(12, "bytes"), metadata=Field(13, "bytes"), + mqttClientProxyMessage=Field(14, "bytes"), fileInfo=Field(15, "bytes"), + clientNotification=Field(16, "bytes"), deviceuiConfig=Field(17, "bytes"), + lockdown_status=Field(18, "bytes"), region_presets=Field(19, "bytes"), + ) + ONEOFS = {"payload_variant": tuple(name for name in FIELDS if name != "id")} + + +class ToRadio(ProtoMessage): + FIELDS = fields( + packet=Field(1, "message", MeshPacket), want_config_id=Field(3), + disconnect=Field(4, "bool"), xmodemPacket=Field(5, "bytes"), + mqttClientProxyMessage=Field(6, "bytes"), heartbeat=Field(7, "message", Heartbeat), + ) + ONEOFS = {"payload_variant": tuple(FIELDS)} diff --git a/micromesh/portnums.py b/micromesh/portnums.py new file mode 100644 index 0000000..c0e3438 --- /dev/null +++ b/micromesh/portnums.py @@ -0,0 +1,49 @@ +"""Meshtastic application port numbers.""" + + +class PortNum: + UNKNOWN_APP = 0 + TEXT_MESSAGE_APP = 1 + REMOTE_HARDWARE_APP = 2 + POSITION_APP = 3 + NODEINFO_APP = 4 + ROUTING_APP = 5 + ADMIN_APP = 6 + TEXT_MESSAGE_COMPRESSED_APP = 7 + WAYPOINT_APP = 8 + AUDIO_APP = 9 + DETECTION_SENSOR_APP = 10 + ALERT_APP = 11 + KEY_VERIFICATION_APP = 12 + REMOTE_SHELL_APP = 13 + REPLY_APP = 32 + IP_TUNNEL_APP = 33 + PAXCOUNTER_APP = 34 + STORE_FORWARD_PLUSPLUS_APP = 35 + NODE_STATUS_APP = 36 + MESH_BEACON_APP = 37 + SERIAL_APP = 64 + STORE_FORWARD_APP = 65 + RANGE_TEST_APP = 66 + TELEMETRY_APP = 67 + ZPS_APP = 68 + SIMULATOR_APP = 69 + TRACEROUTE_APP = 70 + NEIGHBORINFO_APP = 71 + ATAK_PLUGIN = 72 + MAP_REPORT_APP = 73 + POWERSTRESS_APP = 74 + LORAWAN_BRIDGE = 75 + RETICULUM_TUNNEL_APP = 76 + CAYENNE_APP = 77 + ATAK_PLUGIN_V2 = 78 + LORA_OTA_APP = 79 + GROUPALARM_APP = 112 + PRIVATE_APP = 256 + ATAK_FORWARDER = 257 + MAX = 511 + + +for _name in dir(PortNum): + if _name.isupper(): + globals()[_name] = getattr(PortNum, _name) diff --git a/micromesh/portnums_pb2.py b/micromesh/portnums_pb2.py new file mode 100644 index 0000000..6770c6a --- /dev/null +++ b/micromesh/portnums_pb2.py @@ -0,0 +1,3 @@ +"""Generated-module-style compatibility imports for application port numbers.""" + +from .portnums import * # noqa: F401,F403 diff --git a/micromesh/protobuf.py b/micromesh/protobuf.py new file mode 100644 index 0000000..3f1b295 --- /dev/null +++ b/micromesh/protobuf.py @@ -0,0 +1,310 @@ +"""Tiny proto3 codec used by MicroMesh. + +It deliberately implements only wire types used by the public messages below. +There are no descriptors and no dependency on ``google.protobuf``. +""" + +try: + import struct +except ImportError: # pragma: no cover - all supported ports normally have it + import ustruct as struct + + +VARINT = 0 +FIXED64 = 1 +BYTES = 2 +FIXED32 = 5 + + +class DecodeError(ValueError): + pass + + +def encode_varint(value): + value = int(value) + if value < 0: + value &= 0xFFFFFFFFFFFFFFFF + out = bytearray() + while value > 0x7F: + out.append((value & 0x7F) | 0x80) + value >>= 7 + out.append(value) + return bytes(out) + + +def decode_varint(buf, offset=0): + value = 0 + shift = 0 + length = len(buf) + while offset < length and shift < 70: + byte = buf[offset] + offset += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value, offset + shift += 7 + raise DecodeError("truncated or invalid varint") + + +def _skip(buf, offset, wire): + if wire == VARINT: + _, offset = decode_varint(buf, offset) + return offset + if wire == FIXED64: + offset += 8 + elif wire == BYTES: + size, offset = decode_varint(buf, offset) + offset += size + elif wire == FIXED32: + offset += 4 + else: + raise DecodeError("unsupported protobuf wire type: %d" % wire) + if offset > len(buf): + raise DecodeError("truncated protobuf field") + return offset + + +class Field: + def __init__(self, number, kind="uint", message=None, repeated=False, packed=False, optional=False): + self.number = number + self.kind = kind + self.message = message + self.repeated = repeated + self.packed = packed + self.optional = optional + + @property + def wire(self): + if self.kind in ("bytes", "string", "message"): + return BYTES + if self.kind in ("fixed32", "sfixed32", "float"): + return FIXED32 + if self.kind in ("fixed64", "sfixed64", "double"): + return FIXED64 + return VARINT + + +class ProtoMessage: + FIELDS = {} + ONEOFS = {} + _field_numbers = None + + def __init__(self, **kwargs): + object.__setattr__(self, "_values", {}) + object.__setattr__(self, "_unknown", []) + for name, value in kwargs.items(): + setattr(self, name, value) + + @classmethod + def _by_number(cls): + result = cls._field_numbers + if result is None: + result = {} + for name, field in cls.FIELDS.items(): + result[field.number] = (name, field) + cls._field_numbers = result + return result + + def __getattr__(self, name): + fields = type(self).FIELDS + if name.endswith("_") and name[:-1] in fields: + name = name[:-1] + field = fields.get(name) + if field is None: + raise AttributeError(name) + if name in self._values: + return self._values[name] + if field.repeated: + value = [] + self._values[name] = value + return value + if field.kind == "message": + value = field.message() + for members in type(self).ONEOFS.values(): + if name in members: + for other in members: + if other != name: + self._values.pop(other, None) + self._values[name] = value + return value + if field.kind == "bytes": + return b"" + if field.kind == "string": + return "" + if field.kind in ("float", "double"): + return 0.0 + return False if field.kind == "bool" else 0 + + def __setattr__(self, name, value): + fields = type(self).FIELDS + if name.endswith("_") and name[:-1] in fields: + name = name[:-1] + field = fields.get(name) + if field is None: + object.__setattr__(self, name, value) + return + if field.kind == "message" and isinstance(value, dict): + value = field.message(**value) + for members in type(self).ONEOFS.values(): + if name in members: + for other in members: + if other != name: + self._values.pop(other, None) + self._values[name] = value + + def HasField(self, name): + return name in self._values + + def ClearField(self, name): + self._values.pop(name, None) + + def WhichOneof(self, name): + for field_name in self.ONEOFS.get(name, ()): + if field_name in self._values: + return field_name + return None + + def CopyFrom(self, other): + self.ParseFromString(other.SerializeToString()) + + def SerializeToString(self): + out = bytearray() + for name, field in sorted(type(self).FIELDS.items(), key=lambda item: item[1].number): + if name not in self._values: + 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": + 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)) + 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)) + for raw in self._unknown: + out.extend(raw) + return bytes(out) + + def _encode_scalar(self, field, value, include_tag=False): + kind = field.kind + if kind == "message": + data = value.SerializeToString() + return encode_varint(len(data)) + data + if kind == "string": + value = value.encode("utf-8") + if kind in ("bytes", "string"): + value = bytes(value) + return encode_varint(len(value)) + value + if kind == "float": + return struct.pack("> (63 if kind == "sint64" else 31)) + return encode_varint(value) + + def ParseFromString(self, data): + object.__setattr__(self, "_values", {}) + object.__setattr__(self, "_unknown", []) + data = bytes(data) + offset = 0 + by_number = type(self)._by_number() + while offset < len(data): + start = offset + tag, offset = decode_varint(data, offset) + number, wire = tag >> 3, tag & 7 + entry = by_number.get(number) + if entry is None: + offset = _skip(data, offset, wire) + self._unknown.append(data[start:offset]) + continue + name, field = entry + if wire == BYTES: + size, offset = decode_varint(data, offset) + end = offset + size + if end > len(data): + raise DecodeError("truncated length-delimited field") + raw = data[offset:end] + offset = end + if field.packed and field.wire != BYTES: + values = self._values.setdefault(name, []) + inner = 0 + while inner < len(raw): + item, inner = self._decode_scalar(field, raw, inner, field.wire) + values.append(item) + continue + value = self._decode_bytes(field, raw) + else: + if wire != field.wire: + offset = _skip(data, offset, wire) + self._unknown.append(data[start:offset]) + continue + value, offset = self._decode_scalar(field, data, offset, wire) + if field.repeated: + self._values.setdefault(name, []).append(value) + else: + setattr(self, name, value) + return self + + def _decode_bytes(self, field, raw): + if field.kind == "message": + return field.message().ParseFromString(raw) + if field.kind == "string": + return raw.decode("utf-8") + return raw + + def _decode_scalar(self, field, data, offset, wire): + kind = field.kind + if wire == VARINT: + value, offset = decode_varint(data, offset) + if kind in ("sint", "sint64"): + value = (value >> 1) ^ -(value & 1) + elif kind in ("int", "int64"): + bits = 64 + if value & (1 << (bits - 1)): + value -= 1 << bits + elif kind == "bool": + value = bool(value) + return value, offset + size = 4 if wire == FIXED32 else 8 + if offset + size > len(data): + raise DecodeError("truncated fixed-width field") + raw = data[offset:offset + size] + if kind == "float": + value = struct.unpack(" MAX_FRAME: + raise ValueError("Meshtastic frame exceeds %d bytes" % MAX_FRAME) + return bytes((START1, START2, size >> 8, size & 0xFF)) + payload + + +class StreamParser: + def __init__(self, max_frame=MAX_FRAME, on_log=None): + self.max_frame = max_frame + self.on_log = on_log + self._buffer = bytearray() + self._log = bytearray() + + def feed(self, data): + packets = [] + for byte in data or b"": + if not self._buffer: + if byte == START1: + self._buffer.append(byte) + else: + self._log_byte(byte) + continue + if len(self._buffer) == 1: + if byte == START2: + self._buffer.append(byte) + elif byte == START1: + self._buffer[0] = byte + else: + self._buffer = bytearray() + self._log_byte(byte) + continue + self._buffer.append(byte) + if len(self._buffer) == HEADER_LEN: + size = (self._buffer[2] << 8) | self._buffer[3] + if size > self.max_frame: + self._buffer = bytearray() + elif size == 0: + packets.append(b"") + self._buffer = bytearray() + elif len(self._buffer) > HEADER_LEN: + size = (self._buffer[2] << 8) | self._buffer[3] + if len(self._buffer) == HEADER_LEN + size: + packets.append(bytes(self._buffer[HEADER_LEN:])) + self._buffer = bytearray() + return packets + + def _log_byte(self, byte): + if byte in (10, 13): + if byte == 10 and self._log and self.on_log: + try: + line = bytes(self._log).decode("utf-8") + except UnicodeError: + line = bytes(self._log).decode("utf-8", "replace") + self.on_log(line) + self._log = bytearray() + elif len(self._log) < 256: + self._log.append(byte) diff --git a/package.json b/package.json new file mode 100644 index 0000000..331f97c --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "micromesh", + "version": "0.1.0", + "description": "Small Meshtastic client and protobuf codec for MicroPython", + "license": "GPL-3.0-or-later", + "urls": [ + ["micromesh/__init__.py", "micromesh/__init__.py"], + ["micromesh/interface.py", "micromesh/interface.py"], + ["micromesh/mesh_pb2.py", "micromesh/mesh_pb2.py"], + ["micromesh/messages.py", "micromesh/messages.py"], + ["micromesh/portnums.py", "micromesh/portnums.py"], + ["micromesh/portnums_pb2.py", "micromesh/portnums_pb2.py"], + ["micromesh/protobuf.py", "micromesh/protobuf.py"], + ["micromesh/stream.py", "micromesh/stream.py"] + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa9a4ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "micromesh" +version = "0.1.0" +description = "A small, dependency-free Meshtastic client for MicroPython" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "GPL-3.0-or-later"} +authors = [{name = "MicroMesh contributors"}] +keywords = ["meshtastic", "micropython", "lora", "protobuf"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: MicroPython", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", +] + +[tool.setuptools] +packages = ["micromesh"] diff --git a/tests/__pycache__/test_meshmicro.cpython-311.pyc b/tests/__pycache__/test_meshmicro.cpython-311.pyc new file mode 100644 index 0000000..aca38b2 Binary files /dev/null and b/tests/__pycache__/test_meshmicro.cpython-311.pyc differ diff --git a/tests/__pycache__/test_micromesh.cpython-311.pyc b/tests/__pycache__/test_micromesh.cpython-311.pyc new file mode 100644 index 0000000..c0e26c4 Binary files /dev/null and b/tests/__pycache__/test_micromesh.cpython-311.pyc differ diff --git a/tests/test_micromesh.py b/tests/test_micromesh.py new file mode 100644 index 0000000..6a51a9a --- /dev/null +++ b/tests/test_micromesh.py @@ -0,0 +1,112 @@ +import unittest + +from micromesh import Data, FromRadio, MeshPacket, PortNum, Position, SerialInterface, ToRadio +from micromesh.protobuf import DecodeError, decode_varint, encode_varint +from micromesh.stream import StreamParser, frame + + +class FakeUART: + def __init__(self, incoming=b""): + self.incoming = bytearray(incoming) + self.written = bytearray() + + def any(self): + return len(self.incoming) + + def read(self, count=None): + count = len(self.incoming) if count is None else count + result = bytes(self.incoming[:count]) + del self.incoming[:count] + return result + + def write(self, data): + self.written.extend(data) + return len(data) + + +class MicroMeshTests(unittest.TestCase): + def test_varint_round_trip(self): + for value in (0, 1, 127, 128, 16384, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF): + encoded = encode_varint(value) + self.assertEqual(decode_varint(encoded), (value, len(encoded))) + + 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()) + + def test_generated_style_imports(self): + from micromesh import mesh_pb2, portnums_pb2 + self.assertEqual(mesh_pb2.Constants.DATA_PAYLOAD_LEN, 233) + 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"") + self.assertEqual(ToRadio(disconnect=False).SerializeToString(), b"\x20\x00") + self.assertEqual(Position(latitude_i=0).SerializeToString(), b"\x0d\x00\x00\x00\x00") + + 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 + packet.decoded.payload = b"hi" + decoded = MeshPacket().ParseFromString(packet.SerializeToString()) + self.assertEqual(decoded.from_, 0x12345678) + self.assertEqual(decoded.to, 0xFFFFFFFF) + self.assertEqual(decoded.id, 42) + self.assertEqual(decoded.decoded.payload, b"hi") + + 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_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_stream_parser_handles_chunks_logs_and_resync(self): + logs = [] + parser = StreamParser(on_log=logs.append) + wrapped = b"debug line\n" + frame(b"one") + frame(b"two") + packets = [] + for chunk in (wrapped[:3], wrapped[3:14], wrapped[14:19], wrapped[19:]): + packets.extend(parser.feed(chunk)) + self.assertEqual(packets, [b"one", b"two"]) + self.assertEqual(logs, ["debug line"]) + + def test_send_text_builds_to_radio_frame(self): + import micromesh.interface as interface_module + old_packet_id = interface_module._packet_id + interface_module._packet_id = lambda: 0x1234 + try: + uart = FakeUART() + interface = SerialInterface(uart) + packet = interface.sendText("hello", destinationId="!aabbccdd", wantAck=True) + finally: + interface_module._packet_id = old_packet_id + sent = ToRadio().ParseFromString(StreamParser().feed(uart.written)[0]) + self.assertEqual(sent.packet.to, 0xAABBCCDD) + self.assertEqual(sent.packet.id, 0x1234) + self.assertIs(sent.packet.want_ack, True) + self.assertEqual(sent.packet.decoded.portnum, PortNum.TEXT_MESSAGE_APP) + self.assertEqual(sent.packet.decoded.payload, b"hello") + self.assertEqual(packet.id, 0x1234) + + def test_poll_decodes_and_tracks_state(self): + config_id = 123 + uart = FakeUART(frame(FromRadio(config_complete_id=config_id).SerializeToString())) + interface = SerialInterface(uart) + interface.config_id = config_id + self.assertEqual(len(interface.poll()), 1) + self.assertIs(interface.config_complete, True) + + def test_truncated_message_raises(self): + with self.assertRaises(DecodeError): + Data().ParseFromString(b"\x12\x05no") + + +if __name__ == "__main__": + unittest.main()