fixes
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user