From d96435b7c41ddd7b5f61c0c6c19a09e04b64b2b7 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 31 Jul 2026 23:46:37 -0700 Subject: [PATCH] fixes --- .github/workflows/release.yaml | 59 ++++++++++++++ .gitignore | 6 ++ README.md | 27 +++++++ examples/README.md | 39 +++++++++ examples/esp32.py | 36 +++++++++ examples/raspberry_pi_pico.py | 34 ++++++++ examples/xiao_rp2040.py | 75 ++++++++++++++++++ .../__pycache__/interface.cpython-311.pyc | Bin 9417 -> 9804 bytes .../__pycache__/protobuf.cpython-311.pyc | Bin 17572 -> 17827 bytes micromesh/interface.py | 13 ++- micromesh/protobuf.py | 7 +- pyproject.toml | 12 ++- .../test_micromesh.cpython-311.pyc | Bin 11038 -> 12971 bytes tests/test_micromesh.py | 19 +++++ 14 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 examples/README.md create mode 100644 examples/esp32.py create mode 100644 examples/raspberry_pi_pico.py create mode 100644 examples/xiao_rp2040.py 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 68273237567d725954b47efad286e9bf9eb0838a..3f45c8fd8ba1a2bf2d6f4cfa77912f44af33df67 100644 GIT binary patch delta 2342 zcmZuyYit`u5Z<+Yw$G03#Ia+?dB2pV6-`5%w&|OezF(lWDm6&v;#)UI{7CoCaT?Nx zsumTfM8Xy-)Jjw!3J4@Bg};C(5~xxoTBw9Mgy0W9kovCKtmmv>O2+ErINkg1K=X6oW~nP5B!GIzXQ zmE$4RbDG4%su!@qMz#{Q<}^`#S8RlQ08e?uBdTp5>GRK&&iJY6eViq8D%;F?j?Wsj z#^%q%rZSy)zPlomytkmyc;fO9uFoUl>M)nv$UCjm^#UZAI4(2uYbKu7!S*5FZbUheJ1Dg8-+ljP@6 z^sM8YT#R+YD^P6ggqOb)3xKH9kIO{Da*{Y1MM2VxIj559!NFBpf_3txp4_z&O@t&a( zFbO9;6q{{(Bs4uBn| zOP`#*Sns6Z@LQovG$q!Ddj_oVISe*o@C5Ew5QY&jrMU{AqoIm2Q1n{3mmU_Mgg1b< z|AxD0Er!o@D``VB@+#vgF%W5_LtYl=KKSG>Gwid>e(M-Abnt|@7Ld!ytKQEkUu$6e>1I#rS#uz(%{5c2f=#C~HU=4Qx)LKwR!uW#n^s!L%DAGXmEklLWipq~8hkfs;N265 zPb2I?jgr+abnG>iEP)~sKQ*_xj)O>Vrb23&I3dfOB?54EYsu#HHT)RsdSlV0Mc>5+ zzljZAJ$x&+?sjb5v}@XR@5fNvJIy!i7ng{`*Ki+-^^x;yOT<>!QVc~e?0$9k<(~H< zw?YHALjyNM1I1AD^tSS^SR1|vqa?$2e0zv5{K{VOc1v%Uj@9@1nvRXFwrj0*n5Dpw7?eOV`*q8Uln#nRZCxj@lKQ-+=irEz9NC*?xfCA+5q}Y3Ie=-$FTqR5 zV$-qO#{)gm+HWrxs1+&KxB~4VYv|bPAy(Emh@S#xGV=e7PK-FM%MgOL-*8!1?${%~ zZC@Q;g{C$mY(%hGTGAHML zC7WGxmk5G~Y@s`7G1gkLd!*J9nGaEy6ee zVi>^mZqJP6xM6+OO(}0^8OED%!k*9Rd_vsm>>YNG@tlzx&ZoGQyiVPKRL_(@4%p*3 zwq4$Xa1g=j=T@V%PB#Cvf4kRYa%w)!*71w57!FJH10vORt*+xHnfEV>CEX#huG=Xd Z>mFE^~>x&NH9Qt?J_?t-verj(ro0!7Y0- zBB85R%3kTwwml#S+>dI;%^M*o?phrpg_#aRD@^D*LoLB0h^99`DHkSGBd;kufkb%j zi1;(>Y($a#r{rQ)z1QDet|)50TrKg{=n{vsPR=Cw*BJ}GjryB{D&&|V@C|Fuh1{wV-NKs_8+wc3Mt1V>%~guB#lTJ?qjAQ`FVsDDOf8$MOlh zn_xYHP8lU=@d#Y^q!Sjo2g&2Yd?X(I7%6jZ-Hu{!)b-;gA0Ks)(;;CWIT4f zA!B0$&>u8>1N5Pt1cPuTn08Xj@C4ir_6GY%v=a2y65I;|jUy``BYGG?r);R^cNUiZ zqsBDL&i&BnWSHL;PmlIsn#NEbv!P$?L~J!Meq~>4bA^9j=@G3-DunCS~^|7X*aU{ohNivlFjR+?2Y% zmAWscuS?r+NZU{M&-+7QwqzWflAT-C`Z8l1;9{bEKebK9XL_F)*9zkY%4%hlZ$OHl zB-n^x+UdV_iqy>nG%NSay0zb4>&88hXbac6`xxwKYi6&&lWo_WR(?W|TD+NcAT|HL&Xhd>?D!~lFiv-6A!UPnoHj8UXBIv|NPrxt9Sjg>YSrC>(2$JpJ z1nL~o1z{0dfTg0FtWXpnBk@-$82#@}-(YF7!i`5cuW-{*HMEk-o5*0VR&+iE*;IPi zsT+93rP@!4{)(IikEaM8CrA)jgKK5EJoCTZ50xrPwW#joZ=fHwq23EOQ6jL zL=raTe_Mx%$j_J(J=p4F@Q15Qm^S||Ey9F4t&!Q0HZ4Mtehh?^WN+&hOpqDVO;Zw* zx%wo&8nUPeqk=bCmw=7?0`Q>Zn zOKYsK&I;L~?9g_DIVcgz$oxfPkG)}I^J4|=l6KwZT>W*H#}q3E$^nT|^;x%ws+Vah zkcg_A*_I_yau+3PiN%%=ED3e9ebsDl?8TNpcPt8|tNTto@1IZQQguDIrst%yKn#}K zopEKuHIsGNL23@|$N{S6bBwUmYnGPp)k;7Quz)Xv`$1cMAN)o^0XGB=jMW_>LxC4l zgXf`y^+8@HgnGbL?G4#LPW)j%1aT@{G%P6VDkvx^sG#-a$1a|>NaPR3ad+ea2>2{= e4_;vORP*>a1!0iL|HD?h*8KO$88xO)ANdKPA-oR& delta 403 zcmZ47&A6nKk#9LKFBbz4^!DXuHEM3;bJCe?BgV;O$2$2eZ^`8II^C>tHB7ZElLPgX zCpYPyW-5BOIZ$scBV+1hMgv{O=*deB>RI>HFf0(9oTw-|+0IabQFwB^VJ4&I~b{>!3>)0lf{i}m^4Kv#~9Ucn*l8=@&yu_LX%$@rLh(Z0ojwIjqMrdOrCB0p0Q(c zx5+K38xk_tCA2O{Xss~apnOrn?23fh1l9?x4}`@h-!NUE;L6CrFFLh&hRMXrDV3M_ z6fW{9d~joA5D@9$F5&^2w|RpZA0y+;$@|Ua8Cf^qH~-GeIAij4YXwH0%|EQ`nHhB_ zSJ+1|uAF?)UXO9*BnL@G_03Wa5{y6*V@DH4!^s(ra|LFC96AF;tOOD3CyP2g=htKv x`M>}t!X|S&FJd&Eyv}((=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 c0e26c453cb09f3defdf5c51132be879ec68914a..c82f00852e206d09551275c30e026009506970b7 100644 GIT binary patch delta 1506 zcmb7^-%ndr9Kg@%?QKut_80ACAS;%3lol7_aDZ7x=i;(V8Wsm;K{UDE^8s_kS09&50nfO5+C%O*1}A9(39TJ`TjcJ zd%pL3P9I)gQC;6ToppeYyZ0uq`5M<;>o)j4`Nek0GY3Xpij&f9t!V!mIElZWL& ze*>S^@TXM3pHU?Dw?P%=_R;5gH^qIp2<9yXmU|H7Kn{*s{$0;7z$`ch@fe%960c&c zVSI{eC0j+-b&e{*;4E&UGyafb>Me4qy?G`nrzNarG@O;>v?A%(Co<9%S&_!DJgMqk zr&C%|PU&$f$dgwTIi8_Qb(Umr7Cb)xbnu74pL%}oD4rTHjwj6HiCKWX)ER>%q@|_m zKDwv@v+~}F)ngY^SbeykVFodJ80Hv0XJA$17KTq4Zc@Z-_z0a!9Nk;VI<9F7{+Q{A zZc>-WRUO}^s&1nQvhhZx&{8z3>RBnHrBc%2h1S(7o$C7!pW_;P$1Vjee#`62+qPZ4 zg+ap=GF_oOx9#@de|I%p6nhP^*A#m<#S0ta1w$M##R0>eFx`o~b=x1_7W@mtODFG) z8bZ(%f?HyuSo@V{cuuU_0TjCoc}E$6mqU4niCVUNM;GIFWb-WorXU6m?F%tgpy;bn;jIjgyZU nTxXmxd5+-*vp_~hqYn(&$q5lxMAbfmCBGmm$zYn?WTXiI`ie7U 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")