This commit is contained in:
pdxlocations
2026-07-31 23:46:37 -07:00
parent 9eaf3ce1c4
commit d96435b7c4
14 changed files with 321 additions and 6 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Publish to PyPI
on:
release:
types: [published]
jobs:
build:
name: Build distributions
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Install build tools
run: python -m pip install --upgrade build twine
- name: Build wheel and source distribution
run: python -m build
- name: Validate distributions
run: python -m twine check dist/*
- name: Upload distributions
uses: actions/upload-artifact@v5
with:
name: python-package-distributions
path: dist/
if-no-files-found: error
publish:
name: Publish distributions to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/micromesh
permissions:
id-token: write
steps:
- name: Download distributions
uses: actions/download-artifact@v6
with:
name: python-package-distributions
path: dist/
- name: Publish distributions
uses: pypa/gh-action-pypi-publish@release/v1
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.py[cod]
build/
dist/
*.egg-info/
+27
View File
@@ -36,6 +36,12 @@ It can also be installed on CPython for development:
python -m pip install .
```
After the first PyPI release, install it by name with:
```sh
python -m pip install micromesh
```
## Quick start
```python
@@ -61,6 +67,9 @@ while True:
The UART baud rate and pins depend on the attached Meshtastic device. `poll()` is
non-blocking when the UART is configured with `timeout=0`.
Ready-to-run wiring and code for the Seeed Studio XIAO RP2040, Raspberry Pi Pico, and
generic ESP32 boards are in [`examples/`](examples/README.md).
Direct messages accept either an integer or Meshtastic's hexadecimal node ID:
```python
@@ -94,6 +103,24 @@ round trip, but their contents are not interpreted. A field named `from` is acce
python -m unittest discover -s tests
```
## Releasing to PyPI
PyPI publishing uses GitHub Trusted Publishing, so no API token is stored in the
repository. Configure a pending publisher at
[`pypi.org/manage/account/publishing`](https://pypi.org/manage/account/publishing/)
with these values:
- PyPI project name: `micromesh`
- GitHub owner: `pdxlocations`
- Repository: `micromesh`
- Workflow: `release.yaml`
- Environment: `pypi`
Keep the version in `pyproject.toml`, `package.json`, and `micromesh/__init__.py` in
sync. Push the change, create a matching GitHub release such as `v0.1.0`, and publish
the release. The workflow builds, validates, and uploads both the wheel and source
distribution.
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.
+39
View File
@@ -0,0 +1,39 @@
# Board examples
These examples connect a MicroPython board to the hardware UART exposed by a
Meshtastic radio. The radio must have its serial module enabled in `PROTO` mode at
115200 baud.
| Board | Example | MicroPython UART | TX pin | RX pin |
| --- | --- | --- | --- | --- |
| Seeed Studio XIAO RP2040 | `xiao_rp2040.py` | UART0 | D6 / GPIO0 | D7 / GPIO1 |
| Raspberry Pi Pico / Pico W | `raspberry_pi_pico.py` | UART0 | GP0 | GP1 |
| Generic ESP32 | `esp32.py` | UART2 | GPIO17 | GPIO16 |
## Wiring
UART signals cross between the two boards:
```text
MicroPython board TX -> radio RX
MicroPython board RX <- radio TX
MicroPython board GND --- radio GND
```
Both ends must use 3.3 V UART logic. Do not connect the power pins unless you have
confirmed the voltage and current requirements for both boards. Meshtastic radio UART
pin names vary by model and may need to be configured in the radio firmware.
## Run an example
Install MicroMesh, copy the matching example to the board as `main.py`, and reset it:
```sh
mpremote mip install package.json
mpremote fs cp examples/xiao_rp2040.py :main.py
mpremote reset
```
Each example requests the node configuration, listens for text messages, and broadcasts
one greeting after configuration completes. Remove the `mesh.sendText(...)` line for a
receive-only application.
+36
View File
@@ -0,0 +1,36 @@
"""MicroMesh example for a generic ESP32 development board.
Wiring (cross TX and RX):
ESP32 GPIO17 / UART2 TX -> Meshtastic radio RX
ESP32 GPIO16 / UART2 RX <- Meshtastic radio TX
ESP32 GND --- Meshtastic radio GND
Change the pin numbers when GPIO16 or GPIO17 is unavailable on your board.
"""
from machine import Pin, UART
from time import sleep_ms
from micromesh import PortNum, SerialInterface
def on_packet(packet):
if packet.WhichOneof("payload_variant") != "decoded":
return
if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP:
print("from !%08x: %s" % (
packet.from_, packet.decoded.payload.decode("utf-8", "replace")
))
uart = UART(2, baudrate=115200, tx=Pin(17), rx=Pin(16), timeout=0, rxbuf=1024)
mesh = SerialInterface(uart, on_packet=on_packet, on_log=print)
mesh.connect()
hello_sent = False
while True:
mesh.poll()
if mesh.config_complete and not hello_sent:
mesh.sendText("hello from ESP32")
hello_sent = True
sleep_ms(10)
+34
View File
@@ -0,0 +1,34 @@
"""MicroMesh example for Raspberry Pi Pico and Pico W.
Wiring (cross TX and RX):
Pico GP0 / UART0 TX -> Meshtastic radio RX
Pico GP1 / UART0 RX <- Meshtastic radio TX
Pico GND --- Meshtastic radio GND
"""
from machine import Pin, UART
from time import sleep_ms
from micromesh import PortNum, SerialInterface
def on_packet(packet):
if packet.WhichOneof("payload_variant") != "decoded":
return
if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP:
print("from !%08x: %s" % (
packet.from_, packet.decoded.payload.decode("utf-8", "replace")
))
uart = UART(0, 115200, tx=Pin(0), rx=Pin(1), timeout=0, rxbuf=1024)
mesh = SerialInterface(uart, on_packet=on_packet, on_log=print)
mesh.connect()
hello_sent = False
while True:
mesh.poll()
if mesh.config_complete and not hello_sent:
mesh.sendText("hello from Raspberry Pi Pico")
hello_sent = True
sleep_ms(10)
+75
View File
@@ -0,0 +1,75 @@
"""MicroMesh example for the Seeed Studio XIAO RP2040.
Wiring (cross TX and RX):
XIAO D6 / GPIO0 / TX -> Meshtastic radio RX
XIAO D7 / GPIO1 / RX <- Meshtastic radio TX
XIAO GND --- Meshtastic radio GND
"""
from machine import Pin, UART
from time import sleep_ms, ticks_diff, ticks_ms
from micromesh import PortNum, SerialInterface
def on_packet(packet):
if packet.WhichOneof("payload_variant") != "decoded":
return
if packet.decoded.portnum == PortNum.TEXT_MESSAGE_APP:
try:
text = packet.decoded.payload.decode("utf-8")
except UnicodeError:
text = repr(packet.decoded.payload)
print("from !%08x: %s" % (packet.from_, text))
def on_receive(message):
variant = message.WhichOneof("payload_variant")
print("radio message:", variant or "unknown")
def on_error(error, payload):
print("skipping undecodable radio frame:", error)
print("frame bytes:", payload.hex())
print("MicroMesh starting on XIAO RP2040")
print("UART0: D6/GPIO0 TX -> radio RX")
print("UART0: D7/GPIO1 RX <- radio TX")
print("UART0: 115200 baud; common GND required")
uart = UART(
0,
baudrate=115200,
tx=Pin(0), # XIAO D6
rx=Pin(1), # XIAO D7
timeout=0,
rxbuf=1024,
)
mesh = SerialInterface(
uart,
on_receive=on_receive,
on_packet=on_packet,
on_log=lambda line: print("radio log:", line),
on_error=on_error,
)
mesh.connect()
print("configuration requested; id=%08x" % mesh.config_id)
hello_sent = False
last_status = ticks_ms()
while True:
mesh.poll()
if mesh.config_complete and not hello_sent:
print("radio configuration complete; sending greeting")
mesh.sendText("hello from XIAO RP2040")
hello_sent = True
now = ticks_ms()
if ticks_diff(now, last_status) >= 5000:
if mesh.config_complete:
print("connected; %d nodes known" % len(mesh.nodes))
else:
print("waiting for radio data; check TX/RX, GND, baud, and PROTO mode")
last_status = now
sleep_ms(10)
Binary file not shown.
Binary file not shown.
+10 -1
View File
@@ -12,6 +12,7 @@ except ImportError: # pragma: no cover
from .messages import FromRadio, MeshPacket, Position, ToRadio
from .portnums import PortNum
from .protobuf import DecodeError
from .stream import START2, StreamParser, frame
BROADCAST_ADDR = 0xFFFFFFFF
@@ -43,10 +44,12 @@ class SerialInterface:
``poll()`` should be called regularly. Callbacks receive ``FromRadio`` objects.
"""
def __init__(self, uart, on_receive=None, on_packet=None, on_log=None):
def __init__(self, uart, on_receive=None, on_packet=None, on_log=None,
on_error=None):
self.uart = uart
self.on_receive = on_receive
self.on_packet = on_packet
self.on_error = on_error
self.parser = StreamParser(on_log=on_log)
self.my_info = None
self.nodes = {}
@@ -130,7 +133,13 @@ class SerialInterface:
data = self.uart.read()
messages = []
for payload in self.parser.feed(data or b""):
try:
message = FromRadio().ParseFromString(payload)
except (DecodeError, UnicodeError) as error:
if self.on_error:
self.on_error(error, payload)
continue
raise
messages.append(message)
self._handle(message)
return messages
+5
View File
@@ -245,7 +245,12 @@ class ProtoMessage:
item, inner = self._decode_scalar(field, raw, inner, field.wire)
values.append(item)
continue
try:
value = self._decode_bytes(field, raw)
except DecodeError as error:
raise DecodeError("%s.%s: %s" % (
type(self).__name__, name, error
))
else:
if wire != field.wire:
offset = _skip(data, offset, wire)
+9 -3
View File
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=61"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
@@ -8,14 +8,20 @@ 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"}
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
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+)",
"Topic :: Communications :: Ham Radio",
]
[project.urls]
Homepage = "https://github.com/pdxlocations/micromesh"
Repository = "https://github.com/pdxlocations/micromesh.git"
Issues = "https://github.com/pdxlocations/micromesh/issues"
[tool.setuptools]
packages = ["micromesh"]
Binary file not shown.
+19
View File
@@ -103,6 +103,25 @@ class MicroMeshTests(unittest.TestCase):
self.assertEqual(len(interface.poll()), 1)
self.assertIs(interface.config_complete, True)
def test_poll_can_report_and_skip_bad_frames(self):
errors = []
bad = frame(b"\x12\x05no")
good = frame(FromRadio(config_complete_id=123).SerializeToString())
interface = SerialInterface(
FakeUART(bad + good),
on_error=lambda error, payload: errors.append((error, payload)),
)
interface.config_id = 123
messages = interface.poll()
self.assertEqual(len(messages), 1)
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0][1], b"\x12\x05no")
self.assertIs(interface.config_complete, True)
def test_nested_decode_errors_include_the_field_path(self):
with self.assertRaisesRegex(DecodeError, "FromRadio.packet"):
FromRadio().ParseFromString(b"\x12\x03\x22\x05x")
def test_truncated_message_raises(self):
with self.assertRaises(DecodeError):
Data().ParseFromString(b"\x12\x05no")