From dbaa3b38b82e0c638d602893b833cf5d69122f81 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 1 Aug 2026 01:14:46 -0700 Subject: [PATCH] Enhance testing and error handling; update documentation and examples --- .github/workflows/release.yaml | 3 + README.md | 65 +++++++++++++----- examples/README.md | 11 +-- examples/curses_dashboard.py | 40 +++++++++-- examples/esp32.py | 14 ++-- examples/raspberry_pi_pico.py | 14 ++-- .../__pycache__/__init__.cpython-311.pyc | Bin 1322 -> 0 bytes .../__pycache__/interface.cpython-311.pyc | Bin 9804 -> 0 bytes .../__pycache__/mesh_pb2.cpython-311.pyc | Bin 311 -> 0 bytes .../__pycache__/messages.cpython-311.pyc | Bin 14234 -> 0 bytes .../__pycache__/portnums.cpython-311.pyc | Bin 1888 -> 0 bytes .../__pycache__/portnums_pb2.cpython-311.pyc | Bin 321 -> 0 bytes .../__pycache__/protobuf.cpython-311.pyc | Bin 17827 -> 0 bytes micromesh/__pycache__/stream.cpython-311.pyc | Bin 4392 -> 0 bytes micromesh/interface.py | 26 +++++-- micromesh/messages.py | 25 +++++-- micromesh/protobuf.py | 35 ++++++++-- pyproject.toml | 2 + .../test_meshmicro.cpython-311.pyc | Bin 11038 -> 0 bytes .../test_micromesh.cpython-311.pyc | Bin 13498 -> 0 bytes tests/test_micromesh.py | 54 ++++++++++++++- 21 files changed, 228 insertions(+), 61 deletions(-) delete mode 100644 micromesh/__pycache__/__init__.cpython-311.pyc delete mode 100644 micromesh/__pycache__/interface.cpython-311.pyc delete mode 100644 micromesh/__pycache__/mesh_pb2.cpython-311.pyc delete mode 100644 micromesh/__pycache__/messages.cpython-311.pyc delete mode 100644 micromesh/__pycache__/portnums.cpython-311.pyc delete mode 100644 micromesh/__pycache__/portnums_pb2.cpython-311.pyc delete mode 100644 micromesh/__pycache__/protobuf.cpython-311.pyc delete mode 100644 micromesh/__pycache__/stream.cpython-311.pyc delete mode 100644 tests/__pycache__/test_meshmicro.cpython-311.pyc delete mode 100644 tests/__pycache__/test_micromesh.cpython-311.pyc diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a8237f8..55ae724 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,6 +25,9 @@ jobs: - name: Install build tools run: python -m pip install --upgrade build twine + - name: Run tests + run: python -m unittest discover -s tests + - name: Build wheel and source distribution run: python -m build diff --git a/README.md b/README.md index 2b0dffa..dbac078 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ three wires, and regular Python. - Send binary data and positions - Read user, position, signal, battery, and hop information - Preserve unknown protobuf fields for forward compatibility -- Recover from serial noise and skip newer messages it cannot decode +- Recover from serial noise and optionally report and skip undecodable frames - Run a curses dashboard with live stats, nodes, and messages - Work on MicroPython without third-party runtime dependencies @@ -59,16 +59,21 @@ You need: - A separate Meshtastic-compatible radio running Meshtastic firmware - Three jumper wires for TX, RX, and GND - A data-capable USB cable for the XIAO -- Python 3 on your Mac, Linux computer, or Windows PC +- Python 3.8 or newer on your Mac, Linux computer, or Windows PC -Configure the Meshtastic radio's serial interface for: +For the UART-through-microcontroller setups, configure the Meshtastic radio's serial +module for: ```text Enabled: yes Mode: PROTO -Baud: 115200 +Baud: 115200 (must match the MicroPython UART) ``` +Meshtastic's serial-module default is 38400 baud; these examples deliberately use +115200. Set both ends to the same value. A direct USB connection to the radio does not +use this external-UART setup. + The radio's UART pin names depend on its model. Consult that board's pinout before connecting wires. @@ -168,8 +173,14 @@ mpremote connect /dev/cu.usbmodem2101 fs cp examples/xiao_rp2040.py :main.py mpremote connect /dev/cu.usbmodem2101 reset ``` -To stop or replace an automatically running script, connect the XIAO, press `Ctrl-C` -in `mpremote repl`, and upload a different `main.py`. +To replace an automatically running script, upload a different `main.py`; `mpremote` +stops the running program before filesystem commands. To disable automatic startup +without replacing it: + +```sh +mpremote connect /dev/cu.usbmodem2101 rm :main.py +mpremote connect /dev/cu.usbmodem2101 reset +``` ## Curses dashboard through the XIAO @@ -178,10 +189,17 @@ It displays live connection stats, known nodes, battery levels, SNR, hops, last- times, and incoming messages. The XIAO runs a small bridge between the radio UART and the dashboard over USB. +The header shows `Config: complete` after a matching configuration handshake. Some +radios do not return the matching completion ID; once local and node data are usable, +the dashboard shows `Config: ready` instead. + The dashboard needs a small bridge program on the XIAO. The regular `xiao_rp2040.py` example prints human-readable logs; it does not expose structured data to desktop applications. +Complete the XIAO quick-start installation through step 4 first, so the `micromesh` +package is present on the board. + ### 1. Install the desktop dependencies ```sh @@ -214,7 +232,8 @@ Only one application can own a serial port. Close `mpremote`, Arduino Serial Mon screen, minicom, and other serial tools before launching the dashboard. The message field is always active. Type a message and press Enter—there is no separate -compose mode. +compose mode. `message queued` means the radio accepted the packet for transmission; +it is not a delivery receipt. | Key | Action | | --- | --- | @@ -296,9 +315,11 @@ Send a **new** text from another Meshtastic node while MicroMesh is running. ### A frame is skipped or reports a decode error -Meshtastic's protobuf schema evolves. MicroMesh reports and skips newer messages that -its compact schema does not yet understand. Text and other supported packets continue -processing. Update the installed files after pulling a newer MicroMesh version: +Meshtastic's protobuf schema evolves. Unknown fields are retained automatically. When +an `on_error` callback is configured—as it is in the board examples—MicroMesh reports +and skips a frame if it is malformed or conflicts with the compact schema; later +frames continue processing. Without that callback, `poll()` raises the decoding error. +Update the installed files after pulling a newer MicroMesh version: ```sh mpremote connect /dev/cu.usbmodem2101 mip install package.json @@ -324,14 +345,19 @@ def received(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"), - )) + try: + text = packet.decoded.payload.decode("utf-8") + except UnicodeError: + text = repr(packet.decoded.payload) + print("from !%08x: %s" % (packet.from_, text)) + + +def decode_error(error, payload): + print("skipping undecodable frame:", error, "(%d bytes)" % len(payload)) uart = UART(0, 115200, tx=Pin(0), rx=Pin(1), timeout=0, rxbuf=1024) -mesh = SerialInterface(uart, on_packet=received) +mesh = SerialInterface(uart, on_packet=received, on_error=decode_error) mesh.connect() while True: @@ -355,8 +381,9 @@ mesh.sendPosition(45.5152, -122.6784, altitude=15) mesh.sendData(b"custom", portNum=PortNum.PRIVATE_APP) ``` -Payloads may be at most 233 bytes. `poll()` is non-blocking when the UART uses -`timeout=0`, so call it frequently from your main loop. +Data payloads may be at most 233 bytes, including UTF-8 encoded text bytes rather than +characters. `poll()` is non-blocking when the UART uses `timeout=0`, so call it +frequently from your main loop. ### Inspect connection state @@ -437,8 +464,8 @@ python -m pip install -e . python -m unittest discover -s tests ``` -The test suite covers encoding, decoding, framing, serial recovery, sending, and -configuration state. +The test suite covers encoding, decoding, field presence, framing, serial recovery, +sending, input validation, and configuration state. ## Releasing to PyPI diff --git a/examples/README.md b/examples/README.md index d71e889..40cb223 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,8 +1,9 @@ # 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. +Meshtastic radio. The radio must have its serial module enabled in `PROTO` mode. The +examples choose 115200 baud, so configure the radio to match; Meshtastic's serial +module defaults to 38400 baud. | Board | Example | MicroPython UART | TX pin | RX pin | | --- | --- | --- | --- | --- | @@ -21,9 +22,11 @@ python -m pip install -e . pyserial python examples/curses_dashboard.py /dev/cu.usbmodem123456 ``` -For a radio wired to a XIAO RP2040, first install the bridge as the XIAO's boot script: +For a radio wired to a XIAO RP2040, install MicroMesh and then install the bridge as +the XIAO's boot script: ```sh +mpremote connect /dev/cu.usbmodem2101 mip install package.json mpremote connect /dev/cu.usbmodem2101 fs cp examples/xiao_dashboard_bridge.py :main.py mpremote connect /dev/cu.usbmodem2101 reset python examples/curses_dashboard.py --xiao-bridge /dev/cu.usbmodem2101 @@ -37,7 +40,7 @@ Use `mpremote connect list`, `python -m serial.tools.list_ports`, or your operat system's device list to find the radio port. The message field is always active: type and press Enter to send. Use Escape to clear it, F5 to refresh, and F10 to quit. `Ctrl-R` and `Ctrl-Q` are also supported. On Windows, install `windows-curses` and use -a port such as `COM4`. +a port such as `COM4`. A `message queued` status is not a delivery receipt. ## Wiring diff --git a/examples/curses_dashboard.py b/examples/curses_dashboard.py index 32eebbb..3f5f1ce 100644 --- a/examples/curses_dashboard.py +++ b/examples/curses_dashboard.py @@ -21,6 +21,7 @@ from micromesh import PortNum, SerialInterface EVENT_PREFIX = "MMEVT " COMMAND_PREFIX = "MMCMD " +MAX_BRIDGE_BUFFER = 8192 class PySerialUART: @@ -85,7 +86,10 @@ class XiaoBridge: command = {"type": kind} command.update(values) line = COMMAND_PREFIX + json.dumps(command, separators=(",", ":")) + "\n" - self.port.write(line.encode("utf-8")) + data = line.encode("utf-8") + written = self.port.write(data) + if written is not None and written != len(data): + raise OSError("short serial write") def connect(self): self._command("refresh") @@ -101,6 +105,11 @@ class XiaoBridge: waiting = self.port.in_waiting if waiting: self._buffer.extend(self.port.read(waiting)) + if len(self._buffer) > MAX_BRIDGE_BUFFER and b"\n" not in self._buffer: + self._buffer = bytearray() + self.state.decode_errors += 1 + self.state.status = "discarded oversized bridge line" + return while True: newline = self._buffer.find(b"\n") if newline < 0: @@ -112,8 +121,11 @@ class XiaoBridge: if marker < 0: continue try: - self._event(json.loads(line[marker + len(EVENT_PREFIX):])) - except (ValueError, TypeError) as error: + event = json.loads(line[marker + len(EVENT_PREFIX):]) + if not isinstance(event, dict): + raise ValueError("event is not an object") + self._event(event) + except (KeyError, ValueError, TypeError) as error: self.state.decode_errors += 1 self.state.status = "bad bridge event: %s" % error @@ -206,6 +218,17 @@ def node_values(info): return node_id(info.num), name, short, snr, battery, hops, heard +def config_status(mesh, state): + """Describe useful progress even when the completion ID never matches.""" + if mesh.config_complete: + return "complete" + if mesh.my_info is not None and mesh.nodes: + return "ready" + if state.frames or mesh.my_info is not None or mesh.nodes: + return "receiving" + return "waiting" + + def add_line(screen, row, text, style=0): height, width = screen.getmaxyx() if 0 <= row < height and width > 1: @@ -225,13 +248,13 @@ def draw(screen, mesh, state): add_line(screen, 0, " MicroMesh dashboard ", title_style) local = node_id(mesh.my_info.my_node_num) if mesh.my_info else "unknown" - connected = "yes" if mesh.config_complete else "waiting" + config = config_status(mesh, state) uptime = int(time.monotonic() - state.started) add_line( screen, 1, "Local: %s Config: %s Uptime: %ds Nodes: %d" % ( - local, connected, uptime, len(mesh.nodes) + local, config, uptime, len(mesh.nodes) ), ) add_line( @@ -348,10 +371,10 @@ def run(screen, port_name, baudrate, xiao_bridge): state.status = "configuration requested" elif key in (10, 13, curses.KEY_ENTER): text = state.draft.strip() - state.draft = "" if text: try: mesh.sendText(text) + state.draft = "" state.text_sent += 1 state.add_message("local", "all", text) state.status = "message queued" @@ -372,7 +395,10 @@ def run(screen, port_name, baudrate, xiao_bridge): def main(): parser = argparse.ArgumentParser(description="MicroMesh curses dashboard") parser.add_argument("port", help="Meshtastic serial port, such as /dev/cu.usbmodem1234") - parser.add_argument("--baud", type=int, default=115200, help="UART baud rate (default: 115200)") + parser.add_argument( + "--baud", type=int, default=115200, + help="UART baud rate (default: 115200)", + ) parser.add_argument( "--xiao-bridge", action="store_true", diff --git a/examples/esp32.py b/examples/esp32.py index 98ae97c..6fa12e8 100644 --- a/examples/esp32.py +++ b/examples/esp32.py @@ -18,13 +18,19 @@ 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") - )) + try: + text = packet.decoded.payload.decode("utf-8") + except UnicodeError: + text = repr(packet.decoded.payload) + print("from !%08x: %s" % (packet.from_, text)) + + +def on_error(error, payload): + print("skipping undecodable radio frame:", error, "(%d bytes)" % len(payload)) 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 = SerialInterface(uart, on_packet=on_packet, on_log=print, on_error=on_error) mesh.connect() hello_sent = False diff --git a/examples/raspberry_pi_pico.py b/examples/raspberry_pi_pico.py index 6cbf932..9f0a13d 100644 --- a/examples/raspberry_pi_pico.py +++ b/examples/raspberry_pi_pico.py @@ -16,13 +16,19 @@ 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") - )) + try: + text = packet.decoded.payload.decode("utf-8") + except UnicodeError: + text = repr(packet.decoded.payload) + print("from !%08x: %s" % (packet.from_, text)) + + +def on_error(error, payload): + print("skipping undecodable radio frame:", error, "(%d bytes)" % len(payload)) 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 = SerialInterface(uart, on_packet=on_packet, on_log=print, on_error=on_error) mesh.connect() hello_sent = False diff --git a/micromesh/__pycache__/__init__.cpython-311.pyc b/micromesh/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index d1612a5594a0f18e311a4dca1b46aeae52e5e1be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1322 zcmdUtO>fgM7{{G9U6OX~y7zbJ0U=e0bzBhwm34?o+bG=-!WYX++$|8tmF-S+r+om# z5k3Oq+w3^`G>H?pNxSWYpA^{OGOjq*PtWrtvH#D@mvXs??E3JLzZH3ezJc>=<$f9$ zpRDm2MF^wFiE@d@FFhq5-#ykJj%#;OqX*jswm5kLOuBp;-SPPSwrMlJ?vX=eQ z9_Ry+u6%cFc6HZOo_1a|TWigoUD9l|I;Prc?lwug`DW9G$>#dD8QWnprpZRCne5V# z89x;fBWa)4t0q_zsnRsn%D646sVTSEVH~n8rez!|6FifGcW4v~6Fg^B>ODra2{y%k zhlN5$rU+8oG4?5&tyqQPkja7Z zz>m{?iODUuoZA+n{n2PQ*Q;xD% z7U}d#FFYVkQ=>c4T510c40GKaX0*nI6fC^v?Fbk*x<^kh? z3BV+v0GI+y17-jVfJMNF#df`BJVGK75@G_Ya@d-orc6$D27GFKKFmuE#b!GHL1L!v zFJXUbGJemN)6Rj)^06*daj)MUmLbdRw`@})#P&(qK3d6EJ53TozM=~&5nZ+$&@0v0 zV`&oi6dN?ICb&WNV|~!yYxJV`i3ll-f@*LWf1BCCHH(lqjWr=Fy}_mbh>NJ7u*dSQ zy#R(?-LpK;JC1XT)=$v-8S0$ew9j*{lRHQM7mCQab%K_!?^CpOa??JmOdprd-H}r6 L*n>yju|@j<5KUrD diff --git a/micromesh/__pycache__/interface.cpython-311.pyc b/micromesh/__pycache__/interface.cpython-311.pyc deleted file mode 100644 index 3f45c8fd8ba1a2bf2d6f4cfa77912f44af33df67..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9804 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 TRnvEvX7Z1YUGx{!F8cofC(enT diff --git a/micromesh/__pycache__/mesh_pb2.cpython-311.pyc b/micromesh/__pycache__/mesh_pb2.cpython-311.pyc deleted file mode 100644 index 9346c389eb54472db81193d351014934ce7529ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 311 zcmY*Tu}T9$5Z%29i9#A1E8*G`k_BtA5`=(3P#c@&?siO8c5j!R4SN29AK)j5f0I(K z60Gcmbg7(;rf?YEykQ>m-s^C<$Ec>y_Wpt9XIi$E{gKr^kr$@eEmK@&hF>$)yJE%8 z)7d#R5XuN@WSv?V7-4J;CCA@kxt|o8Sps>0Q vE}4l0n{C6Sua3vQT@OxvQ=f)iGDtD-kl?$=Ie%x9C7XP(gXRAYeNy}Y%Y9w{ diff --git a/micromesh/__pycache__/messages.cpython-311.pyc b/micromesh/__pycache__/messages.cpython-311.pyc deleted file mode 100644 index 5aec2a86931e4a03b2edab9c346f4bc4f0988846..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14234 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 PBsjsqMaijzh@bJlY28vd diff --git a/micromesh/__pycache__/portnums.cpython-311.pyc b/micromesh/__pycache__/portnums.cpython-311.pyc deleted file mode 100644 index fc4ce5200005861a928dec830a64aeea92e01753..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1888 zcmZXU&2JM&6u@`=5hw9SoY;;{4CEU~s$vNaTu{}CJ#m(JKeW3}$cT32WLDtHj$PZ4 zN+pC_MQTtva4J1=qOFknw*;w&)t)N#iHaW+PxY@Ebcl*Cz#Bv_nGumnl62{Or&WQt9a6q_PxmLeIJ zCetiKvTT~bQ1P}p?0>%Jv z)q^M?28aXZ0P}zaz#?D?unhPdfB$=JFEm5OVwczzlCm>a7*~mUs z@LFxqHH?bGVvJin79>1RUi1V7PMApJm9?=Y!jIdyf>msrBD4rG#6fXOQL7ciBk|8o z*d{7ssI0hvq=uF9#+q)PmmS$?6M`)(nwLFpj)@R7`ULijl^o12WOxPGF5*bHB4m-4Wmu? zro)0_(+jHT{%_Jd{>@77jY74{J4s>J*>BX_UFUJ(yzdm~!+P)0!ET|=zG<{X|LGQ* z^}SB3$(vIF;Li%slz30--fTbazz^#Ke%kJz9R0J$ol5mL-E8hSly+zG$Dyp7O7|-Q z{!((Xueb|~$DvuF^ym32$Dy2?oZ?GD%w7Tqz9K!{SKUmuuetNw>Z&`P>+1rOss24T zH**}yh;@HwBH1sy=QhI8RGJ6n`|z!ONgQ;(lFINnGdX_FR0ei~G#>94;puFhrmU?* zya=VNwMVI6124$x9WU&+0WVVT9<?YPvMjsO>anzXGIB#soJb$zZ_xd7zOW@riOD1DMfTZ)=MPS#fPC9W zAG?v*4^O^-^4-(JrzesezU}50e!lbS&ae1w{?;G)TSt+j$g{EMV=tE9j^_S737;DJ GQT`7cNfGY= diff --git a/micromesh/__pycache__/portnums_pb2.cpython-311.pyc b/micromesh/__pycache__/portnums_pb2.cpython-311.pyc deleted file mode 100644 index b60fbfdc6307deb4333766e0ae57a99e5883a7fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 321 zcmY+8y-Gtd6vvZWp;hSO;v{6fLK|=vS3yt<)}>3h`Orp^n~>y!SMUjZ03Shon~riP z!O2bN*2$B83?2?A=i^8I|M%hW072Zm+Q%1ozw@w*V%sbap?O0R&5*>hFnEe&?-rGN z&zBdfR-sBtj;xc7QKOhzqlj?US4neibZUvV-h~v&y$huB-e>_gSChOYbz`{-ar}cp zq{JOQxh{*Y&zN+Av2KtdVB!Z_W;?4Xy^Fw8u4bTZzX|etQ_>Pc&Je$rn0W{Ljz_;tzK7 BV$J{n diff --git a/micromesh/__pycache__/protobuf.cpython-311.pyc b/micromesh/__pycache__/protobuf.cpython-311.pyc deleted file mode 100644 index 2ca5be2f24257f50aa28df07a6dc1b9adf259f5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17827 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 diff --git a/micromesh/__pycache__/stream.cpython-311.pyc b/micromesh/__pycache__/stream.cpython-311.pyc deleted file mode 100644 index 0404023ac90c003fa77980b5b87c201b311d839d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4392 zcmbtXU2GHC6~6OhJN`**;~_t_TPFW83!w|N{A?N&aF(SB*#cR*WV?*Ko(Y&Z3XeN6)DujHZH*b1?69EfEkq6s8Y6O>3#&>}Oz zi0lL_nr@;A6OB$I(R>q$mWMP#kKkW!6K1jD0`gnqBWI=9RV5fx!XZ8;;czg*PvYQo zSiZ8456hx7BLTa6t(WE&p=rj095JNnUg$z+Z;;9@zvMw+|C6G{6 zh7vS9)#67n)dtW*ARpBpfvYC-Te)iSC6e`}#=th~bnu3tyHztsWg!y1qFKo*1~CrK zX%^w5*~v)>Yb+V@Gq6TdWD+-nYTN=4BYJtB$1b>Q4T2Ds!-^opcN=HiU+vyS-q;JU zfbwk}Np`U*-|T~%z0ktn{$%MBARPtnySGP{Mu4;?;lkvdP}S`~BNA^!>&9M~%`&=z z=II1ALy1%XrRWcB^GpI=BYb*`4I~)F@b;8WW3Q1XZ5Xe<2fKr*-xU;+cPCJ~=I2e$ z?RXd8R!D$Wno(Q5RFSS&B!QiVJ^FvyIk@ed?TI?ih|B<3C-*6hRVyKz09hARwV~IT z`PatT?$ozC^On+7GYC~YBpbDfY+H>%G{s@9>PF3*qjAWp^K62J@miLwWZd05^GJ$X zJeq*bUa_MuL2o}-k*Z&D3hh|_)&IdO`VPmR*3J7hz4H266D&C~DxNs~pFE*>YKv31 zF2+?A(Wmt0D%E?g{%Oa4q!%3t8nT^bOwT1nl241ok|Npn7!FZXB)i&g!$dQ70BGjm z3@H;dD@l6-$%mNiXG}H=ZY3PHcZP=o!{>$3;jwZ$Byr?7VJ{hE#h?--2U}=LQeqmb z3vj|njO1|0m(`5PPQqatch_Z0(nVCn{sN{#rrgCPEqSgrHL`N5gnZTm&j}<0c~8sI zKna-|+km?F!#jhiLEu^%eaiJ_x!yH4!|l&;`&Iq-jB8yUyEB$PoZ z=dRS@rGbo-&pG+@P}bS4I=lb7to-h_dl&CqPF>FT>`euhM$_)w@21}UTj##d-@5-6 z=>RLXD$6@ui=$5+d$Nu_X*T2N&N;eOM|YhG4}P3=_N&hRe5-HOkv_NTTzfOqx^JER z((&Znm(EPfi4+Ugt#qySXFYwYr?0T7^7bfLwQ}yxrPQT7=hd|z%5c3ou2=5Ky@GZ7KH^*b4poCWISCtPuCx8>*JZeV_DBJ)pM+(=fMxw zxW~RfUR$49pUU}9W;##hI!|S|K#mKjT)?P0?`%#sZ9UFNKyc;Fj3>$l7H2bcKDAvaIUhdYt6aO^_3mqH?&HWVLVaix)p z+^93~$`Mj;!#sTn$?SD>gSmvRL!pzd&7&%h+!J*DTRYw8q@+>E9;-B?UG)-0^OVkm zeMX*i?I5U1Aos+U^iA~W<&$O1PZ!CVUDUGzscYhv@ZWeJU<6TQ znbC*c^H4e_s1SXFp3)tGpMB;;3H|Zr?WcbX#$x{ffE;XhrjDw1FZ}XOPYVAko{X=EtKnpPE%10G z886XRcXPqZr@8xW8Sfi8?;B6O2eaOT>qj!)w{l)ci>bhiLQ7`}S=`{S8Z;o^_kMTe zvm=|+e%9^3f2m{w&pao-{e}QgK0^7LiJVlRWCZ~)Uc4x|kbC#C{f>Plka4x=T zz0lfz*S=~`1{TjG;VLw`QruG8t;>s-S3=pwcD1pcWa2YV9lor?w=$k_bmSZzYV`+8 z*wg_$G4h4=S?>^yzM}hw4l!RHqyYUEQsK++6(|UrO%SG|;%tP_4ng?&Y%o$D!DLHd zAAvps#Km7AeL3~O!yM@)K$OHr0TyakzJ2fd;Syp^{Hx&DV`?d(8l0qDCUECgC@Ui3 z$G~2TAc)bBAn2!h`Sk9Bp|Xucf7zNJ5sG|il($C1KzV9|_yqKk%^3>;lo*Pl^5~q3 t&ONg>E|~K=G&U{R@($;MwPZF?!xWszo9H@9fstF`IqCamn*rU3e*?^Ia;N|R diff --git a/micromesh/interface.py b/micromesh/interface.py index b4d9ff3..1f20135 100644 --- a/micromesh/interface.py +++ b/micromesh/interface.py @@ -34,14 +34,21 @@ def node_num(value): value = value.strip() if value.startswith("!"): value = value[1:] - return int(value, 16) - return int(value) + value = int(value, 16) + else: + value = int(value) + if not 0 <= value <= 0xFFFFFFFF: + raise ValueError("node number must fit in 32 bits") + return value class SerialInterface: """A non-blocking Meshtastic client around a ``machine.UART``-like object. - ``poll()`` should be called regularly. Callbacks receive ``FromRadio`` objects. + ``poll()`` should be called regularly. ``on_receive`` receives each + ``FromRadio`` message, ``on_packet`` receives its ``MeshPacket`` payload, + ``on_log`` receives a text line, and ``on_error`` receives an exception and + the undecodable frame payload. """ def __init__(self, uart, on_receive=None, on_packet=None, on_log=None, @@ -102,6 +109,8 @@ class SerialInterface: def sendText(self, text, destinationId=BROADCAST_ADDR, wantAck=False, wantResponse=False, channelIndex=0, hopLimit=None): + if not isinstance(text, str): + raise TypeError("text must be a string") return self.sendData(text.encode("utf-8"), destinationId, PortNum.TEXT_MESSAGE_APP, wantAck, wantResponse, channelIndex, hopLimit) @@ -109,10 +118,15 @@ class SerialInterface: def sendPosition(self, latitude, longitude, altitude=0, destinationId=BROADCAST_ADDR, wantAck=False, channelIndex=0, hopLimit=None): + latitude = float(latitude) + longitude = float(longitude) + if not -90 <= latitude <= 90: + raise ValueError("latitude must be between -90 and 90") + if not -180 <= longitude <= 180: + raise ValueError("longitude must be between -180 and 180") position = Position(latitude_i=int(latitude * 10000000), - longitude_i=int(longitude * 10000000)) - if altitude: - position.altitude = int(altitude) + longitude_i=int(longitude * 10000000), + altitude=int(altitude)) return self.sendData(position, destinationId, PortNum.POSITION_APP, wantAck, False, channelIndex, hopLimit) diff --git a/micromesh/messages.py b/micromesh/messages.py index cc78897..75424d4 100644 --- a/micromesh/messages.py +++ b/micromesh/messages.py @@ -17,7 +17,8 @@ class Position(ProtoMessage): altitude_hae=Field(9, "sint", optional=True), altitude_geoidal_separation=Field(10, "sint", optional=True), PDOP=Field(11), HDOP=Field(12), VDOP=Field(13), gps_accuracy=Field(14), - ground_speed=Field(15, optional=True), ground_track=Field(16, optional=True), fix_quality=Field(17), + ground_speed=Field(15, optional=True), ground_track=Field(16, optional=True), + fix_quality=Field(17), fix_type=Field(18), sats_in_view=Field(19), sensor_id=Field(20), next_update=Field(21), seq_number=Field(22), precision_bits=Field(23), ) @@ -55,6 +56,12 @@ class Routing(ProtoMessage): DUTY_CYCLE_LIMIT = 9 BAD_REQUEST = 32 NOT_AUTHORIZED = 33 + PKI_FAILED = 34 + PKI_UNKNOWN_PUBKEY = 35 + ADMIN_BAD_SESSION_KEY = 36 + ADMIN_PUBLIC_KEY_UNAUTHORIZED = 37 + RATE_LIMIT_EXCEEDED = 38 + PKI_SEND_FAIL_PUBLIC_KEY = 39 FIELDS = fields( route_request=Field(1, "message", RouteDiscovery), @@ -68,6 +75,7 @@ class Data(ProtoMessage): portnum=Field(1), payload=Field(2, "bytes"), want_response=Field(3, "bool"), dest=Field(4, "fixed32"), source=Field(5, "fixed32"), request_id=Field(6, "fixed32"), reply_id=Field(7, "fixed32"), emoji=Field(8, "fixed32"), bitfield=Field(9, optional=True), + xeddsa_signature=Field(10, "bytes"), ) @@ -91,11 +99,12 @@ class MeshPacket(ProtoMessage): "encrypted": Field(5, "bytes"), "id": Field(6, "fixed32"), "rx_time": Field(7, "fixed32", optional=True), "rx_snr": Field(8, "float"), "hop_limit": Field(9), "want_ack": Field(10, "bool"), - "priority": Field(11), "rx_rssi": Field(12, "int", optional=True), "delayed": Field(13), + "priority": Field(11), "rx_rssi": Field(12, "int", optional=True), + "delayed": Field(13), "via_mqtt": Field(14, "bool"), "hop_start": Field(15), "public_key": Field(16, "bytes"), "pki_encrypted": Field(17, "bool"), "next_hop": Field(18), "relay_node": Field(19), "tx_after": Field(20), - "transport_mechanism": Field(21), + "transport_mechanism": Field(21), "xeddsa_signed": Field(22, "bool"), } ) ONEOFS = {"payload_variant": ("decoded", "encrypted")} @@ -103,8 +112,9 @@ class MeshPacket(ProtoMessage): class DeviceMetrics(ProtoMessage): FIELDS = fields( - battery_level=Field(1), voltage=Field(2, "float"), channel_utilization=Field(3, "float"), - air_util_tx=Field(4, "float"), uptime_seconds=Field(5), + battery_level=Field(1, optional=True), voltage=Field(2, "float", optional=True), + channel_utilization=Field(3, "float", optional=True), + air_util_tx=Field(4, "float", optional=True), uptime_seconds=Field(5, optional=True), ) @@ -113,9 +123,10 @@ class NodeInfo(ProtoMessage): num=Field(1), user=Field(2, "message", User), position=Field(3, "message", Position), snr=Field(4, "float"), last_heard=Field(5, "fixed32"), device_metrics=Field(6, "message", DeviceMetrics), channel=Field(7), - via_mqtt=Field(8, "bool"), hops_away=Field(9, optional=True), is_favorite=Field(10, "bool"), + via_mqtt=Field(8, "bool"), hops_away=Field(9, optional=True), + is_favorite=Field(10, "bool"), is_ignored=Field(11, "bool"), is_key_manually_verified=Field(12, "bool"), - is_muted=Field(13, "bool"), + is_muted=Field(13, "bool"), has_xeddsa_signed=Field(14, "bool"), ) diff --git a/micromesh/protobuf.py b/micromesh/protobuf.py index 0698815..98f35cd 100644 --- a/micromesh/protobuf.py +++ b/micromesh/protobuf.py @@ -24,6 +24,8 @@ def encode_varint(value): value = int(value) if value < 0: value &= 0xFFFFFFFFFFFFFFFF + if value > 0xFFFFFFFFFFFFFFFF: + raise ValueError("varint exceeds 64 bits") out = bytearray() while value > 0x7F: out.append((value & 0x7F) | 0x80) @@ -39,6 +41,8 @@ def decode_varint(buf, offset=0): while offset < length and shift < 70: byte = buf[offset] offset += 1 + if shift == 63 and byte > 1: + raise DecodeError("invalid 64-bit varint") value |= (byte & 0x7F) << shift if not byte & 0x80: return value, offset @@ -65,7 +69,8 @@ def _skip(buf, offset, wire): class Field: - def __init__(self, number, kind="uint", message=None, repeated=False, packed=False, optional=False): + def __init__(self, number, kind="uint", message=None, repeated=False, + packed=False, optional=False): self.number = number self.kind = kind self.message = message @@ -153,9 +158,13 @@ class ProtoMessage: self._values[name] = value def HasField(self, name): + if name.endswith("_") and name[:-1] in type(self).FIELDS: + name = name[:-1] return name in self._values def ClearField(self, name): + if name.endswith("_") and name[:-1] in type(self).FIELDS: + name = name[:-1] self._values.pop(name, None) def WhichOneof(self, name): @@ -174,26 +183,30 @@ class ProtoMessage: continue value = self._values[name] in_oneof = any(name in members for members in type(self).ONEOFS.values()) - if not in_oneof and not field.optional and not field.repeated and field.kind != "message": + implicit_scalar = ( + not in_oneof and not field.optional and not field.repeated + and field.kind != "message" + ) + if implicit_scalar: if value in (0, False, b"", "", 0.0): continue values = value if field.repeated else (value,) if field.packed and values: body = bytearray() for item in values: - body.extend(self._encode_scalar(field, item, include_tag=False)) + body.extend(self._encode_scalar(field, item)) out.extend(encode_varint((field.number << 3) | BYTES)) out.extend(encode_varint(len(body))) out.extend(body) else: for item in values: out.extend(encode_varint((field.number << 3) | field.wire)) - out.extend(self._encode_scalar(field, item, include_tag=False)) + out.extend(self._encode_scalar(field, item)) for raw in self._unknown: out.extend(raw) return bytes(out) - def _encode_scalar(self, field, value, include_tag=False): + def _encode_scalar(self, field, value): kind = field.kind if kind == "message": data = value.SerializeToString() @@ -225,6 +238,8 @@ class ProtoMessage: start = offset tag, offset = decode_varint(data, offset) number, wire = tag >> 3, tag & 7 + if number == 0: + raise DecodeError("invalid protobuf field number: 0") entry = by_number.get(number) if entry is None: offset = _skip(data, offset, wire) @@ -238,7 +253,10 @@ class ProtoMessage: raise DecodeError("truncated length-delimited field") raw = data[offset:end] offset = end - if field.packed and field.wire != BYTES: + if field.wire != BYTES: + if not field.packed: + self._unknown.append(data[start:offset]) + continue values = self._values.setdefault(name, []) inner = 0 while inner < len(raw): @@ -303,7 +321,10 @@ class ProtoMessage: if isinstance(value, ProtoMessage): value = value.to_dict() elif isinstance(value, list): - value = [item.to_dict() if isinstance(item, ProtoMessage) else item for item in value] + value = [ + item.to_dict() if isinstance(item, ProtoMessage) else item + for item in value + ] result[name] = value return result diff --git a/pyproject.toml b/pyproject.toml index 9e6d831..474dbf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ license-files = ["LICENSE"] authors = [{name = "MicroMesh contributors"}] keywords = ["meshtastic", "micropython", "lora", "protobuf"] classifiers = [ + "Development Status :: 3 - Alpha", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: Implementation :: MicroPython", "Topic :: Communications :: Ham Radio", diff --git a/tests/__pycache__/test_meshmicro.cpython-311.pyc b/tests/__pycache__/test_meshmicro.cpython-311.pyc deleted file mode 100644 index aca38b2182e6459e07baa1b5ccb39f02b33dea6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11038 zcmbVSeQXqGdY{?PS+oAw#Wo&3eApy5i@$9GAxAzOY!e7J1Uq0hgt@h6Y{u+|Z)O|^ zvu@m$R+}sB!HH|!E9kaTN*#q(w7OHNbX7@pm5}<+%xa{yMzVBL|3YddN8}H5s+vB} zJG-+xyK9cz>+!Siyz_p|`~IHKcmA%rx{88y=Feht{}ZIB|H7Mma_2Hne+`+>D4yc! zC>6$EN0g2^!j70T?2Ng>u9!RQj(NhKm^bW=F=2+rI?kvsRu!&_Z3}OU`NMuv=89Iw zYQiwz zz(GOH`uwwdkohuyL&3=OIQb7%J;Bnkkk@bxMU;r)8<7&Rvk_iQ7*(T!eECdd`ih_! z-ZKeF8C!@M%$bBNDqS;UD}po=nHCK1M1nL7IgM>Y!gPWch9xN>8Pz%Q8CW?w?B+di=iy!BRLEyABath@`-5jEo??H~`Ht`gmks})P5}9g zQfT3@i^VB%?1w95U;hVau2oXt&Ts0UGUOXnoXASS7 zBr1v!hnIKBLUcxIfRSZvy?5_#&-T0z>mc_`3Gt{nFAG6iY zCB0v3>d>1yQmz}-8$0&ix_0{-Wc?}llMr{))=I#QwT9-#6t7K^nzL%!S96?_wnJNZ zcV|?HlUM(OmUcjqj4k#`O+XE2Bz|?tZ%r2Ns<8$zB+j!oc=O=x*R|Rfy|zVVTFA`C zY}<>RO1AfpAoCfOq*v-+Y(tWfK1 z9qHIloi~SWk7{+TdR?np*ZQy_<=zN1-c)Wcr_ZH@^f`S;rxxha16?W4y1(wm=xW>Q zE30j)zh%9)AysYetc70a*4kNn3FpkIUV-N!f|t;byT}DKUq|5vSqbO8H%R zdIpM$gd`2(&64XrCON1rEgJKE$cm`+{?*qDxuLA1lw*jr@{UA!K zjT)3fqbO-6P{`Qi0KPRM=IKw!*gQQ%eRvF*C_g7CEyV}2N@XP3aO1EuSdvz}&{-}e zOP}r#yZVaI$4mm)-NeJ&JIHM<@AXxz-Qg2bzf8)%*{p8zEoKzIK9<%sYONCsL z9}0|Z5g8=1GK_$bwn2#r)I95c6p1c?_A}KQZ|B`cl*l<%$4S8BJf}$F{L;(z)$FcZ zZ8wZ1PXYmKH=F`6_)gI#O$~1}?n@7AjR*9`1F7S{(^>!SjDPpt57!QBdj|A90}q6M z9e=drUoQUYqWa27&Ht|Me^({frn}G$!IEt&Jun*CN*Ftxn%k*m=}gg2@eBXNZT)i4j*UXT%i3>|cn7ELrj z8<$$b@r}BP;mHYZba;Gx@c1w{c;<}MkHrDgRuh+mi3C_%XoJ)edkd|-T!B|faJ)FJ z&$tTId4_DTmM&$ zcN(*7PloMz;LY})$n>7jdf(N1-__VtI(sV1PGr~#jXkHc=hR6qJ2{(~oYf{peNxnz zIh~nP^H(`PAX*7O{3T?-;s>-xsTf!YF~_ol2HRjOoX=S}pRIM>G3?J4ZmZ5YEi-(X9l=zMTz7)Wk zY}5@6P7HEq1}~gCJvhXj8XhC?l8#~*rrawuwoAO-1R=9RT#zD)z;m*46%?u%!}-Wd z9hES$MddaaEHC|-gKDMAg1~B%&UWbR(TCF-^R~{stuk*Hp6%AzE}ebj5p6w#$8eSJ zHm)?;?J*+#V0WOPJSkWwmk<%W^S;Y20n?Uz%-6%;d+=ZI!uT?EwdHsg0x0kz@4HLn z(~FE%;!zUh7EjH}E9ej85>Mav{vKm7r6?yT^^Fmfl0_epUWFk-v{A*2@^m5|7p4_c z&XB?=I5x@x_=uv6yn~iM6><}hnzHV7D3EZX60VFy^dzeGoWu9ONT1K@prfDfT84{0wFf1DW~q%ry~!xKN?oqlNx(cXHRC? ziy8K!#$M9dOX?2*m%PsKD#Kg2yt`u!YJ*_w*V+CoJCtFEGp}m5~0RBdDk81 z=k#Bo^HT(fEPUt-+wBSiSc+zHb+}X2Mr7;6KGoUg9789=SxU(}I)|g(M z=~bCtd-++7*{?JERc3#2`S*0T_rc2=^Qz9gs^+h94#gcKisyeJV>i1iD;T?XWmhbf z{VL$FBEE)Ua>?Gd0*6^R&9}f`;IM)WsKjAgwX!5cIj`i58Id~v&OeKf{;heJ_|3Q9 zehXMU1p^w}qKP;}zld5em`K!A5h0JM0_=b$Eu;(3ERSR+A&IkM92~fEEY}k(%i?St z;5wg+Z!Ybtgx8Wv$V;*W1TeXMSNhr`pE_|~-9D*ppVYTcrkIW3&RYj=AIJtfGr`Wa z$!yp0OxJO(>xAA_33On>tbL#{M|9?h${Z;|k+m zSO$YU1KD4QX)Zw>-iZ{DRJ2!iG*m`RMPI(C59l7lPy% zP6uD#Jw}8fLyJu?rKe~WcumvSL%ey@fFN)|(Pzp;l!SStIHky<0fG;r(Iw7&3&F`k z-1KSkk`By?dGO5y=_4>GdMknEc`e?8k>zUjUCY0fd}Ij zumD?dmE*?)vmp3M5-VXAk%7yXOQ7eiyZa^ayk!UPD303zZ$n)>+HB8V<u0-(-n#ZJ#GZlsdqA zuwNevPVt1nd*>Vj-U)7kJu=I1fIij}07u*5YZ2t2W;p$>-L4HpiTgS~_Y6xfEx z7zCdG$Pl2u3^0}vBM_K|WX5FsOwmaWa5%y<5_cJte^ih;vjuqvVh;e5T#ZjJy;X@t z484rjbO{)4zIQS&FJT&TCnez!w=j3 zar76E9RwX8tOwG#XLV}zhpSV!ucQyJ^{t&=_9m%gh@^LOk1Zk1dG=G&1zw01~s z?XO@&)TjNcf1oklI@7J@uX1j*EP`hs4`&^Z=jmYNR_dRwe7r4t&JaXK>5A1^Cl11uc=LKq!R)`A+TL#HKqsmsl zVjXdo)wk?=vp16_opYAuZs%B@-tw@<1DtGG?zY}Ma~Ss}Y0jEC?=96gFVr5HjwHU! zEr#~B`KQId$>QZz7|9%N&hHVpDwNjiup=q6b7H zr@#pmH?<(5?*eg92@k+IauVHr6c1XX@*9vbyCDW7j=e&y&8(QmK!zEOUGlX=B2!b- z(>y-`;W#+?hEv-(Ii@`U!W84UgVR@{D6%(BExzxdBvk#Ld!UllL)ad3az!lS7RYvcZt> zGW1MLW78nQ*CWwB;x0mnnJ9@mYc9EiGNU>X1(P{HE5o%Akre48Y>dO<={7_Y(Xl5w zBd;}HGPTC@7k+8r_t>p7FqAwA2*JqAA=Q4ZH|^J(22$1QZ3okFI1SX9eVY!q?^Q6} zZ@qT=wbal?Q~R1rYwFUQx>6_B8=A8X?U{!5HSfb~>cphl(5^LH&>JqKMt*bPu#sO*G6q4o;YS}_h)MN|NZVSn(sGfLq{{AqYo#3bx{kQ)I%p9_vxWg zt#(YW9ZR_idZYf%E->eIYRo~M1UyThG!}cGKd$~!^-ccv6*vQ0g9Fb#o#|7VK3fx= z?S!!TFK6{bV;XZ>XHKik=?WDdT-KO3b>>Z#d9(1e@eZ3Fu?$PFE#V^O$Y-w%)>B{A z`v&(oZHGs=(TMG*X84rHSdwKkg0+Ira;exFOw#vDypzf{+dXLeWxfN-F6Zrc%MP2K zE9rgNnWU3W&a%ot(jnfxu3JOF?dE($qgtnPtf!KL2sBA`fh?MZ<8bMv+_TYyIhHE|%jY?NBTU*j8oP8q8 zOMi@H83}T=jJ+Z&o7P_|;bZIs!mIdEj~Vz;BoGKPME>CbV5zSX7eBYj6b|&~K!Atq zccgq9wL9**?}`tPsLjJ#^RV7LoNYdzX+EztU(lN`XiZ_gDXi6gpx1r?wqX6f^pI8` z((6Mh-+FU<%BM5CEJWYkzIx&3mwt9h+ta1*>C)J4o$Y=Arvm*t)2}l9P>*nws5kaJ zsMnZ7dXfAnx6lwX1nvI-8TiD6O2cn?DvqYwQThs~vSL24W2rI^Ks3|lS&@&(!1FED zxu`w&QGKvuiw@HUqp1rpUd~H)jFGJjy5otE%kZ1J;A}*M<3$NAsUk1AG`PNpY`P*X z#Nqp+LVOQ&CbnMcthC=-)t-Z)(H3 zBfWqB3k8iUm+zHg_2{W#G+7giIiBhig6#=q%2bJa`)m_CpqGG zxnINE9Pu-5@Ovy$U+3=Jq%881$9;H{vPhl74SQ8i%s0gM!tZQi5i!njp$3ClfUnUI zN74v9GQsA;9Li)fa*et~LeWLc8Y5V+jmY`T)WVDjRJ7pD&ot@^RZMr1c)zCCi&nJ3 zOu&f8A`q$}tBw0;xNuG-n*kQ1`5n+3(oLv?8chB_Ab>ZTUZ-kT$lq_MOX_#O)~R>Y zt*>>eSFLnydg^HS4yc@bjb+~y9<;qj)36bh>CH*Kq3vt(@SEZ)mmQSLpIXW|S=Gs| by8ys*T1!eS)7IU$IBPr6TC(GdWUsB*GLx7~X(*X8FWQ+= zY%^3>-FmTf;C7vbvsO|Y%LTHj(sf(6*hO7ni#o+V1nA2QF@S^t1T4@e1AS2HJQ#~& z)Bit14ml*{I2nz8o;h=FbI$i)&-pu#r-p&6 z<~*Ggq_O$9MouTis1!{mH1FAn5DmwUC8UTj9iED4&haFz7_e%ah9gr+J~AQ*NkQ}Q z^da<4ScoPh%@axByZmFoqPfpXLL?kN9TvohpxLK|a6B?)j1>}vd3dq|9`3+*1zDa6 zT6iaKxy}Zyy!AR0wDAOHjt$y*2Ru1=+gT>y(#VnU<;WXDXU3mkdsBsm@Bya{|DTKl zxx+{-{N%4R`w2Y##>h!CAtPN{&OE75%4nZw&N82}V=xo0$!k(1EC}Ii8i~S($D@fE z&3Q$LN>U^NA8!*Qv1y?ZdKR(v)~$a!)At6&XeKIM znxE{OY*-tyEPU`&`iw(~=yabzO7g(vl=}^1$Q+R0UCl(HJ^~?Ne3zo{V2br{` z@X1!81**|c*6aujF<_-%uGy!O^S~PpAtKJlBoRlxb!&bqY``6S%45t0KGs12aRkUB zv$}0(HvWP2*6{67rLIG*>yYa@9yVs|Yrdvi((Q%pxojkRPTkzC_`m*8nYb->4y%pUcMG+9q$F&4&BpGlX5JOLXPDjJD!_3=h zs~=gJ7|H=m;-8lZ*aSCZXq&~lH%}pqy^U5zI z0N3*&Nr=v+UNX;SZ}noW(3dy?1km1i65!!F$?7ySvewj@9Z{Ngs7*UEM}f;%+^sox z>%F&^_bFR;satkEi2VE6N1Olct>3>T?>VlxPpIw_GQHRB#bzkFOiSs2-sn>DFjZzn zDd7y}uUR9_v%KZL)ohzAu&}t;O2w6Cv$l#%$S^Bc-d4Vl8?{#oF>@Q)5}97?@%j2U zgD#76QgNAkgLa>zRQXp?aN5V7;7~6`VzK1-r&i552gX+dj4$V0_*yI(<~7fia6$@! zvX}$?9}y9Og!S8Ljb;|0jZ-aP|61Mn$i#SPbmZ*Wp`#||c8Bc=! zg;t4|T3~4Zxxq=HCxf{h`DXCfx4`~n5)0@W~G zwP-txNxTIFl)Jy_-WByb`yMsR7v7fr7Zv|S)qgSL1nIs)nscOiY4Zve8X~WfFY*YY!VPRJf_7nOZgNm_=TRi$(_=LIg5qPYxP zQ8GfT!#T)5@1c#>V}8LheD@^E}T3yG#olPGDhJg9Ka@YxmT=gmUyicAv2Lg zLP=D7+0iD@Z@GTdsB+5Ljwfxt%I^;Ng@)zNeD!$>e** zcdaVdqjHBHvBo=i4R_^g<4n`l9;4C^RtGXFFA2-!FGK`yy>By1z^ox33+3?l6ZkLs zWqgIY+OR(Z0Sx$+_w8l!>3RAp^D!xMONVBpCG-&T7th{zev7_HIm#(YU1JobbkdIq zFGCjrR;%Ho;#4w`h)hYkoS~U9aCM}4@EW5c@(%g}Ou$Y-s>`~AkRjke1)Ldy>Pb}X zc^kTmVsK7KO37UzaVi`O3n4x-9Y$k26b69>jv07ld}?nsIDUP-ozRu|0T611>T>E{ zLpJ=N{?Ulc9ap&HDtCN^dn?DirEnKj?xOqyz$LE|UM9SO%X^!bp)?4#L6sX^;f8bE zu)-Ztxg(!%QpmVU#$_^IEZn^OeVOZ1xIUHZTj7Rs+>pW@R=LCS$Xg0|TP1JHg}V~g zhJE%g6-u3VM5$2Yt-S57^<(yL&>boPR8i5V*;xbjAw#(Fc2m9~>YkyP4Z#KzXk4H` zOF(X9m$8f)jMRq2Z>zY{mRSSFJX;P&V6%;C(O?iZSCu(8s)ZVvUR4GBMzxIDEKv~l z^089ZagIf=p4;sg2B4G2=zT+|!8Q%5TeC<>%@XA)?_7!s^qpJ4RVWH<&8dfEcws;0 zzk=it63RtX@eAT0^)1)e1-^ zNg+BDO@ISeiRA{0WidLF0JzTOBc7?wYIrRxguEmQKme2VE!lS;x#aQla{Yu-KcUu7 zWXPKTh4**d-m&8E&iT8SCsuln=6a4QJ;&6ZYM=uXX8BEp>{rQtnd~n?k;(;@cPXS# zC4F+?uEY?o2gT4+NT8`&DuY3uf$T3vIa5%Ew;}~3m8_K+4b>4-)t4`60~*a=CX_t4 zQcizlngHZsUi(zvNu!FB}AEUyMu+e51(i5}_oVw}jpx(T0Kv1}#=+k8)O2PtC ztU~yT27(Xb(`C+lJH^R-LicG3k`By?Iq=OQ!aHD4^j8DTvueBsJ&SWdz?7)v?u{;Y zDXoKQ>!4CQ_~5)!dmv+7^EJNTbh~NA*Ol{iEn5{|x9aPLz@FhCHQjAn_9KOR;51HT)2xb2@ue+$VW(DD8TAWd7ACYOG=GA@|oq`A0K?s z^|0gNnTId^w)auj=N+GSe%|?qZn^Ok#BE)Dlw?L$-Ss~kdv9!M?=Qdm(RZ`+N=uL0 z(xbS0Rd=sU?;`VU&hA~_D|ZZ5F(MkW?xnv_NUutI<-%RbjfO?=TlmsD%s1mkz-U!n z50bSiHvHGX3zXZ?kiq-}*1 zQ+iUM%!K-et>aEMy?EDi&y#)j8M8x8xEeiDG*xIEf6??n{r1EG1UZ9l>pb2K-$%V( zo#1y<#q=j~AQp~K^5H|Nk*Z<<^kGPXjmXgCd=wp42!0EA4kAPkB=jD@{q~r62oicT z=u1%Tx5z9{FY3F2njV5rx!S|w$;qiHo*#!8EF6G<&HDu1tv?1D6odCeQ zz)vcSO0&&}1xdJum9aZKZZc8CqA|7TddYO=t`UbgkU&suWY_o zo42dYyE2~DZM(Ax*c4Vt=eou2dKvt+_g}gFN@jSixog>`H20{@J(**xjcqH9UAe}t zW#_|pfdjd~frsP2e@h7*R|CgCA5a6Mig!%)j%92m%HeJc*zhkXWVcGA+~seYN<$7m z^StM|#oxXR8-L5N?>(TB0htV#s;FEy?3Mg(M%_E6kW(r-C6iNC3Ou-^kXKdms!U!j zzHPe8Wsex%1UM9MQycWoo}mWjvj*4D7OQE;5EmM;{X~zM^B6!h90f2@OAL-u7a+~v zFLOz&nVV3jerv-~i&y}^lU`?}WYsfIgLDC`KD=w)-I;XAG1BK5TQ)O5Kdiq-Z zwc*ar<9A_%;0^d8Nrum!2xLK%nmCJ0kt8Jajmv>Wn3z0F%W&Pe(Az5FMf1U+0vtK=~1{|mFs-~ zTS0>=8I;K&l%pIKRh#-AG$>@RS|UFxEi}XoMY|g`=TGfun7?N)#XE$XO|I|8+(7ru zO=YVa?ax9RU>^A|f0^*lrY#uWF^?u=pJsX6bpm@xCe6P9hek}?VXe*`=COe7$b7vm zmo9Vm%g;N~y{M9}D0~gb4#SRNm>Xq|`SZ7O!_GU4_8{*lNmsbv&&8`1HXXHETw_m= zQT+BqQn(HY;U_>$r_}H@wPBDMKVa`LH!N95RbFYZ?cp$s2^)n&R4jFxU<1G&!Sj|> zRl|`gOpAGcX^}FjAVI^aV;gefR5$@=3UFJNdgtQh=v)ZAGu;3ZD=naRpg9rGKRpE# z=*T~f{{85~J&(HNSB@*)C)Dl}i@Hq&dwK){7y8(Gp)}p}Ln&27K7QQ)_lPL`4UOSwN9Atbm*e3zV*(bpC3}{JJtHm z3@Md#6CZAYtqYKDc}KJK-MJ52?zUvZRkkUbvMgx*fxE5Q1%>QU$sU>P`L=TL0Za*; zz+|%UTX~gkEtYT0?Exdd)Iu=Jn(Ed;dEkPcvW7sgS-R<=;Z9}%blDY)?wzvC1?Vpy zG`5bV>Wrh%z4@3InhJhvhy7~6rulTk`Aj$p2XM|rW+GPw^gRV+X?-)T6;h=~TJ;UG z{2o~TOtj9W5UQAp;WmbJDWdO}fiW#zs=h(?4I02>ZQ?J003bcS6;Ef*)A{QY^1xxm zGpu@sF@|-Bk~MeZjT1Ldthl>!V9jnnptujJ?t`-X;JUR?gqHgPYkX-`D>hIq_4Mh} zYT3m+5rz5Pj-gG~&oxsWBshgHdO2U%*O&93;2XCoSN-O0!a2C8A5Ul$tV)^?}Rsz`~XP;35s9g$4K5o zavRAVBp)H!fduiUYr<|!AwdIJ{9ho8#^1WdYR6NRCA7wBzf5xs3OR>qjzJ;kBF!;q z4YBWEXAII{v-jg6-#l^K?62Tsp16q}g31Pg!mvUHao5?q*BOJn=&WUEzxln&ye@BiuQL5|wR_!B$HE!yO7aEf zeOY|5ZI)$WA*$0`6KdnOFX+oJON#`pjLn@%<*b}+