diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..a8237f8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4929b7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.py[cod] +build/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md index 8b2977a..a396d32 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c1ab628 --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/esp32.py b/examples/esp32.py new file mode 100644 index 0000000..98ae97c --- /dev/null +++ b/examples/esp32.py @@ -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) diff --git a/examples/raspberry_pi_pico.py b/examples/raspberry_pi_pico.py new file mode 100644 index 0000000..6cbf932 --- /dev/null +++ b/examples/raspberry_pi_pico.py @@ -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) diff --git a/examples/xiao_rp2040.py b/examples/xiao_rp2040.py new file mode 100644 index 0000000..64d0491 --- /dev/null +++ b/examples/xiao_rp2040.py @@ -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) diff --git a/micromesh/__pycache__/interface.cpython-311.pyc b/micromesh/__pycache__/interface.cpython-311.pyc index 6827323..3f45c8f 100644 Binary files a/micromesh/__pycache__/interface.cpython-311.pyc and b/micromesh/__pycache__/interface.cpython-311.pyc differ diff --git a/micromesh/__pycache__/protobuf.cpython-311.pyc b/micromesh/__pycache__/protobuf.cpython-311.pyc index d7fd1e2..2ca5be2 100644 Binary files a/micromesh/__pycache__/protobuf.cpython-311.pyc and b/micromesh/__pycache__/protobuf.cpython-311.pyc differ diff --git a/micromesh/interface.py b/micromesh/interface.py index 104f5d3..b4d9ff3 100644 --- a/micromesh/interface.py +++ b/micromesh/interface.py @@ -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""): - 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) self._handle(message) return messages diff --git a/micromesh/protobuf.py b/micromesh/protobuf.py index 3f1b295..0698815 100644 --- a/micromesh/protobuf.py +++ b/micromesh/protobuf.py @@ -245,7 +245,12 @@ class ProtoMessage: item, inner = self._decode_scalar(field, raw, inner, field.wire) values.append(item) 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: if wire != field.wire: offset = _skip(data, offset, wire) diff --git a/pyproject.toml b/pyproject.toml index aa9a4ac..9e6d831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/__pycache__/test_micromesh.cpython-311.pyc b/tests/__pycache__/test_micromesh.cpython-311.pyc index c0e26c4..c82f008 100644 Binary files a/tests/__pycache__/test_micromesh.cpython-311.pyc and b/tests/__pycache__/test_micromesh.cpython-311.pyc differ diff --git a/tests/test_micromesh.py b/tests/test_micromesh.py index 6a51a9a..f03c342 100644 --- a/tests/test_micromesh.py +++ b/tests/test_micromesh.py @@ -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")