init
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
metadata(version="0.1.0", description="Small Meshtastic client for MicroPython")
|
||||
package("micromesh")
|
||||
@@ -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.
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Generated-module-style compatibility imports for core mesh messages."""
|
||||
|
||||
from .messages import * # noqa: F401,F403
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Generated-module-style compatibility imports for application port numbers."""
|
||||
|
||||
from .portnums import * # noqa: F401,F403
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -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.
@@ -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()
|
||||
Reference in New Issue
Block a user