This commit is contained in:
pdxlocations
2026-07-31 23:16:45 -07:00
commit 9eaf3ce1c4
24 changed files with 1036 additions and 0 deletions
+5
View File
@@ -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.
+101
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
metadata(version="0.1.0", description="Small Meshtastic client for MicroPython")
package("micromesh")
+20
View File
@@ -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",
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+149
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
"""Generated-module-style compatibility imports for core mesh messages."""
from .messages import * # noqa: F401,F403
+178
View File
@@ -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)}
+49
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
"""Generated-module-style compatibility imports for application port numbers."""
from .portnums import * # noqa: F401,F403
+310
View File
@@ -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("<f", value)
if kind == "double":
return struct.pack("<d", value)
if kind in ("fixed32", "sfixed32"):
return struct.pack("<I", int(value) & 0xFFFFFFFF)
if kind in ("fixed64", "sfixed64"):
return struct.pack("<Q", int(value) & 0xFFFFFFFFFFFFFFFF)
if kind in ("sint", "sint64"):
value = (int(value) << 1) ^ (int(value) >> (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("<f", raw)[0]
elif kind == "double":
value = struct.unpack("<d", raw)[0]
elif kind in ("sfixed32", "sfixed64"):
value = struct.unpack("<i" if size == 4 else "<q", raw)[0]
else:
value = struct.unpack("<I" if size == 4 else "<Q", raw)[0]
return value, offset + size
def to_dict(self):
result = {}
for name, value in self._values.items():
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]
result[name] = value
return result
def __repr__(self):
return "%s(%r)" % (type(self).__name__, self.to_dict())
def fields(**definitions):
return definitions
+67
View File
@@ -0,0 +1,67 @@
"""Meshtastic serial framing, independent of a particular UART implementation."""
START1 = 0x94
START2 = 0xC3
HEADER_LEN = 4
MAX_FRAME = 512
def frame(payload):
payload = bytes(payload)
size = len(payload)
if size > 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)
+16
View File
@@ -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"]
]
}
+21
View File
@@ -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"]
Binary file not shown.
Binary file not shown.
+112
View File
@@ -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()