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 .
|
python -m pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After the first PyPI release, install it by name with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m pip install micromesh
|
||||||
|
```
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -61,6 +67,9 @@ while True:
|
|||||||
The UART baud rate and pins depend on the attached Meshtastic device. `poll()` is
|
The UART baud rate and pins depend on the attached Meshtastic device. `poll()` is
|
||||||
non-blocking when the UART is configured with `timeout=0`.
|
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:
|
Direct messages accept either an integer or Meshtastic's hexadecimal node ID:
|
||||||
|
|
||||||
```python
|
```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
|
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
|
The API protocol is defined by the
|
||||||
[`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) project. Update the
|
[`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) project. Update the
|
||||||
field declarations in `micromesh/messages.py` when adopting newer schemas.
|
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.
+11
-2
@@ -12,6 +12,7 @@ except ImportError: # pragma: no cover
|
|||||||
|
|
||||||
from .messages import FromRadio, MeshPacket, Position, ToRadio
|
from .messages import FromRadio, MeshPacket, Position, ToRadio
|
||||||
from .portnums import PortNum
|
from .portnums import PortNum
|
||||||
|
from .protobuf import DecodeError
|
||||||
from .stream import START2, StreamParser, frame
|
from .stream import START2, StreamParser, frame
|
||||||
|
|
||||||
BROADCAST_ADDR = 0xFFFFFFFF
|
BROADCAST_ADDR = 0xFFFFFFFF
|
||||||
@@ -43,10 +44,12 @@ class SerialInterface:
|
|||||||
``poll()`` should be called regularly. Callbacks receive ``FromRadio`` objects.
|
``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.uart = uart
|
||||||
self.on_receive = on_receive
|
self.on_receive = on_receive
|
||||||
self.on_packet = on_packet
|
self.on_packet = on_packet
|
||||||
|
self.on_error = on_error
|
||||||
self.parser = StreamParser(on_log=on_log)
|
self.parser = StreamParser(on_log=on_log)
|
||||||
self.my_info = None
|
self.my_info = None
|
||||||
self.nodes = {}
|
self.nodes = {}
|
||||||
@@ -130,7 +133,13 @@ class SerialInterface:
|
|||||||
data = self.uart.read()
|
data = self.uart.read()
|
||||||
messages = []
|
messages = []
|
||||||
for payload in self.parser.feed(data or b""):
|
for payload in self.parser.feed(data or b""):
|
||||||
message = FromRadio().ParseFromString(payload)
|
try:
|
||||||
|
message = FromRadio().ParseFromString(payload)
|
||||||
|
except (DecodeError, UnicodeError) as error:
|
||||||
|
if self.on_error:
|
||||||
|
self.on_error(error, payload)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
self._handle(message)
|
self._handle(message)
|
||||||
return messages
|
return messages
|
||||||
|
|||||||
@@ -245,7 +245,12 @@ class ProtoMessage:
|
|||||||
item, inner = self._decode_scalar(field, raw, inner, field.wire)
|
item, inner = self._decode_scalar(field, raw, inner, field.wire)
|
||||||
values.append(item)
|
values.append(item)
|
||||||
continue
|
continue
|
||||||
value = self._decode_bytes(field, raw)
|
try:
|
||||||
|
value = self._decode_bytes(field, raw)
|
||||||
|
except DecodeError as error:
|
||||||
|
raise DecodeError("%s.%s: %s" % (
|
||||||
|
type(self).__name__, name, error
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
if wire != field.wire:
|
if wire != field.wire:
|
||||||
offset = _skip(data, offset, wire)
|
offset = _skip(data, offset, wire)
|
||||||
|
|||||||
+9
-3
@@ -1,5 +1,5 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61"]
|
requires = ["setuptools>=77"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
@@ -8,14 +8,20 @@ version = "0.1.0"
|
|||||||
description = "A small, dependency-free Meshtastic client for MicroPython"
|
description = "A small, dependency-free Meshtastic client for MicroPython"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = {text = "GPL-3.0-or-later"}
|
license = "GPL-3.0-or-later"
|
||||||
|
license-files = ["LICENSE"]
|
||||||
authors = [{name = "MicroMesh contributors"}]
|
authors = [{name = "MicroMesh contributors"}]
|
||||||
keywords = ["meshtastic", "micropython", "lora", "protobuf"]
|
keywords = ["meshtastic", "micropython", "lora", "protobuf"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: Implementation :: MicroPython",
|
"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]
|
[tool.setuptools]
|
||||||
packages = ["micromesh"]
|
packages = ["micromesh"]
|
||||||
|
|||||||
Binary file not shown.
@@ -103,6 +103,25 @@ class MicroMeshTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(interface.poll()), 1)
|
self.assertEqual(len(interface.poll()), 1)
|
||||||
self.assertIs(interface.config_complete, True)
|
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):
|
def test_truncated_message_raises(self):
|
||||||
with self.assertRaises(DecodeError):
|
with self.assertRaises(DecodeError):
|
||||||
Data().ParseFromString(b"\x12\x05no")
|
Data().ParseFromString(b"\x12\x05no")
|
||||||
|
|||||||
Reference in New Issue
Block a user