From a3d77a9a76dedcf9f754382e6439130f180e1c18 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 21 Oct 2026 17:35:37 -0700 Subject: [PATCH] Clean project references and separate optional bench tooling Remove unrelated ecosystem comparisons and software citations while retaining hardware links, measured model names, and required license notices. Preserve stock-peer runners and tests in a verified local validation package. Keep shared Companion USB transport in the firmware repository for the raw-radio sweep and OTA seeder, with focused CI coverage. Restore missing hosted documentation links. --- .github/workflows/run-unit-tests.yml | 5 +- README.md | 2 +- docs/cli_commands.md | 3 +- docs/faq.md | 20 +- docs/solarxiao_power.md | 4 + .../bootloader_version_stock_audit.md | 4 +- ..._qualification_f807_followup_2026-10-19.md | 2 +- ...ication_f807_followup_2026-10-19.safe.json | 2 +- .../nano_g2_regulator_2026-10-18.json | 8 +- .../solarxiao_advanced_2026-10-21.md | 4 + .../test-results/solarxiao_auto_2026-10-21.md | 4 + .../solarxiao_basic_2026-10-21.md | 4 + .../test-results/solarxiao_room_2026-10-21.md | 4 + docs/test-results/usb_serial_backpressure.md | 3 - licenses/ESPP-Meshtext-MIT.txt | 6 +- src/helpers/ui/OLEDDisplayProbe.h | 3 +- src/helpers/ui/RC32DisplayProbe.h | 2 +- test/test_companion_serial_hil.py | 270 ++++++ test/test_meshtext_auto_radio2_hil.py | 234 ------ test/test_meshtext_auto_radio2_hil_sdk.py | 175 ---- test/test_meshtext_delivery_hil.py | 672 --------------- test/test_meshtext_speed_hil.py | 288 ------- tools/hil/companion_raw_radio2_sweep.py | 3 +- tools/hil/companion_serial.py | 139 ++++ tools/hil/meshtext_auto_radio2.py | 653 --------------- tools/hil/meshtext_delivery.py | 773 ------------------ tools/hil/meshtext_speed.py | 369 --------- tools/hil/mota_usb_seeder.py | 3 +- variants/heltec_v4_r8/HeltecV4R8Board.cpp | 6 +- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 2 +- variants/lilygo_tbeam_1w/variant.h | 2 +- variants/meshtracker_x1/variant.h | 4 +- variants/muzi_base/variant.h | 7 +- variants/thinknode_m6/ThinkNodeM6Board.h | 3 +- variants/thinknode_m6/variant.cpp | 4 +- variants/thinknode_m6/variant.h | 4 +- variants/wio-tracker-l2/README.md | 5 +- variants/wio-tracker-l2/platformio.ini | 3 +- 38 files changed, 472 insertions(+), 3227 deletions(-) create mode 100644 test/test_companion_serial_hil.py delete mode 100644 test/test_meshtext_auto_radio2_hil.py delete mode 100644 test/test_meshtext_auto_radio2_hil_sdk.py delete mode 100644 test/test_meshtext_delivery_hil.py delete mode 100644 test/test_meshtext_speed_hil.py create mode 100644 tools/hil/companion_serial.py delete mode 100644 tools/hil/meshtext_auto_radio2.py delete mode 100644 tools/hil/meshtext_delivery.py delete mode 100644 tools/hil/meshtext_speed.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index b839e3f4b..9c4612c38 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -561,8 +561,7 @@ jobs: python3 -B test/test_radio_profile_auto.py -v python3 -B test/test_companion_meshtext.py -v python3 -B test/test_companion_meshtext_storage.py -v - python3 -B test/test_meshtext_delivery_hil.py -v - python3 -B test/test_meshtext_speed_hil.py -v + python3 -B test/test_companion_serial_hil.py -v python3 -B test/test_companion_raw_radio2_sweep.py -v python3 -B test/test_meshtext_listen_hil.py -v python3 -B test/test_mercer_mt_flash.py -v @@ -571,8 +570,6 @@ jobs: python3 -B test/test_indicator_radio_hal.py -v python3 -B test/test_mota_usb_seeder_hil.py -v python3 -B test/test_mota_espnow_chain_hil.py -v - python3 -B test/test_meshtext_auto_radio2_hil.py -v - python3 -B test/test_meshtext_auto_radio2_hil_sdk.py -v - name: Verify receive recovery and pinned radio status transport run: | diff --git a/README.md b/README.md index ccfdb8503..be306d88e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ MeshCore is a lightweight, portable C++ library that enables multi-hop packet ro ## What is MeshCore? MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console. -MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions, where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable. +MeshCore creates wireless mesh networks for embedded projects. Devices can communicate over long distances by relaying packets through intermediate nodes. This supports off-grid communication where internet or cellular service is unavailable. > **Upstream Observer WiFi and MQTT** - Observer firmware, release notes, and browser-based > flashing are available at [observer.gessaman.com](https://observer.gessaman.com/). diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 912f1290b..883e61c0a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1103,8 +1103,7 @@ override remains a fallback for existing custom builds. Any saved selection takes precedence, including `auto`, which restores detection even when that fallback forces a controller. -See [the Pocket v3 issue](https://github.com/meshcore-dev/MeshCore/issues/3296) -and [the RAK1921 detection caveat](https://github.com/meshtastic/firmware/pull/11265). +See [the Pocket v3 issue](https://github.com/meshcore-dev/MeshCore/issues/3296). --- diff --git a/docs/faq.md b/docs/faq.md index cfb03dbd3..ee8357205 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -63,7 +63,7 @@ A list of frequently-asked questions and answers for MeshCore - [5.1. Q: What are BW, SF, and CR?](#51-q-what-are-bw-sf-and-cr) - [5.2. Q: Do MeshCore clients repeat?](#52-q-do-meshcore-clients-repeat) - [5.3. Q: What happens when a node learns a route via a mobile repeater, and that repeater is gone?](#53-q-what-happens-when-a-node-learns-a-route-via-a-mobile-repeater-and-that-repeater-is-gone) - - [5.4. Q: How does a node discover a path to its destination and then use it to send messages in the future, instead of flooding every message it sends like Meshtastic?](#54-q-how-does-a-node-discover-a-path-to-its-destination-and-then-use-it-to-send-messages-in-the-future-instead-of-flooding-every-message-it-sends-like-meshtastic) + - [5.4. Q: How does a node discover and reuse a path to its destination?](#54-q-how-does-a-node-discover-and-reuse-a-path-to-its-destination) - [5.5. Q: Do public channels always flood? Do private channels always flood?](#55-q-do-public-channels-always-flood-do-private-channels-always-flood) - [5.6. Q: What is the public key for the default public channel?](#56-q-what-is-the-public-key-for-the-default-public-channel) - [5.7. Q: Is MeshCore open source?](#57-q-is-meshcore-open-source) @@ -75,7 +75,7 @@ A list of frequently-asked questions and answers for MeshCore - [5.13. Q: Can I use a Raspberry Pi to update a MeshCore radio?](#513-q-can-i-use-a-raspberry-pi-to-update-a-meshcore-radio) - [5.14. Q: Are there projects built around MeshCore?](#514-q-are-there-projects-built-around-meshcore) - [5.15. Q: Are there client applications for Windows or Mac?](#515-q-are-there-client-applications-for-windows-or-mac) - - [5.16. Q: Are there any resources that compare MeshCore to other LoRa systems?](#516-q-are-there-any-resources-that-compare-meshcore-to-other-lora-systems) + - [5.16. Q: Where can I learn more about MeshCore?](#516-q-where-can-i-learn-more-about-meshcore) - [6. Troubleshooting](#6-troubleshooting) - [6.1. Q: My client says another client or a repeater or a room server was last seen many, many days ago.](#61-q-my-client-says-another-client-or-a-repeater-or-a-room-server-was-last-seen-many-many-days-ago) - [6.2. Q: A repeater or a client or a room server I expect to see on my discover list (on T-Deck) or contact list (on a smart device client) are not listed.](#62-q-a-repeater-or-a-client-or-a-room-server-i-expect-to-see-on-my-discover-list-on-t-deck-or-contact-list-on-a-smart-device-client-are-not-listed) @@ -194,7 +194,7 @@ If you have more supported devices, you can use your additional devices with the ### 2.2. Q: Does MeshCore cost any money? **A:** All radio firmware versions (e.g. for Heltec V3, RAK, T-1000E, etc.) are free and open source developed by Scott at Ripple Radios. -The native Android and iOS client uses the freemium model and is developed by Liam Cottle, developer of meshtastic map at [meshtastic.liamcottle.net](https://meshtastic.liamcottle.net) on [GitHub](https://github.com/liamcottle/meshtastic-map) and [reticulum-meshchat on GitHub](https://github.com/liamcottle/reticulum-meshchat). +The native Android and iOS client uses the freemium model and is developed by Liam Cottle. The T-Deck firmware is free to download and most features are available without cost. To support the firmware developer, you can pay for a registration key to unlock your T-Deck for deeper map zoom and remote server administration over RF using the T-Deck. You do not need to pay for the registration to use your T-Deck for direct messaging and connecting to repeaters and room servers. @@ -216,7 +216,7 @@ If you have consensus from your community in your region to update your region's ### 2.4. Q: What is an "advert" in MeshCore? **A:** -Advert means to advertise yourself on the network. In Reticulum terms it would be to announce. In Meshtastic terms it would be the node sending its node info. +An advert announces a node and its public information to the network. MeshCore allows you to manually broadcast your name, position and public encryption key, which is also signed to prevent spoofing. When you click the advert button, it broadcasts that data over LoRa. MeshCore calls that an Advert. There's two ways to advert, "zero hop" and "flood". @@ -570,7 +570,7 @@ when explicitly configured, but separating the roles is generally preferable. In the case if users are moving around frequently, and the paths are breaking, they just see the phone client retries and revert to flood to attempt to re-establish a path. -### 5.4. Q: How does a node discover a path to its destination and then use it to send messages in the future, instead of flooding every message it sends like Meshtastic? +### 5.4. Q: How does a node discover and reuse a path to its destination? Routes are stored in sender's contact list. When you send a message the first time, the message first gets to your destination by flood routing. When your destination node gets the message, it will send back a delivery report to the sender with all repeaters that the original message went through. This delivery report is flood-routed back to you the sender and is a basis for future direct path. When you send the next message, the path will get embedded into the packet and be evaluated by repeaters. If the hop and address of the repeater matches, it will retransmit the message, otherwise it will not retransmit, hence minimizing utilization. [Source](https://discord.com/channels/826570251612323860/1330643963501351004/1351279141630119996) @@ -739,12 +739,10 @@ As new MeshCore community projects become available on a weekly basis, we have s Both the Windows and Mac versions of the client app are fully unlocked and are free to use. -### 5.16. Q: Are there any resources that compare MeshCore to other LoRa systems? -**A:** Here is a list of MeshCore comparison resources: - -- The Comms Channel on YouTube: -- MeshCore Advantages by MCarper: -- MeshCore vs Meshtastic by austinmesh.org: +### 5.16. Q: Where can I learn more about MeshCore? +**A:** Start with the [MeshCore documentation](https://docs.meshcore.io) and +[QuickStart playlist](https://www.youtube.com/watch?v=iaFltojJrAc&list=PLshzThxhw4O4WU_iZo3NmNZOv6KMrUuF9). +For this fork, see [feature switches by role](role_feature_switches.md). --- diff --git a/docs/solarxiao_power.md b/docs/solarxiao_power.md index 3ab6d3b21..0543e4a34 100644 --- a/docs/solarxiao_power.md +++ b/docs/solarxiao_power.md @@ -1,3 +1,7 @@ + + + + # SolarXiao carrier profiles, MPPT and battery controls SolarXiao Repeater and QSPI OTA Room Server firmware support the CN3791 Base, diff --git a/docs/test-results/bootloader_version_stock_audit.md b/docs/test-results/bootloader_version_stock_audit.md index 5d52d4f6b..b9a1b516e 100644 --- a/docs/test-results/bootloader_version_stock_audit.md +++ b/docs/test-results/bootloader_version_stock_audit.md @@ -22,7 +22,7 @@ in this audit. One format gap was fixed: Seeed's T1000-E **LoRaWAN development-board** bootloader declares `UF2 Bootloader 1.00`. The reader now accepts that two-component version and returns `1.00` unchanged. It does not invent `1.00.0`. That development image -is separate from Seeed's Meshtastic/MeshCore recovery image, which reports +is separate from Seeed's T1000-E recovery image, which reports `0.9.1-5-g488711a` and already worked. All other tested images already expose a readable UF2 bootloader record. @@ -48,7 +48,7 @@ layout and expected strings are pinned in | Seeed XIAO nRF52840 Plus and Sense Plus | `0.9.2-29-g6a9a6a3` | | Seeed Wio Tracker 1110 SDK/conversion bootloader | `0.6.2-26-g949425a-dirty` | | Seeed Wio Tracker L1 vendor recovery ZIP | `0.7.0-22-g277a0c8` | -| Seeed T1000-E Meshtastic/MeshCore recovery ZIP | `0.9.1-5-g488711a` | +| Seeed T1000-E recovery ZIP | `0.9.1-5-g488711a` | | Seeed T1000-E LoRaWAN development-board HEX | `1.00` (newly supported) | | Seeed MeshTracker X1 recovery ZIP | `0.10.0-18-gb93789f` | | MTools Tec MeshTiny vendor bootloader UF2 | `0.4.5` | diff --git a/docs/test-results/companion_qualification_f807_followup_2026-10-19.md b/docs/test-results/companion_qualification_f807_followup_2026-10-19.md index d2588b1a0..095606209 100644 --- a/docs/test-results/companion_qualification_f807_followup_2026-10-19.md +++ b/docs/test-results/companion_qualification_f807_followup_2026-10-19.md @@ -28,7 +28,7 @@ The official upstream `companion-v1.18.0` T1000 BLE reference had matching ZIP/U Installed S140 7.3.0 readback CRC `733639BD` matched the package bytes, also identical to Seeed's recovery package SoftDevice. That checks the covered image, not RF operation. C2 installation preceded a historical secured diagnostic link that never completed full BLE qualification. -A separate DFU defect was corrected in bootloader `c0c421184a3cf1ca66b40b055101f20b8c587e26`: active-high P0.06 had been configured pull-up/active-low, with RESET/P0.18 treated as another button. The correction adds qualified three-second hold/USB-first recovery while retaining explicit-request latency and other boards' two-button behavior. Eleven actual-function host tests, 13 DFU-entry checks and T1000/T096/T114 cross-builds passed. See [candidate PR #1](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/pull/1) and [upstream polarity evidence](https://github.com/meshtastic/Adafruit_nRF52_Bootloader_OTAFIX/commit/0f415c3ee26816ad52a7587b3d6cdd30547987ec). +A separate DFU defect was corrected in bootloader `c0c421184a3cf1ca66b40b055101f20b8c587e26`: active-high P0.06 had been configured pull-up/active-low, with RESET/P0.18 treated as another button. The correction adds qualified three-second hold/USB-first recovery while retaining explicit-request latency and other boards' two-button behavior. Eleven actual-function host tests, 13 DFU-entry checks and T1000/T096/T114 cross-builds passed. See [candidate PR #1](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/pull/1) and [button-polarity evidence](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/commit/0f415c3ee26816ad52a7587b3d6cdd30547987ec). The reviewed combined bootloader transfer returned 0, but its installer stopped when the MSC disk was unavailable. That failed workflow remains failed. Later exact-device public INFO verified model `Seeed T1000-E for Meshtastic`, Board-ID `nRF52840-T1000-E-v1` and version `TEST_0x02040C01`. Package CRC `6BA4D206` was validated offline; installed CRC was not exposed. Combined ZIP SHA-256: `8db09e6b7f253b1e54aae754220f0d1053d369c07bff299a26b384233de6e234`. diff --git a/docs/test-results/companion_qualification_f807_followup_2026-10-19.safe.json b/docs/test-results/companion_qualification_f807_followup_2026-10-19.safe.json index bbffce252..9fcf38645 100644 --- a/docs/test-results/companion_qualification_f807_followup_2026-10-19.safe.json +++ b/docs/test-results/companion_qualification_f807_followup_2026-10-19.safe.json @@ -246,7 +246,7 @@ "status": "passed" }, "source_commit": "c0c421184a3cf1ca66b40b055101f20b8c587e26", - "upstream_correction_url": "https://github.com/meshtastic/Adafruit_nRF52_Bootloader_OTAFIX/commit/0f415c3ee26816ad52a7587b3d6cdd30547987ec" + "upstream_correction_url": "https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/commit/0f415c3ee26816ad52a7587b3d6cdd30547987ec" }, "campaign_local_date": "2026-10-19", "carried_forward_failures": [ diff --git a/docs/test-results/nano_g2_regulator_2026-10-18.json b/docs/test-results/nano_g2_regulator_2026-10-18.json index b1a7cfb3f..50db55d23 100644 --- a/docs/test-results/nano_g2_regulator_2026-10-18.json +++ b/docs/test-results/nano_g2_regulator_2026-10-18.json @@ -148,7 +148,6 @@ "repositories": [ "meshcore-dev/MeshCore", "mikecarper/MeshCore", - "meshtastic/firmware", "manufacturer-owned public repositories" ], "reason_for_prior_exclusion": "Upstream PR1246 left Nano unchanged pending confirmation or testing of its external LC circuit.", @@ -159,11 +158,8 @@ "other_reviewed_references": [ "https://github.com/meshcore-dev/MeshCore/pull/1201", "https://github.com/meshcore-dev/MeshCore/pull/298", - "https://github.com/meshcore-dev/MeshCore/pull/2426", - "https://github.com/meshtastic/firmware/pull/2660", - "https://github.com/meshtastic/firmware/issues/5839" - ], - "filesystem_issue_caveat": "Meshtastic5839 includes a Nano node-database/filesystem corruption report but provides no regulator-mode connection." + "https://github.com/meshcore-dev/MeshCore/pull/2426" + ] }, "circuit_evidence": { "url": "https://wiki.uniteng.com/meshtastic/nano_g2_ultra/nanog2ultra_19_jun_2023.pdf", diff --git a/docs/test-results/solarxiao_advanced_2026-10-21.md b/docs/test-results/solarxiao_advanced_2026-10-21.md index 20e8712bf..cb60dd8f5 100644 --- a/docs/test-results/solarxiao_advanced_2026-10-21.md +++ b/docs/test-results/solarxiao_advanced_2026-10-21.md @@ -1,3 +1,7 @@ + + + + # SolarXiao Advanced carrier profile, 2026-10-21 The older SolarXiao 2W Advanced already supplies battery voltage and processor diff --git a/docs/test-results/solarxiao_auto_2026-10-21.md b/docs/test-results/solarxiao_auto_2026-10-21.md index 8a965af99..42dcf6002 100644 --- a/docs/test-results/solarxiao_auto_2026-10-21.md +++ b/docs/test-results/solarxiao_auto_2026-10-21.md @@ -1,3 +1,7 @@ + + + + # SolarXiao automatic carrier detection and Base support, 2026-10-21 SolarXiao repeater firmware now defaults to automatic carrier detection when diff --git a/docs/test-results/solarxiao_basic_2026-10-21.md b/docs/test-results/solarxiao_basic_2026-10-21.md index 74d4ad15a..989839bc6 100644 --- a/docs/test-results/solarxiao_basic_2026-10-21.md +++ b/docs/test-results/solarxiao_basic_2026-10-21.md @@ -1,3 +1,7 @@ + + + + # SolarXiao three power profiles, 2026-10-21 Base and Advanced share the same onboard power telemetry: XIAO battery voltage diff --git a/docs/test-results/solarxiao_room_2026-10-21.md b/docs/test-results/solarxiao_room_2026-10-21.md index 844f9f018..f93b3c97c 100644 --- a/docs/test-results/solarxiao_room_2026-10-21.md +++ b/docs/test-results/solarxiao_room_2026-10-21.md @@ -1,3 +1,7 @@ + + + + # SolarXiao Room Server power profiles and history, 2026-10-21 The canonical SolarXiao QSPI OTA Room Server builds now initialize and service diff --git a/docs/test-results/usb_serial_backpressure.md b/docs/test-results/usb_serial_backpressure.md index a49762239..d3ef0ab57 100644 --- a/docs/test-results/usb_serial_backpressure.md +++ b/docs/test-results/usb_serial_backpressure.md @@ -211,12 +211,9 @@ Upstream reports checked for this transition: | Report | Documented behavior | Relevance and mitigation | | --- | --- | --- | | [ESP-IDF #9826](https://github.com/espressif/esp-idf/issues/9826) | S3 retains TinyUSB PHY ownership across software/watchdog restart, preventing USB Serial/JTAG operation. | Pinned HWCDC initialization restores the official S3 hardware PHY route and detaches the former OTG connection first. Initialization fixtures seed retained RTC state; the controlled physical G2 OTA transition below also passed. | -| [Meshtastic #10955](https://github.com/meshtastic/firmware/issues/10955) | Tracker V2 watchdog reboot after a serial reader closes; raw log writes block while the USB bus remains alive. Reproduced on Arduino 2.x and 3.x. | Applies to the same hardware/backend combination. Our bounded writer and diagnostic admission prevent waiting for a host to drain output; synthetic negative controls exercise unsafe raw writes. | -| [Meshtastic #10975](https://github.com/meshtastic/firmware/issues/10975) | Tracker V2 binary synchronization corrupted by interleaved diagnostics and abandoned short-write suffixes. | Companion/KISS tests retain suffixes across short writes; KISS disables diagnostics before board initialization. | | [Arduino-ESP32 #12782](https://github.com/espressif/arduino-esp32/issues/12782) | Open report of HWCDC failing to reconnect after about two minutes idle on C3/C6/C61/S3, latest master / IDF 5.5.4. | Reported SDK differs from our pinned Arduino 2.0.17 / IDF 4.4. Synthetic tests cover idle lease expiry and reply admission, not physical enumeration on that SDK. | | [ESP-IDF #18996](https://github.com/espressif/esp-idf/issues/18996) | Open report of S3 USB Serial/JTAG failing after repeated software resets from macOS, on IDF 6.0.2; includes XIAO S3. | Not qualified by Linux port reopen tests and not reproduced on our pinned SDK. CPU reset and host-port reopen are distinct cases. | | [ESP-IDF #13287](https://github.com/espressif/esp-idf/issues/13287) | S3 may remain in ROM after upload when download mode was entered with physical BOOT/RESET. | Our older esptool does not offer the newer `watchdog-reset` option. Press RESET after a manual BOOT-mode upload if the application does not start. | -| [Meshtastic #4206](https://github.com/meshtastic/firmware/issues/4206) | Station G2 native USB fails to resume around light sleep. | Keep USB active while a host is present. Espressif documents USB Serial/JTAG sleep limitations; a port disappearing during sleep does not establish a firmware crash. | Espressif's [USB Serial/JTAG guide](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-guides/usb-serial-jtag-console.html) describes the fixed CDC/JTAG peripheral and its light/deep sleep limitations. diff --git a/licenses/ESPP-Meshtext-MIT.txt b/licenses/ESPP-Meshtext-MIT.txt index 5deca291a..74b4292ca 100644 --- a/licenses/ESPP-Meshtext-MIT.txt +++ b/licenses/ESPP-Meshtext-MIT.txt @@ -6,9 +6,9 @@ MIT-licensed esp-cpp/espp radio messaging component identified below. They do not incorporate code from GPL firmware or generated protobuf definitions. Pinned upstream source: -https://github.com/esp-cpp/espp/tree/4b32f78e007f3750b2ea82a96c3c5896c2770a97/components/meshtastic -Files: include/meshtastic_types.hpp, src/meshtastic_protocol.cpp, -src/meshtastic_crypto.cpp, src/meshtastic_protobuf.cpp. +https://github.com/esp-cpp/espp/tree/4b32f78e007f3750b2ea82a96c3c5896c2770a97 +The adapted radio messaging helpers cover packet types, framing, encryption +and protobuf wire encoding at this pinned repository revision. License: https://github.com/esp-cpp/espp/blob/4b32f78e007f3750b2ea82a96c3c5896c2770a97/LICENSE diff --git a/src/helpers/ui/OLEDDisplayProbe.h b/src/helpers/ui/OLEDDisplayProbe.h index 20e95db86..c125a3700 100644 --- a/src/helpers/ui/OLEDDisplayProbe.h +++ b/src/helpers/ui/OLEDDisplayProbe.h @@ -18,9 +18,8 @@ constexpr OLEDControllerMode defaultOLEDControllerMode() { #endif } -// Status-byte detection is used by ss_oled and Meshtastic. Only 0x08 is +// Status-byte detection is also used by ss_oled. Only 0x08 is // evidence of SH1106: some RAK1921 SSD1306 panels return the ambiguous 0x00. -// See https://github.com/meshtastic/firmware/pull/11265. template OLEDController detectOLEDController(WireBus& wire, uint8_t address) { wire.beginTransmission(address); diff --git a/src/helpers/ui/RC32DisplayProbe.h b/src/helpers/ui/RC32DisplayProbe.h index d5c9ddb97..c4b2f985c 100644 --- a/src/helpers/ui/RC32DisplayProbe.h +++ b/src/helpers/ui/RC32DisplayProbe.h @@ -5,7 +5,7 @@ namespace mesh { namespace ui { namespace rc32 { // Heltec's optional T108 uses NV3001B on the shared, bidirectional SDA pin. -// Read commands/ID: meshtastic/firmware src/mesh/NodeDB.cpp, TFT_NV3001B_DETECT. +// Probe command 0x04 or individual bytes 0xDA..0xDC for controller ID 0x300101. // Pins/power: HelTecAutomation/RadioCore_Library examples/NV3001B_Display. // Probe before claiming the SPI peripheral; no independent MISO is connected. inline uint32_t readIdCommand(uint8_t command, uint8_t bits, uint8_t dummy, uint8_t bias) { diff --git a/test/test_companion_serial_hil.py b/test/test_companion_serial_hil.py new file mode 100644 index 000000000..4d286a1dd --- /dev/null +++ b/test/test_companion_serial_hil.py @@ -0,0 +1,270 @@ +"""Exercise the shared local USB transport without opening a real device.""" + +from collections import deque +from pathlib import Path +import os +import struct +import subprocess +import sys +from types import ModuleType, SimpleNamespace +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = ROOT / "tools/hil" +sys.path.insert(0, str(TOOLS)) +import companion_serial as HIL + + +def device_frame(payload): + return b">" + struct.pack(" Binary mode\r\n" + self.fail_open = False + self.short_write = False + + def _line(name): + def get(self): + return getattr(self, "_" + name) + + def set(self, value): + setattr(self, "_" + name, value) + self.events.append((name, value)) + return property(get, set) + + dtr = _line("dtr") + rts = _line("rts") + dsrdtr = _line("dsrdtr") + + @property + def in_waiting(self): + return len(self.chunks[0]) if self.chunks else 0 + + def open(self): + self.events.append(("open", self.dtr, self.rts, self.dsrdtr)) + if self.fail_open: + raise OSError("fixture open failure") + self.is_open = True + + def close(self): + self.events.append(("close",)) + self.is_open = False + + def write(self, data): + self.events.append(("write", data)) + if data == b"+++MESHCORE-TERM-STOP\r": + if self.negotiation: + self.chunks.append(self.negotiation) + elif data.startswith(b"<") and self.responses: + # Fragmentation and a push frame exercise the actual shared reader. + reply = device_frame(b"\x80notice") + device_frame(self.responses.popleft()) + self.chunks.extend((reply[:2], reply[2:7], reply[7:])) + return len(data) - int(self.short_write) + + def flush(self): + self.events.append(("flush",)) + + def read(self, count): + if not self.chunks: + return b"" + chunk = self.chunks.popleft() + if len(chunk) > count: + self.chunks.appendleft(chunk[count:]) + return chunk[:count] + + +class CompanionSerialTests(unittest.TestCase): + def connect(self, port=None): + port = port or FakeSerial() + module = ModuleType("serial") + module.Serial = mock.Mock(return_value=port) + with mock.patch.dict(sys.modules, {"serial": module}): + node = HIL.Companion("/dev/fake-local") + self.assertEqual(module.Serial.call_args.kwargs, + {"port": None, "baudrate": 115200, "timeout": 0.05, + "write_timeout": 2, "exclusive": True}) + return node, port + + def test_clean_import_has_no_serial_dependency_or_port_access(self): + script = ( + "import sys; sys.path.insert(0, " + repr(str(TOOLS)) + "); " + "import companion_serial, companion_raw_radio2_sweep, mota_usb_seeder; " + "assert 'serial' not in sys.modules" + ) + result = subprocess.run([sys.executable, "-S", "-B", "-c", script], + cwd="/tmp", capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_safe_configured_open_and_close_do_not_pulse_reset_lines(self): + node, port = self.connect() + self.assertEqual(port.events[:2], [("dtr", False), ("rts", False)]) + opened = next(event for event in port.events if event[0] == "open") + self.assertEqual(opened[1:3], (False, False)) + if os.name == "posix": + self.assertTrue(opened[3]) + self.assertEqual(port.events[-3:], [("rts", False), ("dtr", False), ("dsrdtr", False)]) + self.assertFalse(port.dsrdtr) + self.assertFalse(any(event in (("dtr", True), ("rts", True)) for event in port.events)) + node.close() + self.assertFalse(port.is_open) + + def test_failed_open_closes_the_handle_and_restores_handshake(self): + port = FakeSerial() + port.fail_open = True + with self.assertRaisesRegex(OSError, "fixture open failure"): + self.connect(port) + self.assertIn(("close",), port.events) + self.assertFalse(port.dsrdtr) + self.assertFalse(port.is_open) + + def test_start_negotiates_then_validates_actual_fragmented_frames(self): + node, port = self.connect() + stale_reader = node.reader + stale_reader._buffer.extend(device_frame(b"\x01\x05")) + port.responses.extend((device_info(), self_info())) + with mock.patch.object(HIL.time, "sleep"): + result = node.start() + self.assertIsNot(node.reader, stale_reader) + self.assertEqual(result["protocol"], 14) + self.assertEqual(result["max_channels"], 3) + self.assertEqual(node.max_channels, 3) + self.assertEqual(result["primary"]["node_name"], "USB fixture") + self.assertEqual(result["model"], "Fixture") + self.assertEqual(result["firmware"], "1.2.3.4") + self.assertEqual(result["usb_binary_negotiation"], + {"explicit_stop_token": True, + "discarded_terminal_bytes": len(port.negotiation), "dtr": True}) + writes = [event[1] for event in port.events if event[0] == "write"] + self.assertEqual(writes, [b"+++MESHCORE-TERM-STOP\r", + HIL.encode_host_frame(bytes((22, 14))), + HIL.encode_host_frame(HIL.make_app_start_spec("Meshtext-Auto-HIL").payload)]) + stop = port.events.index(("write", writes[0])) + self.assertEqual(port.events[stop - 2:stop], [("rts", False), ("dtr", True)]) + + def test_start_rejects_logging_owner_before_any_framed_request(self): + node, port = self.connect() + port.negotiation = b"Error: set usb.logging off before Binary mode\r\n" + with mock.patch.object(HIL.time, "sleep"), self.assertRaisesRegex(HIL.Failure, "USB logging owns"): + node.start() + self.assertEqual([event[1] for event in port.events if event[0] == "write"], + [b"+++MESHCORE-TERM-STOP\r"]) + + def test_start_rejects_truncated_device_and_invalid_primary_information(self): + for replies, reason in (((b"\x0d",), "truncated Companion device info"), + ((device_info(), b"\x05"), "SELF_INFO length")): + with self.subTest(reason=reason): + node, port = self.connect() + port.responses.extend(replies) + with mock.patch.object(HIL.time, "sleep"), self.assertRaisesRegex(RuntimeError, reason): + node.start() + + def test_cli_retains_exact_retry_error_and_redacts_keys(self): + node, port = self.connect() + port.responses.append(b"\x1dError: radio busy or TX power rejected") + with self.assertRaisesRegex(HIL.Failure, + "^Companion CLI set tx rejected: Error: radio busy or TX power rejected$"): + node.cli("set tx 2") + key = "private-channel-key" + port.responses.append(("\x1dError: echoed " + key).encode()) + with self.assertRaises(HIL.Failure) as rejected: + node.cli("set mt.channel 1 mt-lab " + key) + self.assertNotIn(key, str(rejected.exception)) + self.assertIn("set mt.channel rejected: Error: echoed ", str(rejected.exception)) + secret = "a1" * 16 + with mock.patch.object(node, "request", side_effect=RuntimeError("diagnostic " + secret)): + with self.assertRaises(HIL.Failure) as failed: + node.cli("set mt.channel 1 mt-lab " + secret) + self.assertNotIn(secret, str(failed.exception)) + self.assertIn("", str(failed.exception)) + + def test_request_rejects_partial_write_device_error_and_wrong_response(self): + node, port = self.connect() + port.short_write = True + with self.assertRaisesRegex(HIL.Failure, "incomplete Companion write"): + node.request(b"\x16\x0e", (13,)) + port.short_write = False + for reply, reason in ((b"\x01\x09", "command rejected, error 09"), + (b"\x0a", "unexpected Companion response 10")): + with self.subTest(reason=reason): + port.responses.append(reply) + with self.assertRaisesRegex(HIL.Failure, reason): + node.request(b"\x16\x0e", (13,)) + + def test_request_retries_frame_timeout_with_a_bounded_deadline(self): + node, _ = self.connect() + with mock.patch.object(node.reader, "read_frame", side_effect=HIL.FrameTimeout("fixture")) as read, \ + mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.2, 5.1)): + with self.assertRaisesRegex(HIL.Failure, "response timeout for command 22"): + node.request(b"\x16\x0e", (13,)) + read.assert_called_once() + + def test_channel_name_uses_existing_request_and_minimum_size(self): + node, port = self.connect() + reply = bytearray(50) + reply[0] = 18 + reply[2:7] = b"Local" + port.responses.append(bytes(reply)) + self.assertEqual(node.channel_name(2), "Local") + self.assertIn(("write", HIL.encode_host_frame(bytes((31, 2)))), port.events) + port.responses.append(b"\x12") + with self.assertRaisesRegex(HIL.Failure, "truncated channel info"): + node.channel_name(2) + + def test_negotiation_drain_is_bounded_by_bytes_and_time(self): + port = FakeSerial() + port.negotiation = b"x" * 65537 + with mock.patch.object(HIL.time, "sleep"), \ + mock.patch.object(HIL.time, "monotonic", return_value=0), \ + self.assertRaisesRegex(HIL.Failure, "bounded negotiation drain"): + HIL.enter_binary_mode(port) + port = FakeSerial() + port.negotiation = b"x" * 20000 + with mock.patch.object(HIL.time, "sleep"), \ + mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.5, 1.1)): + result = HIL.enter_binary_mode(port) + self.assertEqual(result["discarded_terminal_bytes"], 8192) + self.assertTrue(port.chunks) + + def test_port_ownership_guard_preserves_fail_closed_statuses(self): + with mock.patch.object(HIL.sys, "platform", "linux"), \ + mock.patch.object(HIL.shutil, "which", return_value="/bin/fuser"), \ + mock.patch.object(HIL.subprocess, "run") as inspect: + inspect.return_value = SimpleNamespace(returncode=1) + HIL.require_idle_port("/dev/fake-local") + inspect.assert_called_with(["/bin/fuser", os.path.realpath("/dev/fake-local")], + capture_output=True, timeout=3) + for status in (0, 2): + inspect.return_value = SimpleNamespace(returncode=status) + with self.assertRaisesRegex(HIL.Failure, "busy or ownership inspection failed"): + HIL.require_idle_port("/dev/fake-local") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_auto_radio2_hil.py b/test/test_meshtext_auto_radio2_hil.py deleted file mode 100644 index 485e10ef8..000000000 --- a/test/test_meshtext_auto_radio2_hil.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Host-only checks of the preset sweep; never opens a hardware port.""" -import contextlib -import io -import pathlib -import random -import struct -import sys -from types import SimpleNamespace -import unittest -from unittest.mock import Mock, patch - -sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "tools" / "hil")) -import meshtext_auto_radio2 as hil - - -class SweepPlanTests(unittest.TestCase): - def test_meshtext_help_exposes_stock_peer_port_without_opening_hardware(self): - output = io.StringIO() - with patch.object(sys, "argv", ["meshtext_auto_radio2.py", "--help"]), \ - patch.object(hil, "Companion") as companion, \ - patch.object(hil, "StockPeer") as stock, \ - contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as exit_result: - hil.main() - self.assertEqual(exit_result.exception.code, 0) - self.assertIn("Meshtext Companion / stock-peer", output.getvalue()) - self.assertIn("--stock-port", output.getvalue()) - self.assertNotIn("meshtastic", output.getvalue().lower()) - companion.assert_not_called() - stock.assert_not_called() - - def test_us_centers_and_native_stable_presets(self): - expected = { - "LongFast": 906.875, "ShortTurbo": 926.75, - "ShortFast": 918.875, "ShortSlow": 920.625, - "MediumFast": 913.125, "MediumSlow": 914.875, - "LongTurbo": 908.75, "LongMod": 902.6875, - "LongSlow": 905.3125, "MediumTurbo": 925.25, - } - self.assertEqual(sum(p.stock_enum is not None for p in hil.PRESETS), 9) - for preset in hil.PRESETS: - self.assertEqual(preset.frequency, expected[preset.name]) - self.assertEqual(preset.describe()["preamble"], 16) - - def test_alias_selection_and_rejections(self): - self.assertEqual(hil.choose_presets("long_moderate")[0].name, "LongMod") - for value in ("", "longfast,longfast", "not-a-preset"): - with self.assertRaises(hil.TestFailure): - hil.choose_presets(value) - - def test_auto_profile_readback(self): - profile = hil.parse_profile("> 906.875,250.000,11,5,rxtx,16; auto-owned") - self.assertEqual(profile, (906.875, 250, 11, 5, "rxtx", 16)) - with self.assertRaises(hil.TestFailure): - hil.parse_profile("> off") - - def test_queue_envelopes(self): - body = bytes((1, 0, 0)) + struct.pack(" off", "off", "TempRadio inactive"): - self.assertTrue(hil.temporary_off(value)) - for value in ("> 1 min left", "TempRadio active", "TempRadio inactive; scheduled"): - self.assertFalse(hil.temporary_off(value)) - - -class SweepPolicyTests(unittest.TestCase): - def test_traffic_counts_preserve_the_configured_threshold(self): - case = {"v4_to_v3": [{"received": True}] * 3, - "v3_to_v4": [{"received": True}, {"received": False}, {"received": True}]} - self.assertEqual(hil.assess_traffic(case, 3), [ - {"direction": "v3_to_v4", "received": 2, "attempted": 3, "required": 3}]) - self.assertEqual(case["traffic_counts"]["v4_to_v3"], {"received": 3, "attempted": 3}) - self.assertFalse(hil.assess_traffic(case, 2)) - - def test_seeded_gap_jitter_is_reproducible_and_zero_is_unchanged(self): - gaps = [] - with patch.object(hil.time, "sleep") as sleep: - for _ in range(2): - rng = random.Random(0x12345678) - packets = [{} for _ in range(4)] - for packet in packets: - hil.pause_after_packet(packet, 1, 0.5, rng) - gaps.append([packet["gap_after_seconds"] for packet in packets]) - self.assertEqual(gaps[0], gaps[1]) - self.assertTrue(all(1 <= gap <= 1.5 for gap in gaps[0])) - rng = Mock() - packet = {} - hil.pause_after_packet(packet, 1, 0, rng) - rng.uniform.assert_not_called() - sleep.assert_called_with(1) - self.assertEqual(packet["gap_after_seconds"], 1) - - def run_fake(self, *, continue_on_loss=False, lose_first=True, reboot=False, - reboot_loss=False, auto_clear=False): - presets = [hil.PRESETS[1], hil.PRESETS[0]] - state = SimpleNamespace(alias="", rebooted=False, configured=[], commands=[]) - - class FakeCompanion: - max_channels = 2 - - def __init__(self, port): - self.port = SimpleNamespace(write=self.write, flush=lambda: None) - - def write(self, frame): - state.rebooted = True - return len(frame) - - def start(self): - return {"primary": {"public_key_prefix": "unchanged"}} - - def close(self): - pass - - def channel_name(self, slot): - return "normal" - - def cli(self, command): - state.commands.append(command) - if command.startswith("set mt.channel "): - state.alias = command.split()[3] - elif command.startswith("delete mt.channel "): - state.alias = "" - if command in ("get tempradio", "get tempradio2"): - return "> off" - if command == "get radio": - return "unchanged-primary" - if command == "get mt.status": - return "> Meshtext; active; TX=0 RX=0 fail=0" if state.alias else "> Meshtext; no Meshtext channels; TX=0 RX=0 fail=0" - if command == "get radio2": - return "auto-owned" if state.alias else "> off (automatic allowed)" - return "> diagnostic" - - def mt_node(self): - return 0x12345678 - - def wait_auto(self, preset): - return {"radio2": "auto-owned", "status": "active"} - - def send_text(self, slot, token): - pass - - def wait_text(self, slot, from_node, token, timeout): - lost = ((lose_first and state.alias == presets[0].alias and not state.rebooted) - or (reboot_loss and state.rebooted)) - return {"received": not lost} - - class FakeStock: - node = 0x87654321 - - def __init__(self, port): - pass - - def configure(self, preset, key, tx, hops): - state.configured.append(preset.name) - return {"readback": {"use_preset": True, "channel_num": 0, "bandwidth": preset.bandwidth, - "spread_factor": preset.sf, "coding_rate": preset.cr, - "tx_power": tx, "hop_limit": hops}} - - def packets_since(self, timestamp, sender): - return [] - - def wait_packet(self, sender, predicate, since, timeout): - return {"id": 1} - - def send_text(self, token, hops): - return SimpleNamespace(id=1) - - def close(self): - pass - - args = SimpleNamespace(key_bits=128, region="auto", tx_power=-9, hops=7, - minimum_received=3, messages_per_direction=3, - continue_on_loss=continue_on_loss, gap_seconds=1, gap_jitter_seconds=0.5, - companion_port="fake-v4", stock_port="fake-v3", channel_slot=1, - quiet_seconds=1, timeout=15, nodeinfo=False, reboot=reboot, - auto_clear=auto_clear, output=pathlib.Path("unused-mocked-report.json")) - with patch.object(hil, "Companion", FakeCompanion), patch.object(hil, "StockPeer", FakeStock), \ - patch.object(hil.time, "sleep"), patch.object(hil, "write_report") as writer, \ - patch.object(hil.importlib.metadata, "version", return_value="mock"), \ - patch.object(hil.secrets, "token_hex", return_value="12345678"), \ - contextlib.redirect_stdout(io.StringIO()): - report = hil.run(args, presets) - writer.assert_called_once_with(args.output, report) - return report, state - - def test_default_loss_policy_is_failfast_with_diagnostics(self): - report, state = self.run_fake() - self.assertEqual(state.configured, ["ShortTurbo"]) - self.assertEqual(report["result"], "FAIL") - self.assertEqual(report["failed_cases"], ["ShortTurbo"]) - case = report["cases"][0] - self.assertEqual(case["traffic_counts"]["v3_to_v4"]["received"], 0) - for name in ("status_after_traffic", "radio2_after_traffic", - "radio2_status_after_traffic", "radio2_scan_after_traffic"): - self.assertIn(name, case) - - def test_continue_on_loss_collects_both_cases_but_still_fails(self): - report, state = self.run_fake(continue_on_loss=True) - self.assertEqual(state.configured, ["ShortTurbo", "LongFast"]) - self.assertEqual([case["result"] for case in report["cases"]], ["FAIL", "PASS"]) - self.assertEqual(report["result"], "FAIL") - self.assertIn("ShortTurbo", report["failure"]["message"]) - self.assertEqual(report["gap_jitter_seed"], 0x12345678) - for case in report["cases"]: - for direction in ("v4_to_v3", "v3_to_v4"): - self.assertTrue(all(1 <= packet["gap_after_seconds"] <= 1.5 for packet in case[direction])) - - def test_continued_loss_does_not_skip_reboot_and_reboot_loss_is_strict(self): - report, state = self.run_fake(continue_on_loss=True, reboot=True, reboot_loss=True) - self.assertTrue(state.rebooted) - self.assertEqual(report["reboot_persistence"]["result"], "FAIL") - self.assertEqual(report["result"], "FAIL") - self.assertIn("after reboot in v3_to_v4", report["failure"]["message"]) - - def test_successful_reboot_and_auto_clear_leave_final_profile_readded(self): - report, state = self.run_fake(continue_on_loss=True, lose_first=False, reboot=True, auto_clear=True) - self.assertEqual(report["result"], "PASS") - self.assertEqual(report["reboot_persistence"]["result"], "PASS") - self.assertEqual(report["auto_clear"]["result"], "PASS") - self.assertIn("no Meshtext channels", report["auto_clear"]["removed"]["mt_status"]) - self.assertEqual(state.alias, "mt-longfast") - self.assertEqual(report["failed_cases"], []) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_meshtext_auto_radio2_hil_sdk.py b/test/test_meshtext_auto_radio2_hil_sdk.py deleted file mode 100644 index 4c627330c..000000000 --- a/test/test_meshtext_auto_radio2_hil_sdk.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Host-only stock SDK/boot-readback contracts; no SDK installation or serial IO.""" - -import copy -from pathlib import Path -import sys -from types import ModuleType, SimpleNamespace -import unittest -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "hil")) -import meshtext_auto_radio2 as hil - - -class MeshInterfaceError(Exception): - pass - - -class PresetEnum: - values = {p.stock_enum: index for index, p in enumerate(hil.PRESETS) if p.stock_enum} - - @classmethod - def keys(cls): - return cls.values.keys() - - @classmethod - def Value(cls, name): - return cls.values[name] - - -def fake_sdk(): - package = ModuleType("meshtastic") - package.__path__ = [] - protobuf = ModuleType("meshtastic.protobuf") - protobuf.__path__ = [] - channel = ModuleType("meshtastic.protobuf.channel_pb2") - channel.Channel = SimpleNamespace(PRIMARY=1) - config = ModuleType("meshtastic.protobuf.config_pb2") - config.Config = SimpleNamespace(DeviceConfig=SimpleNamespace(CLIENT_MUTE=1), - LoRaConfig=SimpleNamespace(US=1, ModemPreset=PresetEnum)) - interface = ModuleType("meshtastic.mesh_interface") - interface.MeshInterface = SimpleNamespace(MeshInterfaceError=MeshInterfaceError) - util = ModuleType("meshtastic.util") - util.to_node_num = int - package.protobuf, package.mesh_interface = protobuf, interface - protobuf.channel_pb2, protobuf.config_pb2 = channel, config - return {module.__name__: module for module in (package, protobuf, channel, config, interface, util)} - - -class Node: - def __init__(self): - self.localConfig = SimpleNamespace(device=SimpleNamespace(role=0), - lora=SimpleNamespace(modem_preset=0)) - self.channels = [SimpleNamespace(role=0, settings=SimpleNamespace( - name="before", psk=b"old", uplink_enabled=True, downlink_enabled=True))] - self.calls = [] - self.written_lora = None - self.nodeNum = 0x12345678 - - def ensureSessionKey(self): - self.calls.append("session") - - def beginSettingsTransaction(self): - self.calls.append("begin") - - def writeChannel(self, index): - self.calls.append(("channel", index)) - - def writeConfig(self, name): - self.calls.append(("config", name)) - if name == "lora": - self.written_lora = copy.deepcopy(self.localConfig.lora) - - def commitSettingsTransaction(self): - self.calls.append("commit") - - -class StockConfigurationTests(unittest.TestCase): - def configure(self, preset, *, normalized=False, handshake_misses=0, session_misses=0, - readback_cr=None): - node = Node() - session_reads = [] - - def session_node(node_num): - self.assertEqual(node_num, node.nodeNum) - session_reads.append(True) - return {} if len(session_reads) <= session_misses else {"adminSessionPassKey": b"mock-key"} - - iface = SimpleNamespace(localNode=node, myInfo=SimpleNamespace(my_node_num=0x12345678), - metadata=SimpleNamespace(firmware_version="mock-stable"), close=lambda: None, - _getOrCreateByNum=session_node) - stock = hil.StockPeer.__new__(hil.StockPeer) - stock.iface = iface - attempts = [] - - def connect(): - attempts.append(True) - if len(attempts) <= handshake_misses: - raise MeshInterfaceError("transient simulated boot handshake") - # The official stock boot helper derives BW/SF from the preset. - if normalized and preset.stock_enum: - node.localConfig.lora.bandwidth = preset.bandwidth - node.localConfig.lora.spread_factor = preset.sf - if readback_cr is not None: - node.localConfig.lora.coding_rate = readback_cr - stock.iface = iface - - stock.connect = connect - with patch.dict(sys.modules, fake_sdk()), patch.object(hil.time, "sleep"), \ - patch.object(hil.time, "monotonic", side_effect=range(1, 250)): - result = stock.configure(preset, bytes(range(16)), -9, 7) - return node, result, attempts - - def test_nine_native_presets_clear_bw_sf_and_keep_native_cr_with_hash_slot(self): - for preset in hil.PRESETS: - if not preset.stock_enum: - continue - with self.subTest(preset=preset.name): - node, result, _ = self.configure(preset) - lora = node.written_lora - self.assertTrue(lora.use_preset) - self.assertEqual(lora.modem_preset, PresetEnum.Value(preset.stock_enum)) - # Stock clamps a zero CR to 5 instead of selecting preset CR. - self.assertEqual((lora.bandwidth, lora.spread_factor, lora.coding_rate), (0, 0, preset.cr)) - self.assertEqual((lora.channel_num, lora.override_frequency, lora.frequency_offset), (0, 0, 0)) - self.assertEqual((lora.region, lora.tx_power, lora.hop_limit), (1, -9, 7)) - self.assertTrue(lora.tx_enabled and lora.ignore_mqtt) - self.assertFalse(lora.config_ok_to_mqtt) - self.assertEqual(node.calls, ["session", "begin", ("config", "device"), - ("config", "lora"), ("channel", 0), "commit"]) - self.assertEqual(node.channels[0].settings.name, preset.name) - self.assertEqual(node.channels[0].settings.psk, bytes(range(16))) - self.assertFalse(node.channels[0].settings.uplink_enabled) - self.assertFalse(node.channels[0].settings.downlink_enabled) - self.assertEqual(result["node"], "!12345678") - - def test_native_stock_boot_normalization_is_not_a_readback_failure(self): - for preset in hil.PRESETS: - if preset.stock_enum: - with self.subTest(preset=preset.name): - node, _, _ = self.configure(preset, normalized=True) - self.assertEqual(node.written_lora.bandwidth, 0) - self.assertEqual(node.written_lora.spread_factor, 0) - self.assertEqual(node.localConfig.lora.bandwidth, preset.bandwidth) - - def test_custom_mediumturbo_tuple_and_explicit_calculated_slot(self): - preset = next(p for p in hil.PRESETS if p.name == "MediumTurbo") - node, _, _ = self.configure(preset) - lora = node.written_lora - self.assertFalse(lora.use_preset) - self.assertEqual((lora.bandwidth, lora.spread_factor, lora.coding_rate), (500, 9, 5)) - self.assertEqual(lora.channel_num, 47) - self.assertEqual(node.channels[0].settings.name, "MediumTurbo") - - def test_transient_meshinterface_handshake_error_retries(self): - _, _, attempts = self.configure(hil.PRESETS[0], handshake_misses=1) - self.assertEqual(len(attempts), 2) - - def test_async_admin_session_is_ready_before_writes(self): - node, _, _ = self.configure(hil.PRESETS[0], session_misses=3) - self.assertEqual(node.calls[0:2], ["session", "begin"]) - - def test_missing_admin_session_aborts_without_writing(self): - with self.assertRaisesRegex(hil.TestFailure, "admin session setup timed out"): - self.configure(hil.PRESETS[0], session_misses=100) - - def test_zero_or_wrong_cr_is_not_accepted_as_native_readback(self): - for preset_name, observed_cr in (("LongFast", 0), ("LongSlow", 5)): - preset = next(p for p in hil.PRESETS if p.name == preset_name) - with self.subTest(preset=preset_name, cr=observed_cr): - with self.assertRaisesRegex(hil.TestFailure, "configuration readback did not match"): - self.configure(preset, readback_cr=observed_cr) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_meshtext_delivery_hil.py b/test/test_meshtext_delivery_hil.py deleted file mode 100644 index d6ccc7667..000000000 --- a/test/test_meshtext_delivery_hil.py +++ /dev/null @@ -1,672 +0,0 @@ -"""Pure tests of the Meshtext RF runner, never its physical radio interfaces. - -The simulated peer tests validate orchestration and truthful reporting only; -they do not replace real RF, production codec, or stock-firmware tests. -No PlatformIO, pyserial, stock SDK, or connected hardware is required. - -Real hardware uses a stock SDK supporting SerialInterface(timeout=20), tested -with 2.7.11. An older 2.7.3 environment rejected that constructor argument. -""" - -import base64 -from contextlib import ExitStack, redirect_stderr, redirect_stdout -import importlib.util -import io -import json -from pathlib import Path -import sys -import tempfile -from types import ModuleType, SimpleNamespace -import unittest -from unittest import mock - - -ROOT = Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location( - "meshtext_delivery_hil_under_test", ROOT / "tools/hil/meshtext_delivery.py") -HIL = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(HIL) - - -class FakeSerial: - def __init__(self, chunks=(), short_write=False): - self.chunks = list(chunks) - self.short_write = short_write - self.events = [] - - @property - def rts(self): - return False - - @rts.setter - def rts(self, value): - self.events.append(("rts", value)) - - @property - def dtr(self): - return True - - @dtr.setter - def dtr(self, value): - self.events.append(("dtr", value)) - - @property - def in_waiting(self): - return len(self.chunks[0]) if self.chunks else 0 - - def write(self, data): - self.events.append(("write", data)) - return len(data) - int(self.short_write) - - def flush(self): - self.events.append(("flush",)) - - def read(self, count): - self.events.append(("read", count)) - return self.chunks.pop(0) if self.chunks else b"" - - -class SimulatedRadios: - """Small state model for the runner's policies, not a codec/RF oracle.""" - - def __init__(self, *, missing_nodeinfo=False, duplicate_text=False, - heard_echo=True, primary_frequency=909.5, - stale_renamed_nodeinfo=False, drop_renamed_texts=0, - missing_renamed_nodeinfo=False, duplicate_renamed_nodeinfo=False): - self.missing_nodeinfo = missing_nodeinfo - self.duplicate_text = duplicate_text - self.heard_echo = heard_echo - self.stale_renamed_nodeinfo = stale_renamed_nodeinfo - self.drop_renamed_texts = drop_renamed_texts - self.missing_renamed_nodeinfo = missing_renamed_nodeinfo - self.duplicate_renamed_nodeinfo = duplicate_renamed_nodeinfo - self.primary_frequency = primary_frequency - self.events = [] - self.closed = [] - self.commands = [] - self.role = "CLIENT_MUTE" - self.name = "Original HIL" - self.radio2_writes = 0 - self.busy_reads = 0 - self.sequence = 0 - self.clock = 100.0 - owner = self - - class Companion: - def __init__(self, port): - owner.companion = self - self.max_channels = 3 - self.retry = self.heard = self.exhausted = self.tx = self.rx = 0 - self.count, self.hops = 2, 7 - self.announced = False - - def start(self): - return {"firmware": "SIMULATED-NOT-RF", "primary": {"node_name": owner.name}} - - def close(self): - owner.closed.append("companion") - - def channel_name(self, slot): - return "" - - def mt_node(self): - return 0x12345678 - - def cli(self, command): - owner.commands.append(command) - if command == "get radio": - return f"> {owner.primary_frequency},500,5,5" - if command == "get radio2": - return "> 909.5,500,5,5,rxtx,32" - if command == "get mt.status": - status = "active" - if owner.busy_reads: - owner.busy_reads -= 1 - status = "publication busy" - return (f"> Meshtext !12345678; {status}; manual radio2; " - f"RX={self.rx} TX={self.tx} fail=0") - if command == "get mt.retry": - return (f"> retry={self.retry} heard={self.heard} exhausted={self.exhausted} " - f"pending=0 count={self.count} advert=off") - if command.startswith("set flood.retry.count "): - self.count = int(command.split()[-1]) - if command.startswith("set mt.hops "): - self.hops = int(command.split()[-1]) - if command.startswith("set radio2 "): - owner.radio2_writes += 1 - if owner.radio2_writes > 1: - owner.busy_reads = 2 - if command.startswith("set name "): - name = command[len("set name "):] - if name != owner.name: - owner.name = name - self.announced = False - if command == "get name": - return "> " + owner.name - return "OK" - - def send_text(self, slot, token): - if not self.hops: - self.tx += 1 - elif owner.role == "CLIENT" and owner.heard_echo: - self.tx += 2 - self.retry += 1 - self.heard += 1 - else: - self.tx += 1 + self.count - self.retry += self.count - self.exhausted += 1 - renamed = owner.name != "Original HIL" - drop_text = renamed and owner.drop_renamed_texts > 0 - if drop_text: - owner.drop_renamed_texts -= 1 - else: - owner.append_packet(self.mt_node(), token.encode(), "TEXT_MESSAGE_APP") - if owner.duplicate_text: - owner.events.append(owner.events[-1]) - if not self.announced: - self.announced = True - self.tx += 1 - if not owner.missing_nodeinfo and not (renamed and owner.missing_renamed_nodeinfo): - wire_name = ("Original HIL" if owner.stale_renamed_nodeinfo - and owner.name != "Original HIL" else owner.name) - owner.append_packet(self.mt_node(), b"simulated-user", "NODEINFO_APP", wire_name) - if renamed and owner.duplicate_renamed_nodeinfo: - self.tx += 1 - owner.append_packet(self.mt_node(), b"extra-user", "NODEINFO_APP", wire_name) - - def wait_text(self, slot, sender, token, timeout): - self.rx += 1 - return {"received": True} - - class Stock: - node = 0x87654321 - - def __init__(self, port): - owner.stock = self - self.iface = SimpleNamespace(isConnected=True, - stream=SimpleNamespace(is_open=True)) - - def close(self): - owner.closed.append("stock") - - def configure_lab(self, key, role): - owner.role = role - return {"role": role, "rf": dict(HIL.RF)} - - def packets_since(self, timestamp, sender): - return [packet for when, packet in owner.events - if when >= timestamp and packet["from"] == sender] - - def wait_packet(self, sender, predicate, since, timeout): - return next((packet for packet in self.packets_since(since, sender) - if predicate(packet)), None) - - def send_text(self, token, hops): - return SimpleNamespace(id=123) - - self.classes = Companion, Stock - - def now(self): - self.clock += 0.001 - return self.clock - - def append_packet(self, sender, payload, port, user_name=None): - self.sequence += 1 - self.events.append((self.now(), {"id": self.sequence, "from": sender, - "simulated_long_name": user_name, - "decoded": {"payload": payload, "portnum": port}})) - - -class MeshtextDeliveryGuardrailTest(unittest.TestCase): - def invoke_main(self, arguments): - output, errors = io.StringIO(), io.StringIO() - with mock.patch.object(sys, "argv", ["meshtext_delivery.py"] + arguments), \ - redirect_stdout(output), redirect_stderr(errors): - try: - code = HIL.main() - except SystemExit as error: - code = error.code - return code, output.getvalue(), errors.getvalue() - - def test_plan_does_not_import_hardware_dependencies_or_inspect_ports(self): - with mock.patch.object(HIL, "dependencies", side_effect=AssertionError("dependency import")), \ - mock.patch.object(HIL, "require_idle_port", side_effect=AssertionError("port probe")): - code, output, _ = self.invoke_main(["--plan"]) - self.assertEqual(code, 0) - plan = json.loads(output) - self.assertEqual(plan["rf"]["frequency_mhz"], 909.5) - self.assertEqual(plan["rf"]["bandwidth_khz"], 500) - self.assertEqual((plan["rf"]["sf"], plan["rf"]["cr"]), (5, 5)) - self.assertEqual(plan["echo_retry_budget"], 15) - - def test_consent_is_required_before_run(self): - with mock.patch.object(HIL, "run", side_effect=AssertionError("radio run")): - code, _, error = self.invoke_main([]) - self.assertEqual(code, 2) - self.assertIn("--allow-radio-changes is required", error) - - def test_invalid_timing_and_selection_never_run_hardware(self): - for arguments in (["--timeout", "0"], ["--timeout", "30.01"], - ["--quiet-seconds", "0"], ["--quiet-seconds", "31"], - ["--echo-attempts", "0"], ["--echo-attempts", "6"], - ["--case", "not-a-case"], ["--channel-slot", "256"]): - with self.subTest(arguments=arguments), \ - mock.patch.object(HIL, "run", side_effect=AssertionError("radio run")): - code, _, _ = self.invoke_main(["--allow-radio-changes"] + arguments) - self.assertEqual(code, 2) - - def test_same_port_and_existing_report_are_rejected(self): - with tempfile.TemporaryDirectory(prefix="meshtext-hil-guard-") as directory: - report = Path(directory) / "report.json" - with mock.patch.object(HIL, "run", side_effect=AssertionError("radio run")): - common = ["--allow-radio-changes", "--companion-port", "SIMULATED-A", - "--stock-port", "SIMULATED-A", "--report", str(report)] - code, _, error = self.invoke_main(common) - self.assertEqual(code, 2) - self.assertIn("ports must be distinct", error) - report.write_text("preserve-me", encoding="utf-8") - common[4] = "SIMULATED-B" - code, _, error = self.invoke_main(common) - self.assertEqual(code, 2) - self.assertIn("fresh report", error) - self.assertEqual(report.read_text(encoding="utf-8"), "preserve-me") - - def test_secret_values_never_enter_cli_labels_or_rejection_reasons(self): - for size in (16, 32): - key = bytes(range(size)).hex() - command = "set mt.channel 1 mt-labtest " + key - self.assertEqual(HIL.cli_command_label(command), "set mt.channel") - reply = HIL.safe_cli_reply(command, "Error: echoed " + command) - self.assertNotIn(key, reply) - self.assertIn("", reply) - self.assertEqual(HIL.cli_command_label("normalradio"), "normalradio") - self.assertEqual(HIL.cli_command_label("set tempradio2 off"), "set tempradio2") - self.assertEqual(HIL.safe_cli_reply("normalradio", "Error: invalid preamble"), - "Error: invalid preamble") - - def test_counter_parsing_rejects_missing_fields(self): - self.assertEqual(HIL.retry_stats("> retry=2 heard=1 exhausted=0 pending=0 count=15 advert=off")["heard"], 1) - self.assertEqual(HIL.tx_stats("RX=3 TX=4 fail=0"), {"rx": 3, "tx": 4, "fail": 0}) - for parser, reply in ((HIL.retry_stats, "retry=2 pending=0"), - (HIL.tx_stats, "TX=4 fail=0")): - with self.subTest(reply=reply), self.assertRaises(HIL.Failure): - parser(reply) - - def test_idle_wait_has_a_real_deadline(self): - observed = {"delivery": {"pending": 1}} - with mock.patch.object(HIL, "snapshot", return_value=observed) as snapshot, \ - mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.2, 0.3)), \ - mock.patch.object(HIL.time, "sleep"): - with self.assertRaisesRegex(HIL.Failure, "did not become idle"): - HIL.wait_idle(object(), 0.25) - self.assertEqual(snapshot.call_count, 2) - - def test_publication_poll_is_read_only_and_time_bounded(self): - companion = SimpleNamespace(cli=mock.Mock(return_value="> publication busy; RX=0 TX=0 fail=0")) - with mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.2, 0.3)), \ - mock.patch.object(HIL.time, "sleep"), \ - self.assertRaisesRegex(HIL.Failure, "did not become active"): - HIL.wait_active(companion, 0.25) - self.assertEqual(companion.cli.call_args_list, [mock.call("get mt.status")] * 2) - - -class MeshtextDeliveryUsbModeTest(unittest.TestCase): - def test_explicit_terminal_stop_uses_safe_line_order_and_drains_plaintext(self): - port = FakeSerial((b"> +++MESHCORE-TERM-STOP\r\n", b"Binary mode\r\n")) - with mock.patch.object(HIL.time, "sleep") as sleep: - result = HIL.enter_binary_mode(port) - self.assertEqual(port.events[:4], [("rts", False), ("dtr", True), - ("write", b"+++MESHCORE-TERM-STOP\r"), ("flush",)]) - sleep.assert_called_once_with(0.3) - self.assertEqual(result["discarded_terminal_bytes"], 38) - self.assertNotIn("Binary mode", str(result)) - - def test_already_binary_requires_no_ascii_acknowledgement(self): - with mock.patch.object(HIL.time, "sleep"): - self.assertEqual(HIL.enter_binary_mode(FakeSerial())["discarded_terminal_bytes"], 0) - - def test_short_write_and_logging_owner_fail_explicitly(self): - for port, reason in ((FakeSerial(short_write=True), "incomplete"), - (FakeSerial((b"ERROR: set usb.logging off before Binary mode\r\n> ",)), "USB logging owns")): - with self.subTest(reason=reason), mock.patch.object(HIL.time, "sleep"), \ - self.assertRaisesRegex(HIL.Failure, reason): - HIL.enter_binary_mode(port) - - def test_terminal_drain_is_byte_bounded(self): - port = FakeSerial([b"x" * 4096] * 17) - with mock.patch.object(HIL.time, "sleep"), \ - mock.patch.object(HIL.time, "monotonic", return_value=0), \ - self.assertRaisesRegex(HIL.Failure, "bounded negotiation drain"): - HIL.enter_binary_mode(port) - - def test_terminal_drain_is_time_bounded_even_with_continuous_output(self): - port = FakeSerial([b"x"] * 20) - with mock.patch.object(HIL.time, "sleep"), \ - mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.5, 1.1)): - result = HIL.enter_binary_mode(port) - self.assertEqual(result["discarded_terminal_bytes"], 2) - self.assertEqual(len(port.chunks), 18) - - -class MeshtextDeliveryStockSettlingTest(unittest.TestCase): - class Peer: - def __init__(self, outcomes): - self.outcomes = iter(outcomes) - self.iface = None - self.attempts = 0 - self.closed = 0 - - def close(self): - self.closed += 1 - - def connect(self): - self.attempts += 1 - outcome = next(self.outcomes) - if isinstance(outcome, Exception): - raise outcome - self.iface = SimpleNamespace(localNode=outcome, close=self.close) - - def test_stale_readback_reconnects_then_accepts_committed_settings(self): - peer = self.Peer(["stale", "settled"]) - with mock.patch.object(HIL.time, "sleep") as sleep: - result = HIL.reconnect_stock_readback( - peer, lambda node: ["channel.private_key_match"] if node == "stale" else []) - self.assertEqual(result, {"attempts": 2, "retry_history": [ - {"attempt": 1, "mismatched_fields": ["channel.private_key_match"]}]}) - self.assertEqual(peer.closed, 1) - self.assertIsNotNone(peer.iface) - sleep.assert_called_once_with(2) - - def test_transient_connect_errors_are_retried_without_retaining_secret_text(self): - key = bytes(range(16)).hex() - peer = self.Peer([OSError("unrelated diagnostic " + key), "stale", "settled"]) - with mock.patch.object(HIL.time, "sleep") as sleep: - result = HIL.reconnect_stock_readback( - peer, lambda node: ["lora.override_frequency"] if node == "stale" else []) - self.assertEqual(result["attempts"], 3) - self.assertEqual(result["retry_history"][0]["mismatched_fields"], - ["connect/readback error: OSError"]) - self.assertNotIn(key, json.dumps(result)) - self.assertEqual(sleep.call_args_list, [mock.call(2), mock.call(2)]) - - def test_bad_settings_stop_after_three_attempts_with_only_field_names(self): - peer = self.Peer(["wrong"] * 3) - with mock.patch.object(HIL.time, "sleep") as sleep, \ - self.assertRaisesRegex(HIL.Failure, "three bounded reconnects.*lora.override_frequency"): - HIL.reconnect_stock_readback(peer, lambda node: ["lora.override_frequency"]) - self.assertEqual(peer.attempts, 3) - self.assertEqual(peer.closed, 3) - self.assertIsNone(peer.iface) - self.assertEqual(sleep.call_count, 2) - - def test_old_sdk_constructor_error_is_not_reported_as_rf_success(self): - peer = self.Peer([TypeError("SerialInterface got unexpected keyword argument timeout")] * 3) - with mock.patch.object(HIL.time, "sleep"), \ - self.assertRaisesRegex(HIL.Failure, "connect/readback error: TypeError"): - HIL.reconnect_stock_readback(peer, lambda node: []) - self.assertEqual(peer.attempts, 3) - - def test_quiet_requires_live_sdk_and_serial_indicators(self): - event = mock.Mock() - event.is_set.return_value = True - peer = SimpleNamespace(iface=SimpleNamespace(isConnected=event, - stream=SimpleNamespace(is_open=True))) - self.assertEqual(HIL.require_stock_connected(peer), - {"sdk_connected": True, "serial_open": True}) - event.is_set.return_value = False - with self.assertRaisesRegex(HIL.Failure, "disconnected"): - HIL.require_stock_connected(peer) - event.is_set.return_value = True - peer.iface.stream.is_open = False - with self.assertRaisesRegex(HIL.Failure, "disconnected"): - HIL.require_stock_connected(peer) - - def test_missing_health_indicator_cannot_pass_as_quiet(self): - with self.assertRaisesRegex(HIL.Failure, "no supported connection-health"): - HIL.require_stock_connected(SimpleNamespace(iface=object())) - with self.assertRaisesRegex(HIL.Failure, "disconnected"): - HIL.require_stock_connected(SimpleNamespace(iface=None)) - - def test_real_configuration_method_waits_for_deferred_reboot_before_reconnect(self): - # Execute the runner's real transaction/reconnect code with a tiny SDK - # adapter. Any accidentally opened physical port raises immediately. - events = [] - descriptor = SimpleNamespace(fields_by_name={}) - settings = SimpleNamespace(name="old", psk=b"old", uplink_enabled=True, - downlink_enabled=True) - node = SimpleNamespace(nodeNum=42, - channels=[SimpleNamespace(role=1, settings=settings)], - localConfig=SimpleNamespace(device=SimpleNamespace(DESCRIPTOR=descriptor), - position=SimpleNamespace(DESCRIPTOR=descriptor), lora=SimpleNamespace()), - moduleConfig=SimpleNamespace(telemetry=None), - ensureSessionKey=mock.Mock(), beginSettingsTransaction=mock.Mock(), - writeConfig=mock.Mock(), writeChannel=mock.Mock(), - commitSettingsTransaction=lambda: events.append(("commit",))) - - def interface(): - return SimpleNamespace(localNode=node, - _getOrCreateByNum=lambda number: {"adminSessionPassKey": b"session-ready"}, - close=lambda: events.append(("close",))) - - class StockAdapter: - def __init__(self, port): - self.iface = interface() - - def connect(self): - events.append(("connect",)) - self.iface = interface() - - def identity(self): - return {"node": "!0000002a"} - - def module(name, **attributes): - result = ModuleType(name) - result.__dict__.update(attributes) - return result - - device_config = SimpleNamespace(Role=SimpleNamespace(Value=lambda name: {"CLIENT_MUTE": 1}[name]), - RebroadcastMode=SimpleNamespace(Value=lambda name: {"ALL": 0}[name])) - config = SimpleNamespace(Config=SimpleNamespace(DeviceConfig=device_config, - LoRaConfig=SimpleNamespace(US=1))) - channel = SimpleNamespace(Channel=SimpleNamespace(PRIMARY=1, DISABLED=0)) - hardware_forbidden = mock.Mock(side_effect=AssertionError("physical serial access")) - modules = { - "serial": module("serial", Serial=hardware_forbidden), - "meshtext_auto_radio2": module("meshtext_auto_radio2", Companion=object, StockPeer=StockAdapter), - "esp32_companion_serial_stress": module("esp32_companion_serial_stress", - DeviceFrameReader=mock.Mock(), TransportCounters=mock.Mock()), - "serial_session": module("serial_session", open_configured_session=hardware_forbidden), - "meshtastic": module("meshtastic"), - "meshtastic.protobuf": module("meshtastic.protobuf", channel_pb2=channel, config_pb2=config), - "meshtastic.util": module("meshtastic.util", to_node_num=lambda value: value), - } - with mock.patch.dict(sys.modules, modules), \ - mock.patch.object(HIL.time, "sleep", side_effect=lambda seconds: events.append(("sleep", seconds))): - _, Stock = HIL.dependencies() - result = Stock("SIMULATED-ONLY").configure_lab(bytes(range(16)), "CLIENT_MUTE") - hardware_forbidden.assert_not_called() - committed = events.index(("commit",)) - self.assertEqual(events[committed + 1:committed + 4], - [("sleep", 20), ("close",), ("connect",)]) - self.assertTrue(all(event[1] in (0.75, 20) for event in events if event[0] == "sleep")) - self.assertEqual(result["readback"]["attempts"], 1) - - -class MeshtextDeliveryMockFlowTest(unittest.TestCase): - def run_simulation(self, radios, case="all", key_bits=128): - key = bytes(range(key_bits // 8)) - with tempfile.TemporaryDirectory(prefix="meshtext-hil-simulated-") as directory, ExitStack() as stack: - report = Path(directory) / "report.json" - args = SimpleNamespace(case=case, key_bits=key_bits, report=report, - companion_port="SIMULATED-DUT", stock_port="SIMULATED-STOCK", channel_slot=1, - quiet_seconds=0.01, timeout=1, echo_attempts=3) - stack.enter_context(mock.patch.object(HIL, "dependencies", return_value=radios.classes)) - stack.enter_context(mock.patch.object(HIL, "require_idle_port")) - stack.enter_context(mock.patch.object(HIL.importlib.metadata, "version", return_value="SIMULATED")) - stack.enter_context(mock.patch.object(HIL.secrets, "token_bytes", return_value=key)) - stack.enter_context(mock.patch.object(HIL.secrets, "token_hex", return_value="0123abcd")) - stack.enter_context(mock.patch.object(HIL.time, "sleep")) - stack.enter_context(mock.patch.object(HIL.time, "monotonic", side_effect=radios.now)) - stack.enter_context(mock.patch.object(HIL, "inspect_nodeinfo", - side_effect=lambda packet, node: {"id": "!12345678", - "long_name": packet["simulated_long_name"], - "payload_type": "SIMULATED User; no protobuf/RF claim"})) - stack.enter_context(redirect_stdout(io.StringIO())) - result = HIL.run(args) - saved = report.read_text(encoding="utf-8") - self.assertEqual(result, json.loads(saved)) - self.assertNotIn(key.hex(), saved) - self.assertNotIn(base64.b64encode(key).decode(), saved) - return result - - def test_all_cases_success_and_safe_setup_commands(self): - for bits in (128, 256): - with self.subTest(key_bits=bits): - radios = SimulatedRadios() - result = self.run_simulation(radios, key_bits=bits) - self.assertEqual(result["result"], "PASS", result.get("failure")) - self.assertEqual(len(result["cases"]), 9) - self.assertTrue(all(case["result"] == "PASS" for case in result["cases"])) - self.assertIn("normalradio", radios.commands) - self.assertNotIn("set tempradio off", radios.commands) - self.assertIn("set flood.retry.count 15", radios.commands) - self.assertEqual(sorted(radios.closed), ["companion", "stock"]) - self.assertFalse(result["settings_restored"]) - self.assertEqual(radios.name, "Original HIL") - - def test_quiet_only_never_sends_a_chat(self): - radios = SimulatedRadios() - result = self.run_simulation(radios, "quiet") - self.assertEqual(result["result"], "PASS") - self.assertEqual([case["name"] for case in result["cases"]], ["quiet"]) - self.assertEqual(radios.events, []) - self.assertEqual(radios.companion.tx, 0) - - def test_new_cases_work_as_a_subset_with_read_only_reactivation_and_name_restore(self): - radios = SimulatedRadios() - result = self.run_simulation(radios, "same-tuple,rename") - self.assertEqual(result["result"], "PASS", result.get("failure")) - self.assertEqual([case["name"] for case in result["cases"]], ["first-use", - "same-tuple-publication-recovery", "rename-rearms-first-chat-nodeinfo"]) - same_tuple, rename = result["cases"][-2:] - self.assertGreater(same_tuple["publication"]["polls"], 1) - self.assertEqual(same_tuple["traffic_delta"]["tx"], 0) - self.assertEqual(rename["traffic_delta"]["tx"], 2) - self.assertEqual(rename["nodeinfo"]["long_name"], rename["test_name"]) - self.assertTrue(rename["original_name_restored"]) - self.assertEqual(rename["restore_traffic_delta"]["tx"], 0) - self.assertEqual(radios.name, "Original HIL") - self.assertEqual(radios.companion.hops, 7) - - def test_rename_restores_original_name_even_when_wire_metadata_is_wrong(self): - radios = SimulatedRadios(stale_renamed_nodeinfo=True) - result = self.run_simulation(radios, "rename") - self.assertEqual(result["result"], "FAIL") - self.assertIn("old long name", result["failure"]["message"]) - rename = result["cases"][-1] - self.assertEqual(rename["result"], "FAIL") - self.assertTrue(rename["original_name_restored"]) - self.assertEqual(radios.name, "Original HIL") - self.assertEqual(rename["restore_traffic_delta"]["tx"], 0) - - def test_rename_records_first_chat_loss_and_accepts_its_one_nodeinfo(self): - radios = SimulatedRadios(drop_renamed_texts=1) - result = self.run_simulation(radios, "rename") - self.assertEqual(result["result"], "PASS", result.get("failure")) - rename = result["cases"][-1] - self.assertEqual(rename["attempted_texts"], 2) - self.assertEqual(rename["received_texts"], 1) - self.assertEqual([probe["received"] for probe in rename["probes"]], [False, True]) - self.assertIsNone(rename["probes"][0]["packet_id"]) - self.assertIsNotNone(rename["probes"][1]["packet_id"]) - self.assertEqual(rename["decoded_nodeinfo_events"], 1) - self.assertEqual(rename["packets"][0]["port"], "NODEINFO_APP") - self.assertEqual(rename["traffic_delta"]["tx"], 3) - self.assertTrue(rename["original_name_restored"]) - - def test_rename_all_three_chats_lost_is_still_a_failed_rf_test(self): - radios = SimulatedRadios(drop_renamed_texts=3) - result = self.run_simulation(radios, "rename") - self.assertEqual(result["result"], "FAIL") - self.assertIn("any of three", result["failure"]["message"]) - rename = result["cases"][-1] - self.assertEqual(rename["attempted_texts"], 3) - self.assertEqual(rename["received_texts"], 0) - self.assertEqual(rename["decoded_nodeinfo_events"], 1) - self.assertEqual(rename["traffic_delta"]["tx"], 4) - self.assertTrue(rename["original_name_restored"]) - self.assertTrue(all(not probe["received"] for probe in rename["probes"])) - - def test_rename_missing_nodeinfo_is_not_masked_by_extra_successful_chats(self): - radios = SimulatedRadios(missing_renamed_nodeinfo=True) - result = self.run_simulation(radios, "rename") - self.assertEqual(result["result"], "FAIL") - self.assertIn("exactly one observed", result["failure"]["message"]) - rename = result["cases"][-1] - self.assertEqual(rename["attempted_texts"], 3) - self.assertEqual(rename["received_texts"], 3) - self.assertEqual(rename["decoded_nodeinfo_events"], 0) - self.assertTrue(rename["original_name_restored"]) - - def test_rename_extra_nodeinfo_is_never_accepted_as_one_time_announcement(self): - radios = SimulatedRadios(duplicate_renamed_nodeinfo=True) - result = self.run_simulation(radios, "rename") - self.assertEqual(result["result"], "FAIL") - self.assertIn("exactly one observed", result["failure"]["message"]) - rename = result["cases"][-1] - self.assertEqual(rename["decoded_nodeinfo_events"], 2) - self.assertTrue(rename["original_name_restored"]) - - def test_missing_metadata_fails_and_saves_partial_report(self): - radios = SimulatedRadios(missing_nodeinfo=True) - result = self.run_simulation(radios, "first-use") - self.assertEqual(result["result"], "FAIL") - self.assertIn("automatic NodeInfo", result["failure"]["message"]) - self.assertEqual(result["cases"][-1]["result"], "FAIL") - self.assertIn("finished_utc", result) - self.assertEqual(sorted(radios.closed), ["companion", "stock"]) - - def test_duplicate_user_delivery_fails_exhaustion_case(self): - result = self.run_simulation(SimulatedRadios(duplicate_text=True), "exhaustion") - self.assertEqual(result["result"], "FAIL") - self.assertIn("duplicate user text", result["failure"]["message"]) - self.assertEqual(result["cases"][-1]["decoded_text_events"], 2) - - def test_received_chat_without_heard_echo_never_passes_cancellation(self): - result = self.run_simulation(SimulatedRadios(heard_echo=False), "echo") - self.assertEqual(result["result"], "FAIL") - echo = result["cases"][-1] - self.assertEqual(echo["name"], "heard-repeat-cancellation") - self.assertEqual(len(echo["attempts"]), 3) - self.assertTrue(all(attempt["received_by_stock"] for attempt in echo["attempts"])) - self.assertTrue(all(attempt["delivery_delta"]["heard"] == 0 for attempt in echo["attempts"])) - - def test_wrong_primary_is_rejected_before_any_setting_write(self): - radios = SimulatedRadios(primary_frequency=910.525) - result = self.run_simulation(radios) - self.assertEqual(result["result"], "FAIL") - self.assertIn("preconfigure primary", result["failure"]["message"]) - self.assertFalse(any(command.startswith(("set ", "delete ")) for command in radios.commands)) - self.assertEqual(radios.closed, ["companion"]) - - def test_failure_report_redacts_raw_key_bytes_hex_and_base64(self): - key = bytes(range(16)) - message = f"bad setup {key.hex()} {key!r} {base64.b64encode(key).decode()}" - with tempfile.TemporaryDirectory(prefix="meshtext-hil-secret-") as directory, \ - mock.patch.object(HIL.secrets, "token_bytes", return_value=key), \ - mock.patch.object(HIL.secrets, "token_hex", return_value="0123abcd"), \ - mock.patch.object(HIL, "dependencies", side_effect=RuntimeError(message)), \ - redirect_stdout(io.StringIO()): - args = SimpleNamespace(case="all", key_bits=128, report=Path(directory) / "report.json") - result = HIL.run(args) - saved = args.report.read_text(encoding="utf-8") - self.assertEqual(result["result"], "FAIL") - self.assertNotIn(key.hex(), saved) - self.assertNotIn(repr(key), result["failure"]["message"]) - self.assertNotIn(base64.b64encode(key).decode(), saved) - self.assertIn("", saved) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_meshtext_speed_hil.py b/test/test_meshtext_speed_hil.py deleted file mode 100644 index 2a50ff11f..000000000 --- a/test/test_meshtext_speed_hil.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Offline benchmark policy/orchestration tests; never open a radio or SDK.""" -from contextlib import redirect_stdout, redirect_stderr -import importlib.util -import io -import json -from pathlib import Path -import struct -import sys -import tempfile -from types import SimpleNamespace -import unittest -from unittest import mock - -ROOT = Path(__file__).resolve().parents[1] -TOOLS = ROOT / "tools/hil" -with mock.patch.object(sys, "path", [str(TOOLS), *sys.path]): - SPEC = importlib.util.spec_from_file_location("meshtext_speed_under_test", TOOLS / "meshtext_speed.py") - SPEED = importlib.util.module_from_spec(SPEC) - SPEC.loader.exec_module(SPEED) - - -class Simulation: - def __init__(self, *, guard=SPEED.GUARD, primary=910.525, primary_tx=0, - stock_frequency=909.5, queue_stuck=False, drop_outbound=False, drop_inbound=False): - self.now = 100.0 - self.guard, self.primary, self.primary_tx = guard, primary, primary_tx - self.queue_stuck, self.drop_outbound, self.drop_inbound = queue_stuck, drop_outbound, drop_inbound - self.commands, self.stock_writes, self.sent, self.closed = [], [], [], [] - self.frames, self.events, self.sf, self.name, self.announced = [], [], 5, "", False - self.tx, self.rx, self.stock_sequence = 0, 0, 0 - owner = self - - class Companion: - max_channels = 2 - - def __init__(self, port): - owner.companion = self - - def start(self): - return {"model": "SIMULATED", "primary": {"node_name": "Test"}} - - def close(self): - owner.closed.append("companion") - - def channel_name(self, index): - return owner.name if index == 1 else "" - - def mt_node(self): - return 0x12345678 - - def cli(self, command): - owner.commands.append(command) - if command == "get lab.tx": - return "> " + owner.guard - if command == "get radio": - return f"> {owner.primary},62.5,7,5" - if command == "get radio2.status": - return f"> rxtx; RX=0,{owner.rx} TX={owner.primary_tx},{owner.tx} preamble=32,16" - if command == "get radio2": - return f"> 909.5,500,{owner.sf},5,rxtx,32" - if command == "get mt.hops": - return "> 0" - if command == "get mt.status": - return f"> Meshtext !12345678; active; RX={owner.rx} TX={owner.tx} fail=0" - if command == "get mt.retry": - return "> retry=0 heard=0 exhausted=0 pending=0 count=0 advert=off" - if command.startswith("set radio2 "): - owner.sf = int(command.split()[2].split(",")[2]) - if command.startswith("set mt.channel "): - owner.name, owner.announced = "mt-labtest", False - if command.startswith("delete mt.channel "): - owner.name = "" - return "OK" - - def send_text(self, slot, token): - assert 7 + len(token.encode()) <= 176, "Companion USB payload envelope overflow" - owner.sent.append(("companion", token)) - owner.tx += 1 - if not owner.announced: - owner.announced = True - owner.tx += 1 - owner.events.append((owner.now, {"from": 0x12345678, "id": len(owner.sent), - "decoded": {"payload": token.encode(), "portnum": "TEXT_MESSAGE_APP"}})) - - def request(self, payload, expected, timeout): - assert payload == b"\x0a" - return owner.frames.pop(0) if owner.frames else b"\x0a" - - class Node: - def __init__(self): - self.localConfig = SimpleNamespace( - lora=SimpleNamespace(use_preset=False, override_frequency=stock_frequency, - bandwidth=500, coding_rate=5, spread_factor=5, frequency_offset=0, - hop_limit=0, tx_power=2, tx_enabled=True, channel_num=0), - device=SimpleNamespace(role=1)) - self.channels = [SimpleNamespace(role=1, settings=SimpleNamespace(name="labtest", psk=b"a"*16))] - - def beginSettingsTransaction(self): - owner.stock_writes.append("begin") - - def writeConfig(self, section): - owner.stock_writes.append(section) - - def commitSettingsTransaction(self): - owner.stock_writes.append("commit") - - class Interface: - def __init__(self, node): - self.localNode = node - self.isConnected = True - self.stream = SimpleNamespace(is_open=True) - self.queue = {} - self.queueStatus = SimpleNamespace(free=4, maxlen=4, res=0, mesh_packet_id=0) - - def close(self): - self.isConnected = False - self.stream.is_open = False - - class Stock: - node = 0x11223344 - - def __init__(self, port): - self.saved_node = Node() - self.connect() - owner.stock = self - - def connect(self): - self.iface = Interface(self.saved_node) - - def close(self): - owner.closed.append("stock") - - def configure_lab(self, key, role): - assert role == "CLIENT_MUTE" - self.saved_node.channels[0].settings.psk = key - owner.stock_writes.append("suppression") - return {"role": role, "simulated": True} - - def send_text(self, token, hops): - assert hops == 0 - owner.sent.append(("stock", token)) - owner.stock_sequence += 1 - identity = owner.stock_sequence - if owner.queue_stuck: - self.iface.queue[identity] = object() - self.iface.queueStatus.mesh_packet_id = identity - if not owner.drop_inbound: - prefix = b"!11223344: " - text = (prefix + token.encode())[:165] - owner.frames.append(bytes((17, 0, 0, 0, 1, 0, 0)) + struct.pack("= since and packet["from"] == sender and predicate(packet)), None) - owner.sleep(timeout) - return None - - self.classes = Companion, Stock - - def sleep(self, duration): - self.now += duration - - -class MeshtextSpeedTest(unittest.TestCase): - def run_simulated(self, simulation, extra=()): - directory = tempfile.TemporaryDirectory(prefix="meshtext-speed-offline-") - self.addCleanup(directory.cleanup) - path = Path(directory.name) / "report.json" - with mock.patch.object(SPEED.delivery, "dependencies", return_value=simulation.classes), \ - mock.patch.object(SPEED.delivery, "require_idle_port"), \ - mock.patch.object(sys, "path", [str(TOOLS), *sys.path]), \ - mock.patch.object(SPEED.time, "monotonic", side_effect=lambda: simulation.now), \ - mock.patch.object(SPEED.time, "sleep", side_effect=simulation.sleep), \ - redirect_stdout(io.StringIO()): - status = SPEED.main(["--allow-radio-changes", "--companion-port", "/dev/FAKE-A", - "--stock-port", "/dev/FAKE-B", "--report", str(path), "--sf", "7", "--count", "2", *extra]) - return status, json.loads(path.read_text()) - - def test_plan_has_no_dependencies_or_ports_and_never_uses_preset_frequencies(self): - with mock.patch.object(SPEED.delivery, "dependencies") as deps, \ - mock.patch.object(SPEED.delivery, "require_idle_port") as ports, redirect_stdout(io.StringIO()) as output: - self.assertEqual(SPEED.main(["--plan"]), 0) - deps.assert_not_called() - ports.assert_not_called() - plan = json.loads(output.getvalue()) - self.assertEqual(plan["frequency_mhz"], 909.5) - self.assertEqual(plan["spreading_factors"], [7, 9, 10]) - self.assertEqual(plan["preamble_symbols"], 16) - - def test_consent_invalid_parameters_same_ports_and_existing_report_refuse_before_open(self): - for arguments in ([], ["--plan", "--count", "0"], ["--plan", "--sf", "7", "7"], - ["--plan", "--gap", "0"], ["--plan", "--timeout", "31"], - ["--allow-radio-changes", "--companion-port", "same", "--stock-port", "same", "--report", "new.json"]): - with self.subTest(arguments=arguments), mock.patch.object(SPEED.delivery, "dependencies") as deps, \ - redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): - SPEED.main(arguments) - deps.assert_not_called() - - def test_complete_run_measures_both_directions_and_truthful_truncation(self): - simulation = Simulation() - status, data = self.run_simulated(simulation, ["--sf", "7", "9", "10"]) - self.assertEqual(status, 0) - self.assertEqual(data["result"], "MEASURED") - self.assertEqual(len(data["cases"]), 12) - self.assertEqual(set(data["stock_profiles"]), {"7", "9", "10"}) - self.assertEqual(simulation.stock_writes.count("suppression"), 1) - for case in data["cases"]: - self.assertEqual(case["measurement"]["received"], 2) - self.assertEqual(case["measurement"]["loss_fraction"], 0) - self.assertEqual(case["nominal_lora_kbps"], SPEED.nominal_kbps(case["sf"])) - expected = 154 if case["direction"] == "stock-to-companion" and case["wire_bytes"] == 160 else case["wire_bytes"] - self.assertEqual(case["measurement"]["application_bytes_delivered"], 2 * expected) - self.assertEqual(case["safety_after"]["primary_tx"], 0) - self.assertFalse(any(command.startswith(("set radio ", "advert", "reboot", "set freq ")) for command in simulation.commands)) - self.assertEqual(sorted(simulation.closed), ["companion", "stock"]) - - def test_guard_primary_and_counter_fail_before_any_configuration(self): - for simulation in (Simulation(guard="HIL TX lock=909.500 MHz"), Simulation(primary=909.5), Simulation(primary_tx=1)): - with self.subTest(simulation=simulation): - status, data = self.run_simulated(simulation) - self.assertEqual(status, 1) - self.assertEqual(data["result"], "FAIL") - self.assertFalse(simulation.stock_writes or simulation.sent) - self.assertFalse(any(command.startswith("set ") for command in simulation.commands)) - - def test_wrong_initial_stock_frequency_is_not_reconfigured_or_transmitted(self): - simulation = Simulation(stock_frequency=910.525) - status, data = self.run_simulated(simulation) - self.assertEqual(status, 1) - self.assertFalse(simulation.stock_writes or simulation.sent) - self.assertIn("stock must use custom 909.5", data["failure"]["message"]) - - def test_packet_id_queue_depletion_prevents_a_second_sdk_send(self): - simulation = Simulation(queue_stuck=True) - status, data = self.run_simulated(simulation) - self.assertEqual(status, 1) - self.assertEqual(sum(who == "stock" for who, _ in simulation.sent), 1) - self.assertIn("TX completion uncertain", data["failure"]["message"]) - self.assertEqual(data["cases"][-1]["packets"][-1]["tx_completion"], "uncertain") - - def test_loss_is_reported_not_promoted_to_successful_phy_transmission(self): - status, data = self.run_simulated(Simulation(drop_outbound=True, drop_inbound=True)) - self.assertEqual(status, 0) - for case in data["cases"]: - self.assertEqual(case["measurement"]["received"], 0) - self.assertEqual(case["measurement"]["measured_application_goodput_bps"], 0) - if case["direction"] == "stock-to-companion": - self.assertTrue(all("uncertain" in packet["tx_completion"] for packet in case["packets"])) - - def test_sdk_ack_and_empty_queue_are_not_claimed_as_rf_completion(self): - simulation = Simulation() - stock = simulation.classes[1]("not-a-port") - stock.iface.queueStatus.mesh_packet_id = 99 - with mock.patch.object(SPEED.time, "monotonic", side_effect=lambda: simulation.now), \ - mock.patch.object(SPEED.time, "sleep", side_effect=simulation.sleep): - result = SPEED.wait_stock_queue(stock, 1, 99) - self.assertTrue(result["packet_id_depleted"]) - self.assertIn("RF completion unconfirmed", result["meaning"]) - stock.iface.queue = None - with self.assertRaisesRegex(SPEED.delivery.Failure, "no observable packet queue"): - SPEED.wait_stock_queue(stock, 1, 99) - - def test_stale_empty_sdk_queue_cannot_prove_an_unseen_packet_id(self): - simulation = Simulation() - stock = simulation.classes[1]("not-a-port") - with mock.patch.object(SPEED.time, "monotonic", side_effect=lambda: simulation.now), \ - mock.patch.object(SPEED.time, "sleep", side_effect=simulation.sleep): - with self.assertRaisesRegex(SPEED.delivery.Failure, "TX completion uncertain"): - SPEED.wait_stock_queue(stock, 1, 99) - - def test_unique_tokens_differ_between_sizes_directions_and_packets(self): - tokens = {SPEED.token_for("12345678", sf, direction, index, size) - for sf in SPEED.SFS for size in SPEED.SIZES for direction in ("companion", "stock") for index in range(5)} - self.assertEqual(len(tokens), 60) - self.assertTrue(all(len(token) in SPEED.SIZES for token in tokens)) - - def test_nominal_bitrate_is_not_the_measured_goodput(self): - self.assertEqual([SPEED.nominal_kbps(sf) for sf in SPEED.SFS], [21.875, 7.03125, 3.90625]) - measured = SPEED.summary([{"received": True, "wire_bytes": 180, "application_bytes": 154, "truncated": True}], 2) - self.assertEqual(measured["measured_application_goodput_bps"], 616) - self.assertEqual(measured["application_bytes_delivered"], 154) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/hil/companion_raw_radio2_sweep.py b/tools/hil/companion_raw_radio2_sweep.py index 9402791c4..2d8502a27 100644 --- a/tools/hil/companion_raw_radio2_sweep.py +++ b/tools/hil/companion_raw_radio2_sweep.py @@ -332,8 +332,7 @@ def main(argv=None): stream.flush() try: - from meshtext_delivery import dependencies, require_idle_port - Companion, _ = dependencies() + from companion_serial import Companion, require_idle_port for name, port, serial in (("a", args.a_port, args.a_serial), ("b", args.b_port, args.b_serial)): usb[name] = safe_identity(port, serial) require_idle_port(port) diff --git a/tools/hil/companion_serial.py b/tools/hil/companion_serial.py new file mode 100644 index 000000000..44e3d8a29 --- /dev/null +++ b/tools/hil/companion_serial.py @@ -0,0 +1,139 @@ +"""Bounded Companion USB transport for local hardware validation tools. + +Importing this module never imports pyserial or opens a port. The caller must +verify the selected device and obtain consent for any radio changes separately. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import time + +from esp32_companion_serial_stress import ( + DeviceFrameReader, FrameTimeout, TransportCounters, encode_host_frame, + make_app_start_spec, validate_app_start_response, +) +from serial_session import open_configured_session + + +class Failure(RuntimeError): + pass + + +def check(condition, message): + if not condition: + raise Failure(message) + + +def cli_command_label(command): + words = command.split() + # Identify the setting, never its value (especially mt.channel keys). + return " ".join(words[:2] if words and words[0] in ("get", "set", "send", "delete") else words[:1]) + + +def safe_cli_reply(command, reply): + # A diagnostic implementation may echo secrets even when firmware does not. + words = command.split() + if len(words) >= 5 and words[:2] == ["set", "mt.channel"]: + reply = reply.replace(words[-1], "") + return re.sub(r"(?", reply)[:500] + + +def require_idle_port(path): + # BusyBox fuser lacks '--'; the resolved absolute path is a separate argument. + inspector = shutil.which("fuser") if sys.platform.startswith("linux") else None + if inspector: + result = subprocess.run([inspector, os.path.realpath(path)], capture_output=True, timeout=3) + check(result.returncode == 1, "selected serial port is busy or ownership inspection failed") + + +def enter_binary_mode(port): + """Release the ASCII terminal without resetting the connected board.""" + port.rts = False + port.dtr = True + token = b"+++MESHCORE-TERM-STOP\r" + check(port.write(token) == len(token), "incomplete USB Binary-mode negotiation write") + port.flush() + time.sleep(0.3) + deadline = time.monotonic() + 1 + discarded = 0 + tail = b"" + while time.monotonic() < deadline: + chunk = port.read(min(port.in_waiting or 1, 4096)) + if not chunk: + break + discarded += len(chunk) + check(discarded <= 65536, "USB terminal output exceeded bounded negotiation drain") + tail = (tail + chunk)[-512:] + check(b"set usb.logging off before Binary mode" not in tail, + "USB logging owns this port; set usb.logging off in the terminal before this test") + return {"explicit_stop_token": True, "discarded_terminal_bytes": discarded, "dtr": True} + + +class Companion: + """Existing local Companion framing and CLI behavior, with safe USB startup.""" + + def __init__(self, port): + import serial + self.port = serial.Serial(port=None, baudrate=115200, timeout=0.05, + write_timeout=2, exclusive=True) + self.port.port, self.port.dtr, self.port.rts = port, False, False + open_configured_session(self.port) + self.reader = DeviceFrameReader(TransportCounters()) + self.max_channels = 0 + + def close(self): + self.port.close() + + def request(self, payload, expected, timeout=5): + encoded = encode_host_frame(payload) + check(self.port.write(encoded) == len(encoded), "incomplete Companion write") + self.port.flush() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + frame = self.reader.read_frame(self.port, min(0.5, deadline - time.monotonic())) + except FrameTimeout: + continue + if frame[0] >= 0x80: + continue + if frame[0] == 1: + raise Failure("Companion command rejected, error " + str(frame[1:2].hex())) + check(frame[0] in expected, "unexpected Companion response " + str(frame[0])) + return frame + raise Failure("Companion response timeout for command " + str(payload[0])) + + def start(self): + negotiation = enter_binary_mode(self.port) + # ASCII acknowledgements cannot become stale framed responses. + self.reader = DeviceFrameReader(TransportCounters()) + info = self.request(bytes((22, 14)), (13,)) + check(len(info) >= 80, "truncated Companion device info") + self.max_channels = info[3] + frame = self.request(make_app_start_spec("Meshtext-Auto-HIL").payload, (5,)) + return {"protocol": info[1], "max_channels": self.max_channels, + "model": info[20:60].split(b"\0")[0].decode("utf-8", "replace"), + "firmware": info[60:80].split(b"\0")[0].decode("utf-8", "replace"), + "primary": validate_app_start_response(frame), + "usb_binary_negotiation": negotiation} + + def cli(self, command): + label = cli_command_label(command) + try: + frame = self.request(b"\x42" + command.encode("utf-8"), (0x1d,)) + reply = frame[1:].decode("utf-8", "strict").strip() + except Exception as error: + raise Failure("Companion CLI " + label + " failed: " + + safe_cli_reply(command, str(error))) from error + check(not re.match(r"(?:Error|ERR|Err)\b", reply), + "Companion CLI " + label + " rejected: " + safe_cli_reply(command, reply)) + return reply + + def channel_name(self, slot): + frame = self.request(bytes((31, slot)), (18,)) + check(len(frame) >= 50, "truncated channel info") + return frame[2:34].split(b"\0")[0].decode("utf-8", "strict") diff --git a/tools/hil/meshtext_auto_radio2.py b/tools/hil/meshtext_auto_radio2.py deleted file mode 100644 index 87b4048ed..000000000 --- a/tools/hil/meshtext_auto_radio2.py +++ /dev/null @@ -1,653 +0,0 @@ -#!/usr/bin/env python3 -"""Meshtext Companion / stock-peer automatic-radio2 hardware sweep. - -CHANGES BOTH RADIOS' SETTINGS AND TRANSMITS LoRa. The operator must back up -and restore both radios separately. No firmware flashing or PlatformIO is used. -One private random AES-128/256 key is held in memory, never included in the report. -The US stock hash frequencies are intentional, not the 909.5-MHz lab baseline. -The Companion primary modem is not changed. Other channel slots are not erased. - -Dependencies: pyserial, stock peer SDK, pypubsub. Run --plan without opening ports. -The installed stable stock schema has nine matching preset enums; the -MediumTurbo tuple is tested as an explicitly reported custom stock-radio mode. -Use distinct USB ports, no other attached clients, and conservative TX power. -""" - -from __future__ import annotations - -import argparse -from collections import deque -from dataclasses import dataclass -from datetime import datetime, timezone -import hashlib -import importlib.metadata -import json -import os -from pathlib import Path -import random -import re -import secrets -import struct -import threading -import time - -from esp32_companion_serial_stress import ( - DeviceFrameReader, FrameTimeout, TransportCounters, encode_host_frame, - make_app_start_spec, validate_app_start_response, -) - - -class TestFailure(RuntimeError): - pass - - -def require(condition, message): - if not condition: - raise TestFailure(message) - - -@dataclass(frozen=True) -class Preset: - name: str - bandwidth: int - sf: int - cr: int - stock_enum: str | None - - @property - def alias(self): - return "mt-" + self.name.lower() - - @property - def frequency_slot(self): - value = 5381 - for byte in self.name.encode("ascii"): - value = (value * 33 + byte) & 0xffffffff - return value % (26000 // self.bandwidth) + 1 - - @property - def frequency(self): - return 902 + self.bandwidth / 2000 + (self.frequency_slot - 1) * self.bandwidth / 1000 - - def describe(self): - return {"name": self.name, "alias": self.alias, "frequency_mhz": self.frequency, - "frequency_slot": self.frequency_slot, "bandwidth_khz": self.bandwidth, - "sf": self.sf, "cr": self.cr, "preamble": 16, - "stock_mode": self.stock_enum or "CUSTOM (MediumTurbo tuple; no stable enum)"} - - -PRESETS = ( - Preset("LongFast", 250, 11, 5, "LONG_FAST"), - Preset("ShortTurbo", 500, 7, 5, "SHORT_TURBO"), - Preset("ShortFast", 250, 7, 5, "SHORT_FAST"), - Preset("ShortSlow", 250, 8, 5, "SHORT_SLOW"), - Preset("MediumFast", 250, 9, 5, "MEDIUM_FAST"), - Preset("MediumSlow", 250, 10, 5, "MEDIUM_SLOW"), - Preset("LongTurbo", 500, 11, 8, "LONG_TURBO"), - Preset("LongMod", 125, 11, 8, "LONG_MODERATE"), - Preset("LongSlow", 125, 12, 8, "LONG_SLOW"), - Preset("MediumTurbo", 500, 9, 5, None), -) - - -def choose_presets(names): - selected = [] - for name in names.split(","): - normalized = name.strip().lower().replace("_", "") - if normalized in ("longmoderate", "longmod"): - normalized = "longmod" - found = next((p for p in PRESETS if p.name.lower() == normalized), None) - require(found is not None, "unknown preset: " + name) - require(found not in selected, "duplicate preset: " + name) - selected.append(found) - require(selected, "at least one preset is required") - return selected - - -def parse_profile(reply): - match = re.search(r"(?:^|>\s*)([0-9.]+),([0-9.]+),(\d+),(\d+),(rx|rxtx),(\d+)", reply) - require(match, "radio2 did not report a modem tuple") - return (float(match[1]), float(match[2]), int(match[3]), int(match[4]), match[5], int(match[6])) - - -def temporary_off(reply): - return reply in ("> off", "off", "TempRadio inactive") - - -def decode_channel_frame(frame): - if not frame or frame[0] not in (8, 17): - return None - offset = 4 if frame[0] == 17 else 1 - require(len(frame) >= offset + 7, "truncated Companion channel frame") - return {"channel": frame[offset], "path": frame[offset + 1], "type": frame[offset + 2], - "timestamp": struct.unpack_from("= 0x80: - continue - if frame[0] == 1: - raise TestFailure("Companion command rejected, error " + str(frame[1:2].hex())) - require(frame[0] in expected, "unexpected Companion response " + str(frame[0])) - return frame - raise TestFailure("Companion response timeout for command " + str(payload[0])) - - def start(self): - info = self.request(bytes((22, 14)), (13,)) - require(len(info) >= 80, "truncated Companion device info") - self.max_channels = info[3] - frame = self.request(make_app_start_spec("Meshtext-Auto-HIL").payload, (5,)) - return {"protocol": info[1], "max_channels": self.max_channels, - "model": info[20:60].split(b"\0")[0].decode("utf-8", "replace"), - "firmware": info[60:80].split(b"\0")[0].decode("utf-8", "replace"), - "primary": validate_app_start_response(frame)} - - def cli(self, command): - frame = self.request(b"\x42" + command.encode("utf-8"), (0x1d,)) - text = frame[1:].decode("utf-8", "strict").strip() - require(not re.match(r"(?:Error|ERR|Err)\b", text), "Companion CLI rejected " + command.split()[0]) - return text - - def channel_name(self, slot): - frame = self.request(bytes((31, slot)), (18,)) - require(len(frame) >= 50, "truncated channel info") - return frame[2:34].split(b"\0")[0].decode("utf-8", "strict") - - def mt_node(self): - match = re.search(r"!([0-9a-fA-F]{8})", self.cli("get mt.node")) - require(match, "missing Companion Meshtext node ID") - return int(match[1], 16) - - def send_text(self, slot, token): - self.request(bytes((3, 0, slot)) + struct.pack("= timestamp and p.get("from") == sender] - - def wait_packet(self, sender, predicate, since, timeout): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - for packet in self.packets_since(since, sender): - if predicate(packet): - return packet - time.sleep(0.05) - return None - - def send_text(self, token, hops): - return self.iface.sendText(token, destinationId="^all", wantAck=False, - wantResponse=False, channelIndex=0, hopLimit=hops) - - -def text_payload(packet): - return packet.get("decoded", {}).get("payload") - - -def print_packet_progress(label, direction, packets, total): - # Only receipt metadata is logged; payload tokens and keys stay out of stdout. - latest = packets[-1] - outcome = "received" if latest["received"] else "timeout" - elapsed = (f" in {latest['elapsed_seconds']:.3f}s" - if "elapsed_seconds" in latest else "") - received = sum(packet["received"] for packet in packets) - print(f" {label} {direction}: packet {len(packets)}/{total} {outcome}{elapsed}; " - f"received {received}/{len(packets)}", flush=True) - - -def assess_traffic(case, minimum_received): - counts = {direction: {"received": sum(packet["received"] for packet in case[direction]), - "attempted": len(case[direction])} - for direction in ("v4_to_v3", "v3_to_v4")} - failures = [{"direction": direction, **count, "required": minimum_received} - for direction, count in counts.items() if count["received"] < minimum_received] - case["traffic_counts"], case["traffic_failure"] = counts, failures - return failures - - -def pause_after_packet(packet, gap_seconds, jitter_seconds, rng): - gap = gap_seconds if jitter_seconds == 0 else rng.uniform(gap_seconds, gap_seconds + jitter_seconds) - packet["gap_after_seconds"] = gap - time.sleep(gap) - - -def write_report(path, data): - # A report must never overwrite an earlier test; the key is not in data. - with path.open("x", encoding="utf-8") as stream: - json.dump(data, stream, indent=2) - stream.write("\n") - try: - os.chmod(path, 0o600) - except OSError: - pass - - -def run(args, presets): - key = secrets.token_bytes(args.key_bits // 8) - run_id = secrets.token_hex(4) - gap_seed = int(run_id, 16) - gap_rng = random.Random(gap_seed) - report = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(), - "result": "FAIL", "run_id": run_id, "region": "US", "companion_region_setting": args.region, - "tx_power_dbm": args.tx_power, "hops": args.hops, - "key_bits": args.key_bits, "key_fingerprint": hashlib.sha256(key).hexdigest()[:16], - "minimum_received_per_direction": args.minimum_received, - "continue_on_loss": args.continue_on_loss, "gap_seconds": args.gap_seconds, - "gap_jitter_seconds": args.gap_jitter_seconds, "gap_jitter_seed": gap_seed, - "auto_clear_requested": args.auto_clear, - "settings_restored": False, "restore_owner": "operator", - "packages": {n: importlib.metadata.version(n) for n in ("pyserial", "meshtastic", "pypubsub")}, - "cases": []} - companion = stock = None - try: - if args.gap_jitter_seconds: - print(f"Gap jitter seed={gap_seed}; packet gaps {args.gap_seconds:g}.." - f"{args.gap_seconds + args.gap_jitter_seconds:g}s", flush=True) - companion = Companion(args.companion_port) - report["companion"] = companion.start() - require(args.channel_slot < companion.max_channels, "Companion channel slot out of range") - for slot in range(companion.max_channels): - if slot == args.channel_slot: - continue - name = companion.channel_name(slot).lstrip("#") - require(not name.startswith(("mt-", "r2-")), - "another Meshtext/radio2 slot conflicts with the sweep; operator must back up and resolve it first") - require(temporary_off(companion.cli("get tempradio")), "temporary primary profile is active") - require(temporary_off(companion.cli("get tempradio2")), "temporary radio2 profile is active") - report["primary_before"] = companion.cli("get radio") - for command in (f"set mt.region {args.region}", "set mt.slot 0", f"set mt.hops {args.hops}", - f"set tx {args.tx_power}", "set radio2.cross off", "set radio2 auto"): - companion.cli(command) - time.sleep(2.2) - mt_node = companion.mt_node() - report["companion"]["mt_node"] = f"!{mt_node:08x}" - stock = StockPeer(args.stock_port) - require(stock.node != mt_node, "stock and Companion Meshtext sender IDs collide") - for preset in presets: - case = {**preset.describe(), "result": "FAIL", "v4_to_v3": [], "v3_to_v4": []} - report["cases"].append(case) - print(f"Testing {preset.name}: {preset.frequency:.4f} MHz / {preset.bandwidth} / SF{preset.sf} / CR{preset.cr}", flush=True) - case["stock"] = stock.configure(preset, key, args.tx_power, args.hops) - observed = case["stock"]["readback"] - print(f" Stock readback verified: {'preset' if observed['use_preset'] else 'custom'}, " - f"slot={observed['channel_num']}, BW={observed['bandwidth']}, " - f"SF={observed['spread_factor']}, CR={observed['coding_rate']}, " - f"TX={observed['tx_power']} dBm, hops={observed['hop_limit']}", flush=True) - companion.cli(f"set mt.channel {args.channel_slot} {preset.alias} {key.hex()}") - case["automatic"] = companion.wait_auto(preset) - require(companion.cli("get radio") == report["primary_before"], "primary RF settings changed") - since = time.monotonic() - before = companion.cli("get mt.status") - time.sleep(args.quiet_seconds) - after = companion.cli("get mt.status") - before_tx, after_tx = re.search(r"\bTX=(\d+)", before), re.search(r"\bTX=(\d+)", after) - require(before_tx and after_tx and before_tx[1] == after_tx[1], "unsolicited Companion Meshtext TX during quiet window") - require(not stock.packets_since(since, mt_node), "unsolicited Companion Meshtext packet observed") - case["quiet_seconds"] = args.quiet_seconds - for sequence in range(args.messages_per_direction): - token = f"Meshtext-HIL {run_id} {preset.name} V4-V3 {sequence}" - started = time.monotonic() - companion.send_text(args.channel_slot, token) - packet = stock.wait_packet(mt_node, lambda p: text_payload(p) == token.encode(), started, args.timeout) - case["v4_to_v3"].append({"token": token, "received": packet is not None, - "elapsed_seconds": round(time.monotonic() - started, 3), - "packet_id": packet.get("id") if packet else None}) - print_packet_progress(preset.name, "V4->V3", case["v4_to_v3"], args.messages_per_direction) - # No automatic retransmit or ACK assumption: each token is one new send. - pause_after_packet(case["v4_to_v3"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng) - token = f"Meshtext-HIL {run_id} {preset.name} V3-V4 {sequence}" - started = time.monotonic() - sent = stock.send_text(token, args.hops) - received = companion.wait_text(args.channel_slot, stock.node, token, args.timeout) - case["v3_to_v4"].append({"token": token, **received, - "elapsed_seconds": round(time.monotonic() - started, 3), "packet_id": sent.id}) - print_packet_progress(preset.name, "V3->V4", case["v3_to_v4"], args.messages_per_direction) - pause_after_packet(case["v3_to_v4"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng) - # Capture RX/TX/failure counters even when reception misses the threshold. - case["status_after_traffic"] = companion.cli("get mt.status") - case["radio2_after_traffic"] = companion.cli("get radio2") - case["radio2_status_after_traffic"] = companion.cli("get radio2.status") - case["radio2_scan_after_traffic"] = companion.cli("get radio2.scan") - traffic_failures = assess_traffic(case, args.minimum_received) - if traffic_failures and not args.continue_on_loss: - raise TestFailure(preset.name + " insufficient received messages in " + traffic_failures[0]["direction"]) - if args.nodeinfo: - from meshtastic.protobuf import mesh_pb2 - started = time.monotonic() - companion.cli(f"send mt.nodeinfo {args.channel_slot}") - packet = stock.wait_packet(mt_node, lambda p: p.get("decoded", {}).get("portnum") == "NODEINFO_APP", started, args.timeout) - require(packet is not None, "manual NodeInfo not received") - user = mesh_pb2.User() - user.ParseFromString(text_payload(packet)) - require(user.id == f"!{mt_node:08x}" and user.role == 1 and user.is_unmessagable - and 1 <= len(user.short_name.encode()) <= 4 and len(user.long_name.encode()) <= 24 - and not user.public_key and not packet["raw"].want_ack - and not packet["raw"].decoded.want_response, "manual NodeInfo fields/flags invalid") - case["nodeinfo"] = {"received": True, "id": user.id, "role": user.role, - "long_name": user.long_name, "short_name": user.short_name} - case["result"] = "FAIL" if traffic_failures else "PASS" - print(f"{case['result']} {preset.name}: " + ", ".join(f"{d}={sum(p['received'] for p in case[d])}/{len(case[d])}" for d in ("v4_to_v3", "v3_to_v4")), flush=True) - if args.reboot: - # Reboot only the Companion; keep the stock observer connected. - # The NVS settings/channel remain, but the auto-owned modem is RAM-only. - print("Testing Companion reboot / automatic-profile reacquisition", flush=True) - time.sleep(max(1, args.gap_seconds)) - old_identity = report["companion"]["primary"]["public_key_prefix"] - frame = encode_host_frame(b"\x13reboot") - require(companion.port.write(frame) == len(frame), "incomplete Companion reboot command") - companion.port.flush() - companion.close() - companion = None - time.sleep(3) - deadline = time.monotonic() + 90 - while time.monotonic() < deadline: - candidate = None - try: - candidate = Companion(args.companion_port) - info = candidate.start() - except (OSError, TestFailure): - if candidate: - candidate.close() - time.sleep(1) - continue - companion = candidate - require(info["primary"]["public_key_prefix"] == old_identity, - "Companion identity changed on reconnect; wrong device or lost persistence") - break - require(companion is not None, "Companion reboot reconnect timed out") - require(companion.mt_node() == mt_node, "Meshtext sender ID changed after reboot") - persistence = {"automatic": companion.wait_auto(presets[-1]), "result": "FAIL", - "v4_to_v3": [], "v3_to_v4": []} - report["reboot_persistence"] = persistence - for sequence in range(args.messages_per_direction): - token = f"Meshtext-HIL {run_id} REBOOT V4-V3 {sequence}" - started = time.monotonic() - companion.send_text(args.channel_slot, token) - received = stock.wait_packet(mt_node, lambda p: text_payload(p) == token.encode(), started, args.timeout) is not None - persistence["v4_to_v3"].append({"token": token, "received": received}) - print_packet_progress("Reboot", "V4->V3", persistence["v4_to_v3"], args.messages_per_direction) - pause_after_packet(persistence["v4_to_v3"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng) - token = f"Meshtext-HIL {run_id} REBOOT V3-V4 {sequence}" - stock.send_text(token, args.hops) - received = companion.wait_text(args.channel_slot, stock.node, token, args.timeout) - persistence["v3_to_v4"].append({"token": token, **received}) - print_packet_progress("Reboot", "V3->V4", persistence["v3_to_v4"], args.messages_per_direction) - pause_after_packet(persistence["v3_to_v4"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng) - persistence["status_after_traffic"] = companion.cli("get mt.status") - persistence["radio2_after_traffic"] = companion.cli("get radio2") - persistence["radio2_status_after_traffic"] = companion.cli("get radio2.status") - persistence["radio2_scan_after_traffic"] = companion.cli("get radio2.scan") - reboot_failures = assess_traffic(persistence, args.minimum_received) - require(not reboot_failures, "insufficient received messages after reboot in " - + (reboot_failures[0]["direction"] if reboot_failures else "")) - persistence["result"] = "PASS" - if args.auto_clear: - print("Testing automatic radio2 release / channel re-add", flush=True) - cleanup = {"result": "FAIL"} - report["auto_clear"] = cleanup - - def capture_cleanup_state(label): - state = {} - cleanup[label] = state - for name, command in (("mt_status", "get mt.status"), ("radio2", "get radio2"), - ("radio2_status", "get radio2.status"), ("radio2_scan", "get radio2.scan"), - ("primary", "get radio")): - state[name] = companion.cli(command) - require(state["primary"] == report["primary_before"], "primary RF settings changed") - return state - - capture_cleanup_state("before") - companion.cli(f"delete mt.channel {args.channel_slot}") - removed = capture_cleanup_state("removed") - require(re.fullmatch(r"(?:>\s*)?off(?:\s+\(automatic allowed\))?", removed["radio2"]), - "automatic radio2 did not release after the last Meshtext channel was deleted") - require("no Meshtext channels" in removed["mt_status"], "Meshtext backend did not become inactive after channel deletion") - companion.cli(f"set mt.channel {args.channel_slot} {presets[-1].alias} {key.hex()}") - cleanup["automatic_readded"] = companion.wait_auto(presets[-1]) - capture_cleanup_state("readded") - cleanup["result"] = "PASS" - print("PASS automatic radio2 release / channel re-add", flush=True) - report["primary_after"] = companion.cli("get radio") - require(report["primary_after"] == report["primary_before"], "primary RF settings changed") - failed_cases = [case["name"] for case in report["cases"] if case["result"] != "PASS"] - require(not failed_cases, "traffic reception threshold failed for presets: " + ", ".join(failed_cases)) - report["result"] = "PASS" - except (Exception, SystemExit, KeyboardInterrupt) as error: - message = str(error).replace(key.hex(), "") - report["failure"] = {"type": type(error).__name__, "message": message[:500]} - finally: - report["failed_cases"] = [case["name"] for case in report["cases"] if case["result"] != "PASS"] - for name, device in (("stock", stock), ("companion", companion)): - if device: - try: - device.close() - except Exception as error: - report.setdefault("close_failures", []).append({"device": name, "type": type(error).__name__}) - report["result"] = "FAIL" - report["finished_utc"] = datetime.now(timezone.utc).isoformat() - write_report(args.output, report) - return report - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--companion-port") - parser.add_argument("--stock-port") - parser.add_argument("--channel-slot", type=int, default=1, help="Companion channel index to overwrite; backup separately") - parser.add_argument("--key-bits", type=int, choices=(128, 256), default=128) - parser.add_argument("--region", choices=("auto", "us"), default="auto", help="Companion inference from unambiguous US primary frequency") - parser.add_argument("--presets", default=",".join(p.name for p in PRESETS)) - parser.add_argument("--tx-power", type=int, default=-9, help="conservative chip TX dBm, -9..-1 (stock 0 means maximum)") - parser.add_argument("--hops", type=int, default=7, help="production default 7; use 0 for an initial-hop-only lab test") - parser.add_argument("--messages-per-direction", type=int, default=3) - parser.add_argument("--minimum-received", type=int, default=1, help="connectivity threshold; report all losses, use 3 for 3/3") - parser.add_argument("--continue-on-loss", action="store_true", help="collect traffic losses across presets; failed cases still fail the run") - parser.add_argument("--timeout", type=float, default=15) - parser.add_argument("--gap-seconds", type=float, default=1) - parser.add_argument("--gap-jitter-seconds", type=float, default=0, help="seeded uniform extra delay after each packet; recorded in report") - parser.add_argument("--quiet-seconds", type=float, default=5) - parser.add_argument("--nodeinfo", action="store_true") - parser.add_argument("--reboot", action="store_true", help="reboot Companion at the end and verify RAM auto-profile reacquisition/chat") - parser.add_argument("--auto-clear", action="store_true", help="delete/re-add the final Meshtext channel and verify automatic radio2 release/reacquisition") - parser.add_argument("--output", type=Path) - parser.add_argument("--plan", action="store_true", help="print matrix without importing/opening serial interfaces") - args = parser.parse_args() - try: - presets = choose_presets(args.presets) - require(0 <= args.channel_slot <= 255, "channel slot must be 0..255") - require(-9 <= args.tx_power <= -1 and 0 <= args.hops <= 7, "invalid lab TX power/hops; stock TX 0 means maximum") - require(1 <= args.minimum_received <= args.messages_per_direction, "invalid message/reception counts") - require(0 < args.timeout <= 60 and args.gap_seconds >= 0 and args.gap_jitter_seconds >= 0 - and args.quiet_seconds > 0, "invalid timing") - if args.plan: - print(json.dumps([p.describe() for p in presets], indent=2)) - return 0 - require(args.companion_port and args.stock_port and args.output, "ports and --output are required") - require(args.companion_port != args.stock_port, "ports must be distinct") - require(not args.output.exists() and args.output.parent.is_dir(), "choose a fresh report in an existing directory") - report = run(args, presets) - print(json.dumps({"result": report["result"], "output": str(args.output), - "failure": report.get("failure"), "restore_required": True}, indent=2)) - return 0 if report["result"] == "PASS" else 1 - except TestFailure as error: - parser.error(str(error)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/hil/meshtext_delivery.py b/tools/hil/meshtext_delivery.py deleted file mode 100644 index 4184b5bf8..000000000 --- a/tools/hil/meshtext_delivery.py +++ /dev/null @@ -1,773 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise Meshtext first-chat metadata and heard-repeat delivery on two radios. - -CHANGES BOTH RADIOS' SETTINGS AND TRANSMITS LoRa. Requires explicit consent. -The fixed private lab channel uses 909.5 MHz / 500 kHz / SF5 / CR5, TX 2 dBm. -Keys remain in memory and are never included in the report. This does not flash -firmware, back up settings, or restore them. Run --plan without dependencies or -ports. A stock peer's decoded events are not an independent raw RF capture. - -Example: - python meshtext_delivery.py --allow-radio-changes --companion-port /dev/ttyACM9 \ - --stock-port /dev/ttyUSB1 --report /tmp/meshtext-delivery-new.json -""" - -from __future__ import annotations - -import argparse -import base64 -from datetime import datetime, timezone -import importlib.metadata -import json -import os -from pathlib import Path -import re -import secrets -import shutil -import subprocess -import sys -import time - - -RF = {"frequency_mhz": 909.5, "bandwidth_khz": 500, "sf": 5, "cr": 5, - "tx_power_dbm": 2, "stock_preamble_symbols": 16} -CASES = ("quiet", "first-use", "exhaustion", "once", "echo", "inbound", "hops-zero", - "same-tuple", "rename") - - -class Failure(RuntimeError): - pass - - -def check(condition, message): - if not condition: - raise Failure(message) - - -def retry_stats(reply): - values = dict(re.findall(r"\b(retry|heard|exhausted|pending|count)=(\d+)", reply)) - check(len(values) == 5, "get mt.retry did not provide all five counters") - return {name: int(value) for name, value in values.items()} - - -def tx_stats(reply): - match = re.search(r"\bRX=(\d+) TX=(\d+) fail=(\d+)", reply) - check(match is not None, "get mt.status did not provide RF completion counters") - return dict(zip(("rx", "tx", "fail"), map(int, match.groups()))) - - -def delta(after, before): - return {name: after[name] - before[name] for name in after} - - -def port_number(packet): - value = packet.get("decoded", {}).get("portnum") - # The Python SDK normally emits enum names, but accept numeric SDK output. - return {1: "TEXT_MESSAGE_APP", 3: "POSITION_APP", 4: "NODEINFO_APP"}.get(value, value) - - -def packet_summary(packet): - return {"id": packet.get("id"), "port": port_number(packet), - "hop_limit": packet.get("hopLimit"), "hop_start": packet.get("hopStart")} - - -def cli_command_label(command): - words = command.split() - # Identify the setting, never its value (especially mt.channel keys). - return " ".join(words[:2] if words and words[0] in ("get", "set", "send", "delete") else words[:1]) - - -def safe_cli_reply(command, reply): - # Ordinary firmware replies do not echo the key; still redact it if a - # diagnostic implementation does so. Do not save the full command itself. - words = command.split() - if len(words) >= 5 and words[:2] == ["set", "mt.channel"]: - reply = reply.replace(words[-1], "") - return re.sub(r"(?", reply)[:500] - - -def require_idle_port(path): - # Do not reset/steal a serial session held by a flasher or client. BusyBox - # fuser lacks '--', so the inspected absolute path is a separate argument. - inspector = shutil.which("fuser") if sys.platform.startswith("linux") else None - if inspector: - result = subprocess.run([inspector, os.path.realpath(path)], capture_output=True, timeout=3) - check(result.returncode == 1, "selected serial port is busy or ownership inspection failed") - - -def enter_binary_mode(port): - """Explicitly release a Full Companion's default ASCII USB terminal. - - The startup probe is intentionally not assumed to be a permanent Binary - mode. Assert host presence only after opening with RTS/DTR safely released, - then drain the terminal's acknowledgement before creating framed requests. - Never retain/log the discarded plaintext (it may include unrelated logs). - """ - port.rts = False - port.dtr = True - token = b"+++MESHCORE-TERM-STOP\r" - check(port.write(token) == len(token), "incomplete USB Binary-mode negotiation write") - port.flush() - time.sleep(0.3) - deadline = time.monotonic() + 1 - discarded = 0 - tail = b"" - while time.monotonic() < deadline: - chunk = port.read(min(port.in_waiting or 1, 4096)) - if not chunk: - break - discarded += len(chunk) - check(discarded <= 65536, "USB terminal output exceeded bounded negotiation drain") - tail = (tail + chunk)[-512:] - check(b"set usb.logging off before Binary mode" not in tail, - "USB logging owns this port; set usb.logging off in the terminal before this test") - return {"explicit_stop_token": True, "discarded_terminal_bytes": discarded, "dtr": True} - - -def reconnect_stock_readback(peer, validate): - """Allow a committed stock configuration to settle across its reboot. - - Each SDK connect has the existing helper's 20-second timeout. Only static - field names/error types enter this history, never the observed secret key. - """ - history = [] - for attempt in range(1, 4): - try: - peer.connect() - mismatches = validate(peer.iface.localNode) - if not mismatches: - return {"attempts": attempt, "retry_history": history} - except Exception as error: - mismatches = ["connect/readback error: " + type(error).__name__] - history.append({"attempt": attempt, "mismatched_fields": mismatches}) - if peer.iface is not None: - try: - peer.iface.close() - finally: - peer.iface = None - if attempt < 3: - time.sleep(2) - raise Failure("stock configuration did not settle after three bounded reconnects; " - "last mismatched fields: " + ", ".join(history[-1]["mismatched_fields"])) - - -def require_stock_connected(peer): - """Silence is evidence only while the stock receive path is connected.""" - iface = getattr(peer, "iface", None) - check(iface is not None, "stock SDK disconnected; quiet/RX observations are invalid") - state = {} - connected = getattr(iface, "isConnected", None) - if hasattr(connected, "is_set"): - state["sdk_connected"] = bool(connected.is_set()) - elif isinstance(connected, bool): - state["sdk_connected"] = connected - stream = getattr(iface, "stream", None) - if stream is not None and hasattr(stream, "is_open"): - state["serial_open"] = bool(stream.is_open) - check(state, "stock SDK exposes no supported connection-health indicator") - check(all(state.values()), "stock SDK disconnected; quiet/RX observations are invalid") - return state - - -def dependencies(): - """Delay hardware dependencies so --plan also works on a clean computer.""" - import serial - from meshtext_auto_radio2 import Companion, StockPeer - from esp32_companion_serial_stress import DeviceFrameReader, TransportCounters - from serial_session import open_configured_session - - class SafeCompanion(Companion): - def __init__(self, port): - self.port = serial.Serial(port=None, baudrate=115200, timeout=0.05, - write_timeout=2, exclusive=True) - self.port.port, self.port.dtr, self.port.rts = port, False, False - open_configured_session(self.port) - self.reader = DeviceFrameReader(TransportCounters()) - self.max_channels = 0 - - def start(self): - negotiation = enter_binary_mode(self.port) - # The reader has not received any framed traffic yet; the ASCII - # acknowledgement must not become a stale framed response. - self.reader = DeviceFrameReader(TransportCounters()) - return {**super().start(), "usb_binary_negotiation": negotiation} - - def cli(self, command): - label = cli_command_label(command) - try: - frame = self.request(b"\x42" + command.encode("utf-8"), (0x1d,)) - reply = frame[1:].decode("utf-8", "strict").strip() - except Exception as error: - raise Failure("Companion CLI " + label + " failed: " - + safe_cli_reply(command, str(error))) from error - check(not re.match(r"(?:Error|ERR|Err)\b", reply), - "Companion CLI " + label + " rejected: " + safe_cli_reply(command, reply)) - return reply - - class LabStock(StockPeer): - def configure_lab(self, key, role_name, hops=7): - from meshtastic.protobuf import channel_pb2, config_pb2 - from meshtastic.util import to_node_num - - node = self.iface.localNode - node.ensureSessionKey() - deadline = time.monotonic() + 10 - while not self.iface._getOrCreateByNum(to_node_num(node.nodeNum)).get("adminSessionPassKey"): - check(time.monotonic() < deadline, "stock admin session setup timed out") - time.sleep(0.05) - node.beginSettingsTransaction() - time.sleep(0.75) - suppressed = [] - - def optional(message, field, value): - if field in message.DESCRIPTOR.fields_by_name: - setattr(message, field, value) - suppressed.append(field) - - device = node.localConfig.device - device.role = config_pb2.Config.DeviceConfig.Role.Value(role_name) - device.rebroadcast_mode = config_pb2.Config.DeviceConfig.RebroadcastMode.Value("ALL") - optional(device, "node_info_broadcast_secs", 604800) - node.writeConfig("device") - time.sleep(0.75) - position = node.localConfig.position - optional(position, "position_broadcast_secs", 604800) - optional(position, "position_broadcast_smart_enabled", False) - optional(position, "fixed_position", False) - gps_field = position.DESCRIPTOR.fields_by_name.get("gps_mode") - if gps_field and "DISABLED" in gps_field.enum_type.values_by_name: - optional(position, "gps_mode", gps_field.enum_type.values_by_name["DISABLED"].number) - node.writeConfig("position") - time.sleep(0.75) - telemetry = getattr(node.moduleConfig, "telemetry", None) - if telemetry is not None: - for field in telemetry.DESCRIPTOR.fields: - if field.name.endswith("_enabled"): - optional(telemetry, field.name, False) - elif field.name.endswith("_interval"): - optional(telemetry, field.name, 604800) - node.writeConfig("telemetry") - time.sleep(0.75) - primary = node.channels[0] - primary.role = channel_pb2.Channel.PRIMARY - primary.settings.name, primary.settings.psk = "labtest", key - primary.settings.uplink_enabled = primary.settings.downlink_enabled = False - lora = node.localConfig.lora - lora.region = config_pb2.Config.LoRaConfig.US - lora.use_preset = False - lora.bandwidth, lora.spread_factor, lora.coding_rate = 500, 5, 5 - lora.channel_num, lora.override_frequency, lora.frequency_offset = 0, 909.5, 0 - lora.hop_limit, lora.tx_power, lora.tx_enabled = hops, 2, True - lora.config_ok_to_mqtt, lora.ignore_mqtt = False, True - node.writeConfig("lora") - time.sleep(0.75) - node.writeChannel(0) - time.sleep(0.75) - # Disable other channels rather than altering their secret keys. - disabled = [] - for index, channel in enumerate(node.channels[1:], 1): - if channel.role != channel_pb2.Channel.DISABLED: - channel.role = channel_pb2.Channel.DISABLED - node.writeChannel(index) - disabled.append(index) - time.sleep(0.75) - node.commitSettingsTransaction() - # A successful commit/readback can precede the actual stock reboot. - # Reopening too early gives a convincing config followed by a dead - # receive stream. Allow that deferred reboot to finish first. - time.sleep(20) - self.iface.close() - self.iface = None - - def validate(observed_node): - observed, channel = observed_node.localConfig.lora, observed_node.channels[0] - checks = { - "channel.role": channel.role == channel_pb2.Channel.PRIMARY, - "channel.name": channel.settings.name == "labtest", - "channel.private_key_match": channel.settings.psk == key, - "channel.mqtt_off": not channel.settings.uplink_enabled and not channel.settings.downlink_enabled, - "lora.region": observed.region == config_pb2.Config.LoRaConfig.US, - "lora.custom_mode": not observed.use_preset, - "lora.bandwidth": observed.bandwidth == 500, - "lora.spread_factor": observed.spread_factor == 5, - "lora.coding_rate": observed.coding_rate == 5, - "lora.override_frequency": abs(observed.override_frequency - 909.5) < 0.001, - "lora.frequency_offset": observed.frequency_offset == 0, - "lora.channel_num": observed.channel_num == 0, - "lora.tx_power": observed.tx_power == 2, - "lora.tx_enabled": observed.tx_enabled, - "lora.hop_limit": observed.hop_limit == hops, - "lora.mqtt_off": not observed.config_ok_to_mqtt and observed.ignore_mqtt, - "device.role": observed_node.localConfig.device.role - == config_pb2.Config.DeviceConfig.Role.Value(role_name), - "device.rebroadcast_mode": observed_node.localConfig.device.rebroadcast_mode - == config_pb2.Config.DeviceConfig.RebroadcastMode.Value("ALL"), - } - return [field for field, matched in checks.items() if not matched] - - settled = reconnect_stock_readback(self, validate) - return {**self.identity(), "role": role_name, "rebroadcast": "ALL", "rf": RF, - "periodic_suppression_requested": suppressed, - "secondary_channel_slots_disabled": disabled, "readback": settled} - - return SafeCompanion, LabStock - - -class Report: - def __init__(self, path, data): - # Reserve the destination before any radio setting is changed. - self.stream = path.open("x", encoding="utf-8") - try: - os.chmod(path, 0o600) - except OSError: - pass - self.data = data - self.save() - - def save(self): - self.stream.seek(0) - json.dump(self.data, self.stream, indent=2) - self.stream.write("\n") - self.stream.truncate() - self.stream.flush() - os.fsync(self.stream.fileno()) - - def case(self, name): - result = {"name": name, "result": "RUNNING"} - self.data["cases"].append(result) - self.save() - print("Testing " + name, flush=True) - return result - - def finish_case(self, case): - case["result"] = "PASS" - self.save() - print("PASS " + case["name"], flush=True) - - -def snapshot(companion): - status, retry = companion.cli("get mt.status"), companion.cli("get mt.retry") - return {"status": status, "retry_status": retry, - "traffic": tx_stats(status), "delivery": retry_stats(retry)} - - -def wait_idle(companion, timeout): - deadline = time.monotonic() + timeout - latest = None - while time.monotonic() < deadline: - latest = snapshot(companion) - if latest["delivery"]["pending"] == 0: - return latest - time.sleep(0.1) - raise Failure("Meshtext delivery queue did not become idle; last=" + str(latest)) - - -def wait_active(companion, timeout): - deadline = time.monotonic() + timeout - first = last = "" - polls = 0 - while time.monotonic() < deadline: - last = companion.cli("get mt.status") - if not first: - first = last - polls += 1 - if "; active;" in last: - return {"first_status": first, "active_status": last, "polls": polls} - time.sleep(0.1) - raise Failure("Meshtext did not become active after settings publication; last=" + last) - - -def inspect_nodeinfo(packet, node_id): - from meshtastic.protobuf import mesh_pb2 - user = mesh_pb2.User() - user.ParseFromString(packet.get("decoded", {}).get("payload", b"")) - check(user.id == f"!{node_id:08x}", "NodeInfo sender ID mismatch") - check(user.role == 1 and user.is_unmessagable, "NodeInfo is not a receive-only hidden client") - check(1 <= len(user.short_name.encode()) <= 4 and len(user.long_name.encode()) <= 24, - "NodeInfo names exceed supported bounds") - check(not user.public_key, "unexpected public encryption key in NodeInfo") - raw = packet.get("raw") - if raw is not None: - check(not raw.want_ack and not raw.decoded.want_response, "NodeInfo requests a response") - return {"id": user.id, "short_name": user.short_name, "long_name": user.long_name, - "role": user.role, "is_unmessagable": user.is_unmessagable, - "payload_type": "User (no position fields)", "packet": packet_summary(packet)} - - -def run(args): - key = secrets.token_bytes(args.key_bits // 8) - run_id = secrets.token_hex(4) - selected = set(CASES if args.case == "all" else args.case.split(",")) - data = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(), - "result": "RUNNING", "run_id": run_id, "rf": RF, "key_bits": args.key_bits, - "cases_requested": sorted(selected), "settings_restored": False, - "restore_owner": "operator", "cases": [], - "limitations": ["Stock decoded delivery events are not an independent raw RF capture.", - "Exact retry ciphertext identity is covered by host tests, not inspected by this runner.", - "No POSITION_APP means none observed during bounded windows, not a proof of future behavior.", - "The stock device may still send boot metadata despite long periodic intervals."]} - report = Report(args.report, data) - companion = stock = None - try: - Companion, Stock = dependencies() - data["packages"] = {name: importlib.metadata.version(name) - for name in ("pyserial", "meshtastic", "pypubsub")} - require_idle_port(args.companion_port) - require_idle_port(args.stock_port) - companion = Companion(args.companion_port) - data["companion"] = companion.start() - check(args.channel_slot < companion.max_channels, "Companion channel slot out of range") - # The primary is intentionally not changed here: CLI 'set radio' is - # reboot-only. Preconfigure it, and preferably use the lab TX guard too. - data["primary"] = companion.cli("get radio") - check(abs(float(data["primary"].lstrip("> ").split(",")[0]) - 909.5) < 0.001, - "preconfigure primary to 909.5 MHz (or flash a lab profile) before this runner") - for slot in range(companion.max_channels): - if slot != args.channel_slot: - name = companion.channel_name(slot).lstrip("#") - check(not name.startswith(("mt-", "r2-")), - "another Meshtext/radio2 channel conflicts; remove it explicitly first") - target_name = companion.channel_name(args.channel_slot).lstrip("#") - if target_name.startswith("mt-"): - companion.cli(f"delete mt.channel {args.channel_slot}") - # Clear temporary leases before installing a manual lab-only profile. - for command in ("normalradio", "set tempradio2 off", "set mt.region us", - "set mt.slot 0", "set mt.hops 7", "set tx 2", "set radio2.cross off", - "set flood.retry.count 2", "set flood.retry.advert off", - "set radio2 909.5,500,5,5,rxtx,32"): - companion.cli(command) - stock = Stock(args.stock_port) - data["stock_silent"] = stock.configure_lab(key, "CLIENT_MUTE") - stock_role = "CLIENT_MUTE" - node_id = companion.mt_node() - check(stock.node != node_id, "stock and Companion sender IDs collide") - data["companion"]["mt_node"] = f"!{node_id:08x}" - # Installing a new random key resets first-use identity announcement. - companion.cli(f"set mt.channel {args.channel_slot} mt-labtest {key.hex()}") - data["initial"] = snapshot(companion) - data["radio2"] = companion.cli("get radio2") - check("; active;" in data["initial"]["status"], "Meshtext profile is not active") - report.save() - - if "quiet" in selected: - case = report.case("quiet") - case["stock_connected_before"] = require_stock_connected(stock) - before, since = snapshot(companion), time.monotonic() - time.sleep(args.quiet_seconds) - case["stock_connected_after"] = require_stock_connected(stock) - after = snapshot(companion) - case.update(before=before, after=after, observed_seconds=args.quiet_seconds, - packets=[packet_summary(p) for p in stock.packets_since(since, node_id)]) - report.save() - check(after["traffic"]["tx"] == before["traffic"]["tx"], - "Companion transmitted Meshtext before any user chat") - check(not case["packets"], "stock observed unsolicited DUT traffic") - report.finish_case(case) - if selected == {"quiet"}: - data["final"] = after - data["result"] = "PASS" - return data - - # The first send is also needed to prime the once/echo cases. Report it - # even when only a dependent test was requested. - case = report.case("first-use") - token = f"MT-delivery {run_id} first" - before, since = snapshot(companion), time.monotonic() - companion.send_text(args.channel_slot, token) - text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") - == token.encode(), since, args.timeout) - check(text is not None, "stock did not receive first chat") - info = stock.wait_packet(node_id, lambda p: port_number(p) == "NODEINFO_APP", since, args.timeout) - check(info is not None, "stock did not receive automatic NodeInfo after first chat") - case["nodeinfo"] = inspect_nodeinfo(info, node_id) - after = wait_idle(companion, args.timeout) - packets = stock.packets_since(since, node_id) - texts = [p for p in packets if p.get("decoded", {}).get("payload") == token.encode()] - infos = [p for p in packets if port_number(p) == "NODEINFO_APP"] - case.update(before=before, after=after, traffic_delta=delta(after["traffic"], before["traffic"]), - delivery_delta=delta(after["delivery"], before["delivery"]), - packets=[packet_summary(p) for p in packets], decoded_text_events=len(texts), - decoded_nodeinfo_events=len(infos)) - report.save() - check(not any(port_number(p) == "POSITION_APP" for p in packets), - "DUT emitted a position packet after first chat") - check(len(infos) == 1, "automatic NodeInfo was not observed exactly once") - check(packets.index(text) < packets.index(info), "automatic NodeInfo arrived before user text") - report.finish_case(case) - - if "exhaustion" in selected: - case = report.case("silent-peer-exhaustion") - case.update(evidence_from="first-use", **{name: report.data["cases"][-2][name] - for name in ("traffic_delta", "delivery_delta", "decoded_text_events")}) - report.save() - check(case["delivery_delta"]["retry"] == 2 - and case["delivery_delta"]["exhausted"] == 1 - and case["delivery_delta"]["heard"] == 0, - "silent peer did not cause exactly two retries and one exhaustion") - check(case["traffic_delta"]["tx"] == 4 and case["traffic_delta"]["fail"] == 0, - "expected completed RF TX: original chat + two retries + one NodeInfo") - check(case["decoded_text_events"] == 1, - "stock delivered duplicate user text for the same retried packet ID") - report.finish_case(case) - - if "once" in selected: - case = report.case("second-chat-no-new-nodeinfo") - token = f"MT-delivery {run_id} second" - before, since = snapshot(companion), time.monotonic() - companion.send_text(args.channel_slot, token) - text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") - == token.encode(), since, args.timeout) - after = wait_idle(companion, args.timeout) - packets = stock.packets_since(since, node_id) - case.update(received=text is not None, before=before, after=after, - packets=[packet_summary(p) for p in packets]) - report.save() - check(text is not None, "stock did not receive second chat") - check(not any(port_number(p) in ("NODEINFO_APP", "POSITION_APP") for p in packets), - "second chat emitted extra NodeInfo or position") - check(after["traffic"]["tx"] - before["traffic"]["tx"] == 3, - "second silent-peer chat did not complete precisely three text TXs") - report.finish_case(case) - - if "echo" in selected: - case = report.case("heard-repeat-cancellation") - case["stock"] = stock.configure_lab(key, "CLIENT") - stock_role = "CLIENT" - # At SF5 the DUT's first two attempts can finish before a stock - # CLIENT's relay backoff (~2 s). Leave a genuine observation window. - companion.cli("set flood.retry.count 15") - case["retry_budget"] = 15 - case["attempts"] = [] - report.save() - passed = False - for attempt in range(args.echo_attempts): - token = f"MT-delivery {run_id} echo {attempt}" - before, since = snapshot(companion), time.monotonic() - companion.send_text(args.channel_slot, token) - text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") - == token.encode(), since, args.timeout) - after = wait_idle(companion, args.timeout) - delivery = delta(after["delivery"], before["delivery"]) - observation = {"received_by_stock": text is not None, "before": before, "after": after, - "delivery_delta": delivery, - "traffic_delta": delta(after["traffic"], before["traffic"]), - "packets": [packet_summary(p) for p in stock.packets_since(since, node_id)]} - case["attempts"].append(observation) - report.save() - if text is not None and delivery["heard"] >= 1 and delivery["exhausted"] == 0 \ - and delivery["retry"] < 15: - passed = True - break - check(passed, "no progressed stock relay echo cancelled a retry before exhaustion; RF/scanning needs investigation") - case["proof"] = "DUT heard counter advanced on a matching progressed frame; pending=0 before budget exhaustion" - report.finish_case(case) - - if "inbound" in selected: - case = report.case("stock-to-companion") - token = f"MT-delivery {run_id} inbound" - sent = stock.send_text(token, 7) - received = companion.wait_text(args.channel_slot, stock.node, token, args.timeout) - case.update(packet_id=sent.id, **received) - report.save() - check(received["received"], "Companion did not decode the stock private-channel chat") - report.finish_case(case) - - if "hops-zero" in selected: - case = report.case("zero-hops-no-useless-retries") - companion.cli("set mt.hops 0") - companion.cli("set flood.retry.count 2") - before, since = snapshot(companion), time.monotonic() - token = f"MT-delivery {run_id} zero" - companion.send_text(args.channel_slot, token) - text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") - == token.encode(), since, args.timeout) - after = wait_idle(companion, args.timeout) - time.sleep(1) - final = snapshot(companion) - case.update(received=text is not None, before=before, after=after, final=final, - delivery_delta=delta(final["delivery"], before["delivery"]), - traffic_delta=delta(final["traffic"], before["traffic"])) - report.save() - check(text is not None, "stock did not receive zero-hop text") - check(case["traffic_delta"]["tx"] == 1 and case["delivery_delta"]["retry"] == 0, - "zero-hop text caused a useless retransmission or identity announcement") - report.finish_case(case) - - if selected & {"same-tuple", "rename"}: - # Previous all-case hops-zero tests intentionally leave hops=0. - # Restore the baseline before observing either new configuration. - wait_idle(companion, args.timeout) - companion.cli("set mt.hops 7") - companion.cli("set flood.retry.count 0") - companion.cli("set flood.retry.advert off") - - if "same-tuple" in selected: - case = report.case("same-tuple-publication-recovery") - before, since = snapshot(companion), time.monotonic() - companion.cli("set radio2 909.5,500,5,5,rxtx,32") - # Do not repair/reapply any setting while the publication lease - # expires: only read-only status polls may cause this test to pass. - case["publication"] = wait_active(companion, args.timeout) - after = snapshot(companion) - case.update(before=before, after=after, - traffic_delta=delta(after["traffic"], before["traffic"]), - primary_after=companion.cli("get radio"), - packets=[packet_summary(p) for p in stock.packets_since(since, node_id)], - public_safety="primary unchanged; no Public transmission requested") - report.save() - check(case["traffic_delta"]["tx"] == 0, "same-tuple configuration caused unsolicited Meshtext TX") - check(case["primary_after"] == data["primary"], "same-tuple radio2 update changed primary radio") - check(not case["packets"], "stock observed unsolicited DUT traffic after same-tuple update") - report.finish_case(case) - - if "rename" in selected: - case = report.case("rename-rearms-first-chat-nodeinfo") - original_name = data["companion"].get("primary", {}).get("node_name") - check(isinstance(original_name, str) and original_name - and not any(character in original_name for character in "\r\n\0"), - "APP_START did not provide a safely restorable original node name") - if stock_role != "CLIENT_MUTE": - case["stock"] = stock.configure_lab(key, "CLIENT_MUTE") - stock_role = "CLIENT_MUTE" - require_stock_connected(stock) - new_name = "MT-HIL-" + run_id - check(len(new_name.encode()) < 24 and new_name != original_name, "invalid unique rename test name") - case.update(original_name=original_name, test_name=new_name, original_name_restored=False) - renamed = False - try: - before = snapshot(companion) - companion.cli("set name " + new_name) - renamed = True - time.sleep(0.4) - case["after_rename_quiet"] = snapshot(companion) - check(case["after_rename_quiet"]["traffic"]["tx"] == before["traffic"]["tx"], - "renaming the node transmitted Meshtext before user chat") - since = time.monotonic() - tokens = [] - case["probes"] = [] - case["probe_timeout_seconds"] = min(5, args.timeout) - # A zero-retry first chat can be lost over RF even though it - # correctly triggered the one identity TX. Use distinct new - # chats, retain all misses, and observe NodeInfo from before - # the first send rather than demanding it after a received one. - for probe in range(3): - require_stock_connected(stock) - token = f"MT-delivery {run_id} renamed {probe}" - tokens.append(token) - companion.send_text(args.channel_slot, token) - text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") - == token.encode(), since, min(5, args.timeout)) - info = stock.wait_packet(node_id, lambda p: port_number(p) == "NODEINFO_APP", - since, min(5, args.timeout)) - case["probes"].append({"index": probe, "received": text is not None, - "received_in_probe_window": text is not None, - "packet_id": text.get("id") if text else None}) - report.save() - if text is not None and info is not None: - break - after = wait_idle(companion, args.timeout) - require_stock_connected(stock) - packets = stock.packets_since(since, node_id) - for token, probe in zip(tokens, case["probes"]): - text = next((packet for packet in packets - if packet.get("decoded", {}).get("payload") == token.encode()), None) - probe["received"] = text is not None - probe["packet_id"] = text.get("id") if text else None - infos = [packet for packet in packets if port_number(packet) == "NODEINFO_APP"] - case.update(before=before, after=after, - attempted_texts=len(tokens), - received_texts=sum(probe["received"] for probe in case["probes"]), - decoded_nodeinfo_events=len(infos), - traffic_delta=delta(after["traffic"], before["traffic"]), - packets=[packet_summary(p) for p in packets]) - report.save() - check(case["received_texts"] >= 1, "stock did not receive any of three distinct chats after rename") - check(len(infos) == 1, "rename did not produce exactly one observed automatic NodeInfo") - case["nodeinfo"] = inspect_nodeinfo(infos[0], node_id) - report.save() - check(case["nodeinfo"]["long_name"] == new_name, "automatic NodeInfo retained the old long name") - check(case["traffic_delta"]["tx"] == len(tokens) + 1 and case["traffic_delta"]["fail"] == 0, - "renamed chat probes did not complete precisely attempted texts + one NodeInfo TX") - check(not any(port_number(p) == "POSITION_APP" for p in packets), - "renamed first chat emitted position data") - finally: - if renamed: - before_restore = snapshot(companion) - companion.cli("set name " + original_name) - time.sleep(0.4) - after_restore = snapshot(companion) - restored_name = companion.cli("get name") - check(restored_name in (original_name, "> " + original_name), "original node name was not restored") - case["original_name_restored"] = True - case["restore_traffic_delta"] = delta(after_restore["traffic"], before_restore["traffic"]) - report.save() - check(case["restore_traffic_delta"]["tx"] == 0, - "restoring the original name caused unsolicited Meshtext TX") - report.finish_case(case) - data["final"] = snapshot(companion) - data["result"] = "PASS" - except (Exception, KeyboardInterrupt) as error: - message = str(error) - for secret in (key.hex(), str(key), repr(key), base64.b64encode(key).decode()): - message = message.replace(secret, "") - data["result"] = "FAIL" - data["failure"] = {"type": type(error).__name__, "message": message[:1500]} - if data["cases"] and data["cases"][-1]["result"] == "RUNNING": - data["cases"][-1]["result"] = "FAIL" - finally: - for name, device in (("stock", stock), ("companion", companion)): - if device is not None: - try: - device.close() - except Exception as error: - data.setdefault("close_failures", []).append({"device": name, "type": type(error).__name__}) - data["result"] = "FAIL" - data["finished_utc"] = datetime.now(timezone.utc).isoformat() - report.save() - report.stream.close() - return data - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--companion-port") - parser.add_argument("--stock-port") - parser.add_argument("--report", type=Path) - parser.add_argument("--channel-slot", type=int, default=1) - parser.add_argument("--key-bits", type=int, choices=(128, 256), default=128) - parser.add_argument("--case", default="all", help="all or comma-separated: " + ",".join(CASES)) - parser.add_argument("--timeout", type=float, default=25, help="each bounded observation, at most 30 s") - parser.add_argument("--quiet-seconds", type=float, default=5) - parser.add_argument("--echo-attempts", type=int, default=3) - parser.add_argument("--allow-radio-changes", action="store_true") - parser.add_argument("--plan", action="store_true") - args = parser.parse_args() - try: - check(args.case == "all" or set(args.case.split(",")) <= set(CASES), "unknown case") - check(0 <= args.channel_slot <= 255, "channel-slot must be 0..255") - check(0 < args.timeout <= 30 and 0 < args.quiet_seconds <= 30, - "each wait must be positive and at most 30 s") - check(1 <= args.echo_attempts <= 5, "echo-attempts must be 1..5") - if args.plan: - print(json.dumps({"rf": RF, "cases": CASES, "key_bits": args.key_bits, - "consent_required": "--allow-radio-changes", - "primary_precondition": "DUT primary already 909.5 MHz; use lab TX-frequency guard", - "silent_role": "CLIENT_MUTE", "echo_role": "CLIENT", - "echo_retry_budget": 15, "settings_restored": False}, indent=2)) - return 0 - check(args.allow_radio_changes, "--allow-radio-changes is required before opening ports") - check(args.companion_port and args.stock_port and args.report, - "--companion-port, --stock-port and --report are required") - check(os.path.realpath(args.companion_port) != os.path.realpath(args.stock_port), "ports must be distinct") - check(not args.report.exists() and args.report.parent.is_dir(), - "choose a fresh report in an existing directory") - result = run(args) - print(json.dumps({"result": result["result"], "report": str(args.report), - "failure": result.get("failure"), "settings_restored": False}, indent=2)) - return 0 if result["result"] == "PASS" else 1 - except Failure as error: - parser.error(str(error)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/hil/meshtext_speed.py b/tools/hil/meshtext_speed.py deleted file mode 100644 index 0ec517f89..000000000 --- a/tools/hil/meshtext_speed.py +++ /dev/null @@ -1,369 +0,0 @@ -#!/usr/bin/env python3 -"""Guarded custom-PHY Meshtext goodput/loss benchmark, never a preset sweep. - -Changes both selected radios and transmits ONLY on 909.5 MHz. The Companion -must already have the immutable lab TX lock and USA primary RX configuration. -No flashing, SSH, USB reset, primary change, or settings restoration is done. -Use --plan without hardware dependencies. Keys exist in RAM only, not reports. -Stock queue depletion is serial/device admission evidence, not proof of RF TX. -""" -from __future__ import annotations - -import argparse -from collections.abc import Mapping -from datetime import datetime, timezone -from decimal import Decimal -import json -import os -from pathlib import Path -import re -import secrets -import time - -import meshtext_delivery as delivery - -GUARD = "HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency" -PRIMARY = (Decimal("910.525"), Decimal("62.5"), 7, 5) -SFS = (7, 9, 10) -SIZES = (32, 160) - - -def check(condition, message): - delivery.check(condition, message) - - -def radio_tuple(reply): - match = re.fullmatch(r">?\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)(?:[,; ].*)?", reply.strip()) - check(match, "missing or ambiguous primary radio tuple") - return Decimal(match[1]), Decimal(match[2]), int(match[3]), int(match[4]) - - -def primary_tx(reply): - matches = re.findall(r"\bTX=(\d+),(\d+)(?=\s|;|$)", reply) - check(len(matches) == 1, "missing or ambiguous per-profile TX counters") - return int(matches[0][0]) - - -def safety(companion): - guard = companion.cli("get lab.tx") - check(guard.removeprefix(">").strip() == GUARD, "exact immutable 909.500 MHz TX lock is required") - primary = companion.cli("get radio") - actual = radio_tuple(primary) - check(abs(actual[0] - PRIMARY[0]) <= Decimal("0.00005") and actual[1:] == PRIMARY[1:], - "preconfigure and reboot the Companion with USA primary 910.525,62.5,7,5") - status = companion.cli("get radio2.status") - check(primary_tx(status) == 0, "primary TX counter must remain zero; aborting benchmark") - return {"guard": guard, "primary": primary, "profile_status": status, "primary_tx": 0} - - -def nominal_kbps(sf): - return sf * 4 * 500 / (5 * (2 ** sf)) - - -def check_stock_rf(stock, sf=None, key=None): - delivery.require_stock_connected(stock) - config = stock.iface.localNode.localConfig.lora - check(not config.use_preset and abs(config.override_frequency - 909.5) < 0.00005 - and config.bandwidth == 500 and config.coding_rate == 5 - and abs(config.frequency_offset) < 0.00005, "stock must use custom 909.5 MHz / 500 kHz / CR5") - check(config.spread_factor in (5, *SFS) and (sf is None or config.spread_factor == sf), - "stock spreading factor mismatch") - if key is not None: - channel = stock.iface.localNode.channels[0] - check(channel.role == 1 and channel.settings.name == "labtest" - and channel.settings.psk == key, "stock private lab channel readback mismatch") - check(config.hop_limit == 0 and config.tx_power == 2 and config.tx_enabled, - "stock must have hops=0, TX=2 dBm, and TX enabled") - - -def configure_stock_sf(stock, key, sf): - """Change only the fixed lab PHY, then wait for its deferred reboot.""" - check_stock_rf(stock) - node = stock.iface.localNode - node.beginSettingsTransaction() - time.sleep(0.75) - config = node.localConfig.lora - config.use_preset = False - config.bandwidth, config.spread_factor, config.coding_rate = 500, sf, 5 - config.override_frequency, config.frequency_offset, config.channel_num = 909.5, 0, 0 - config.hop_limit, config.tx_power, config.tx_enabled = 0, 2, True - node.writeConfig("lora") - time.sleep(0.75) - node.commitSettingsTransaction() - time.sleep(20) - stock.iface.close() - stock.iface = None - - def validate(observed): - cfg, channel = observed.localConfig.lora, observed.channels[0] - checks = {"custom_phy": not cfg.use_preset and cfg.bandwidth == 500 - and cfg.spread_factor == sf and cfg.coding_rate == 5, - "frequency": abs(cfg.override_frequency - 909.5) < 0.00005 - and abs(cfg.frequency_offset) < 0.00005 and cfg.channel_num == 0, - "tx": cfg.hop_limit == 0 and cfg.tx_power == 2 and cfg.tx_enabled, - "private_channel": channel.role == 1 and channel.settings.name == "labtest" - and channel.settings.psk == key, - "muted_role": observed.localConfig.device.role == 1} - return [name for name, matched in checks.items() if not matched] - - result = delivery.reconnect_stock_readback(stock, validate) - check_stock_rf(stock, sf, key) - return result - - -def check_companion_rf(companion, sf): - from meshtext_auto_radio2 import parse_profile - actual = parse_profile(companion.cli("get radio2")) - check(abs(actual[0] - 909.5) < 0.00005 and actual[1:5] == (500, sf, 5, "rxtx"), - "Companion radio2 does not match the requested custom lab PHY") - status = companion.cli("get radio2.status") - check(re.search(r"\bpreamble=\d+,16(?:\s|;|$)", status), "radio2 must use the fixed 16-symbol MT preamble") - check(companion.cli("get mt.hops").removeprefix(">").strip() == "0", "Companion must use hops=0") - check(delivery.snapshot(companion)["delivery"]["count"] == 0, "Companion retries must be disabled") - return {"saved_radio2": actual, "status": status} - - -def queue_snapshot(iface, packet_id=None): - """Observe the SDK's documented queue without modifying it or resending. - - SDK reference: github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py - QueueStatus/queue describe serial admission, not a physical TX-done event. - """ - queue = getattr(iface, "queue", None) - status = getattr(iface, "queueStatus", None) - supported = isinstance(queue, Mapping) - state = {"supported": supported, "packet_pending": None, "pending_entries": None} - if supported: - # Capture primitive items; the SDK reader can change the dictionary. - try: - items = list(queue.items()) - except RuntimeError: - return state - state["pending_entries"] = sum(bool(value) for _, value in items) - if packet_id is not None: - state["packet_pending"] = any(identity == packet_id and bool(value) for identity, value in items) - if status is not None: - state.update(free=int(status.free), maxlen=int(status.maxlen), - result=int(status.res), last_id=int(status.mesh_packet_id)) - return state - - -def wait_stock_queue(stock, timeout, packet_id=None): - deadline = time.monotonic() + timeout - latest = {} - observed_packet = False - while time.monotonic() < deadline: - delivery.require_stock_connected(stock) - latest = queue_snapshot(stock.iface, packet_id) - check(latest["supported"], "stock SDK exposes no observable packet queue; TX completion uncertain") - if packet_id is not None and latest.get("last_id") == packet_id: - check(not latest.get("result"), "stock rejected the queued packet; no additional TX requested") - observed_packet = True - if packet_id is not None and latest["packet_pending"] is True: - observed_packet = True - if latest["pending_entries"] == 0 and (packet_id is None or latest["packet_pending"] is False) \ - and latest.get("free", 0) > 0 and (packet_id is None or observed_packet): - return {**latest, "packet_id_depleted": packet_id is not None, - "packet_id_observed": observed_packet, - "meaning": "SDK/device admission only; RF completion unconfirmed without peer receipt"} - time.sleep(0.02) - raise delivery.Failure("stock packet queue did not drain before deadline; TX completion uncertain") - - -def wait_companion_text(companion, slot, sender, token, timeout): - from meshtext_auto_radio2 import decode_channel_frame - prefix = f"!{sender:08x}: ".encode() - payload = token.encode("ascii") - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - frame = companion.request(b"\x0a", (7, 8, 10, 16, 17), - timeout=min(5, max(0.1, deadline - time.monotonic()))) - decoded = decode_channel_frame(frame) - if decoded and decoded["channel"] == slot and decoded["text"].startswith(prefix): - observed = decoded["text"][len(prefix):] - # A V3 event can truncate 160 wire bytes to its bounded app envelope. - # Match a unique leading token, never claim the discarded tail arrived. - if len(observed) >= 24 and observed == payload[:len(observed)]: - return {"received": True, "application_bytes": len(observed), - "truncated": len(observed) < len(payload)} - time.sleep(0.02) - return {"received": False, "application_bytes": 0, "truncated": False} - - -def token_for(run_id, sf, direction, index, size): - prefix = f"MS{run_id}:{sf}:{size}:{direction[0]}:{index:02d}:" - check(len(prefix) <= size, "message size cannot contain the unique benchmark token") - return prefix + "x" * (size - len(prefix)) - - -def summary(rows, elapsed): - received = [row for row in rows if row.get("received")] - count = len(rows) - delivered = sum(row["application_bytes"] for row in received) - return {"attempted": count, "received": len(received), "lost": count - len(received), - "loss_fraction": (count - len(received)) / count if count else None, - "wire_payload_bytes_requested": sum(row["wire_bytes"] for row in rows), - "application_bytes_delivered": delivered, "elapsed_seconds": round(elapsed, 6), - "measured_application_goodput_bps": delivered * 8 / elapsed if elapsed else 0, - "truncated_messages": sum(row.get("truncated", False) for row in received), - "definition": "sequential host-observed goodput including receipt waits and pacing; not maximum PHY throughput"} - - -def benchmark(companion, stock, *, sf, size, direction, count, timeout, gap, slot, run_id, node_id, case, persist): - rows = case["packets"] - started = time.monotonic() - for index in range(count): - safety(companion) - check_companion_rf(companion, sf) - check_stock_rf(stock, sf) - token = token_for(run_id, sf, direction, index, size) - row = {"index": index, "wire_bytes": size, "received": False, - "application_bytes": 0, "truncated": False, "tx_completion": "uncertain"} - rows.append(row) - before = time.monotonic() - if direction == "companion-to-stock": - original = delivery.snapshot(companion) - companion.send_text(slot, token) - packet = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") == token.encode(), - before, timeout) - completed = delivery.wait_idle(companion, timeout) - row.update(received=packet is not None, application_bytes=size if packet else 0, - packet_id=packet.get("id") if packet else None, - tx_completion="DUT RF completion counter" if completed["traffic"]["tx"] > original["traffic"]["tx"] else "uncertain") - check(row["tx_completion"] != "uncertain", "DUT TX completion uncertain; stopping benchmark") - else: - row["queue_before"] = wait_stock_queue(stock, timeout) - packet = stock.send_text(token, 0) - row["packet_id"] = int(packet.id) - persist() - row["queue_after"] = wait_stock_queue(stock, timeout, packet.id) - row.update(wait_companion_text(companion, slot, stock.node, token, timeout)) - row["tx_completion"] = "peer receipt" if row["received"] else "uncertain: SDK queue drained, no RF receipt" - row["elapsed_seconds"] = round(time.monotonic() - before, 6) - persist() - time.sleep(gap) - case["measurement"] = summary(rows, time.monotonic() - started) - - -def run(args): - key = secrets.token_bytes(args.key_bits // 8) - data = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(), "result": "RUNNING", - "run_id": secrets.token_hex(4), "key_bits": args.key_bits, "settings_restored": False, - "phy": {"frequency_mhz": 909.5, "bandwidth_khz": 500, "cr": 5, "preamble_symbols": 16}, - "count_per_size_direction": args.count, "gap_seconds": args.gap, "cases": [], - "limitations": ["Stock decoded events are not raw RF capture or independent TX-done evidence.", - "Nominal LoRa bitrate and measured application goodput are different quantities.", - "Inbound 160-byte wire messages may be truncated by the Companion app envelope.", - "Missed stock sends without RF receipt remain TX-completion-uncertain, even after queue depletion."]} - report = delivery.Report(args.report, data) - companion = stock = None - try: - Companion, Stock = delivery.dependencies() - delivery.require_idle_port(args.companion_port) - delivery.require_idle_port(args.stock_port) - companion = Companion(args.companion_port) - data["companion"] = companion.start() - data["initial_safety"] = safety(companion) - check(args.channel_slot < companion.max_channels, "Companion channel slot out of range") - for index in range(companion.max_channels): - if index != args.channel_slot: - check(not companion.channel_name(index).lstrip("#").startswith(("mt-", "r2-")), - "remove conflicting radio2/MT channels explicitly before this benchmark") - stock = Stock(args.stock_port) - check_stock_rf(stock) # Refuse an initially preset/USA stock radio before any writes. - data["stock_suppression"] = stock.configure_lab(key, "CLIENT_MUTE") - node_id = companion.mt_node() - check(node_id != stock.node, "radio sender IDs collide") - for sf in args.sf: - data["current_sf"] = sf - safety(companion) - delivery.wait_idle(companion, args.timeout) - if companion.channel_name(args.channel_slot).lstrip("#").startswith("mt-"): - companion.cli(f"delete mt.channel {args.channel_slot}") - for command in ("set tempradio2 off", "set mt.region us", "set mt.hops 0", "set tx 2", - "set radio2.cross off", "set flood.retry.count 0", "set flood.retry.advert off", - f"set radio2 909.5,500,{sf},5,rxtx,32"): - companion.cli(command) - data.setdefault("stock_profiles", {})[str(sf)] = configure_stock_sf(stock, key, sf) - companion.cli(f"set mt.channel {args.channel_slot} mt-labtest {key.hex()}") - delivery.wait_active(companion, args.timeout) - data.setdefault("companion_profiles", {})[str(sf)] = check_companion_rf(companion, sf) - safety(companion) - companion.send_text(args.channel_slot, f"MS{data['run_id']}:warmup:{sf}") - time.sleep(0.4) - delivery.wait_idle(companion, args.timeout) - time.sleep(0.3) - for size in SIZES: - for direction in ("companion-to-stock", "stock-to-companion"): - case = report.case(f"SF{sf}-{size}-{direction}") - case.update(sf=sf, wire_bytes=size, direction=direction, - nominal_lora_kbps=nominal_kbps(sf), packets=[]) - benchmark(companion, stock, sf=sf, size=size, direction=direction, count=args.count, - timeout=args.timeout, gap=args.gap, slot=args.channel_slot, - run_id=data["run_id"], node_id=node_id, case=case, persist=report.save) - case["safety_after"] = safety(companion) - case["result"] = "MEASURED" # Packet loss is measured, not relabelled as a pass. - report.save() - data["final_safety"] = safety(companion) - data["result"] = "MEASURED" - except (Exception, KeyboardInterrupt) as error: - message = str(error) - import base64 - for secret in (key.hex(), str(key), repr(key), base64.b64encode(key).decode()): - message = message.replace(secret, "") - data["result"] = "FAIL" - data["failure"] = {"type": type(error).__name__, "message": message[:1000]} - finally: - for device in (stock, companion): - if device is not None: - try: - device.close() - except Exception as error: - data.setdefault("close_errors", []).append(type(error).__name__) - data["result"] = "FAIL" - data["finished_utc"] = datetime.now(timezone.utc).isoformat() - report.save() - report.stream.close() - return data - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--companion-port") - parser.add_argument("--stock-port") - parser.add_argument("--report", type=Path) - parser.add_argument("--channel-slot", type=int, default=1) - parser.add_argument("--key-bits", type=int, choices=(128, 256), default=128) - parser.add_argument("--sf", type=int, choices=SFS, nargs="+", default=list(SFS)) - parser.add_argument("--count", type=int, default=5) - parser.add_argument("--timeout", type=float, default=3) - parser.add_argument("--gap", type=float, default=0.25) - parser.add_argument("--allow-radio-changes", action="store_true") - parser.add_argument("--plan", action="store_true") - args = parser.parse_args(argv) - try: - check(0 <= args.channel_slot <= 255 and 1 <= args.count <= 20, "invalid slot/count (count 1..20)") - check(0 < args.timeout <= 30 and 0.25 <= args.gap <= 5, "timeout 0..30 s; pacing gap 0.25..5 s") - check(len(set(args.sf)) == len(args.sf), "duplicate SF selection") - if args.plan: - print(json.dumps({"frequency_mhz": 909.5, "bandwidth_khz": 500, "cr": 5, - "spreading_factors": args.sf, "preamble_symbols": 16, - "sizes": SIZES, "count_per_direction_size": args.count, - "primary_required": "910.525,62.5,7,5; TX counter zero; immutable 909.500 MHz HIL guard", - "hops": 0, "retries": 0, "key_bits": args.key_bits, - "consent_required": "--allow-radio-changes", "settings_restored": False}, indent=2)) - return 0 - check(args.allow_radio_changes, "--allow-radio-changes is required before opening ports") - check(args.companion_port and args.stock_port and args.report, "ports and a fresh report are required") - check(os.path.realpath(args.companion_port) != os.path.realpath(args.stock_port), "ports must be distinct") - check(not args.report.exists() and args.report.parent.is_dir(), "use a new report in an existing directory") - result = run(args) - print(json.dumps({"result": result["result"], "report": str(args.report), - "failure": result.get("failure"), "settings_restored": False}, indent=2)) - return 0 if result["result"] == "MEASURED" else 1 - except delivery.Failure as error: - parser.error(str(error)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/hil/mota_usb_seeder.py b/tools/hil/mota_usb_seeder.py index 13e4438de..7c0089797 100644 --- a/tools/hil/mota_usb_seeder.py +++ b/tools/hil/mota_usb_seeder.py @@ -291,10 +291,9 @@ def main(argv=None): catalog, report["catalog"] = prepare_catalog(args.mota) save() from companion_raw_radio2_sweep import safe_identity - from meshtext_delivery import dependencies, require_idle_port + from companion_serial import Companion, require_idle_port report["usb"] = safe_identity(args.source_port, args.source_serial) require_idle_port(args.source_port) - Companion, _ = dependencies() node = Companion(args.source_port) safe_identity(args.source_port, args.source_serial) report["companion"] = node.start() diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index 37f1a18e9..9197c3f43 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -78,9 +78,9 @@ bool HeltecV4R8Board::isExternalPowered() { if (!power_voltage_sampled || uint32_t(now - last_power_check_ms) >= 5000) { last_power_check_ms = now; power_voltage_sampled = true; - // Like Meshtastic's analog power detection, enter above a full Li-ion cell - // plus 10 mV. Release at 4.20 V to reduce display-profile chatter. This can - // miss a charger while the battery is low; it is not a charging-status flag. + // Enter above a full Li-ion cell plus 10 mV. Release at 4.20 V to reduce + // display-profile chatter. This can miss a charger while the battery is + // low; it is not a charging-status flag. const uint16_t millivolts = getBattMilliVolts(); voltage_external_power = millivolts > (voltage_external_power ? 4200 : 4210); } diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index 86a6573f0..cad4f09c3 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -111,7 +111,7 @@ void TBeam1WBoard::onAfterTransmit() { uint16_t TBeam1WBoard::getBattMilliVolts() { // T-Beam 1W uses a 2S battery through the GPIO4 divider. These settings - // match the hardware-tested fork and the Meshtastic board definition. + // match the hardware-tested fork's board configuration. static bool adc_initialized = false; if (!adc_initialized) { pinMode(BATTERY_PIN, INPUT); diff --git a/variants/lilygo_tbeam_1w/variant.h b/variants/lilygo_tbeam_1w/variant.h index 682a4312d..ec1a4e27d 100644 --- a/variants/lilygo_tbeam_1w/variant.h +++ b/variants/lilygo_tbeam_1w/variant.h @@ -1,5 +1,5 @@ // LilyGo T-Beam-1W variant.h -// Configuration based on Meshtastic PR #8967 and LilyGO documentation +// Configuration follows the board support pin map and LilyGO documentation. #pragma once diff --git a/variants/meshtracker_x1/variant.h b/variants/meshtracker_x1/variant.h index 7f2b246b5..7f7c59641 100644 --- a/variants/meshtracker_x1/variant.h +++ b/variants/meshtracker_x1/variant.h @@ -26,7 +26,7 @@ #define EXT_CHRG_DETECT (35) // P1.3, LOW while charging // P1.4 is the charger's second status line, but it reads LOW regardless of -// charge state on this board, so it is not used (Meshtastic leaves it out too) +// charge state on this board, so it is not used. #define EXT_PWR_DETECT (5) // P0.5 #define ADC_RESOLUTION (14) @@ -136,4 +136,4 @@ #define PIN_QSPI_IO2 (23) // P0.23 #define PIN_QSPI_IO3 (32) // P1.00 -#define EXTERNAL_FLASH_DEVICES GD25Q64C \ No newline at end of file +#define EXTERNAL_FLASH_DEVICES GD25Q64C diff --git a/variants/muzi_base/variant.h b/variants/muzi_base/variant.h index b007c1f8b..e3e0b3256 100644 --- a/variants/muzi_base/variant.h +++ b/variants/muzi_base/variant.h @@ -26,12 +26,12 @@ #define ADC_MULTIPLIER 1.537 #define ADC_RESOLUTION 14 #define PIN_BATTERY_CHARGING (32+2) // P1.02 STAT2 -#define PIN_CHARGER_FAULT (27) // P0.27 STAT1 this pin is disabled on meshtastic. +#define PIN_CHARGER_FAULT (27) // P0.27 STAT1, unused by charge-state detection. // BQ25185 has 2 status pins: STAT1 and STAT2. Both are high when not charging. STAT1 high, STAT2 low: charging. Recoverable fault: STAT1 low, STAT2 high. Unrecoverable fault: both low. // We only need to detect charging vs not charging, but someone else can use the fault pin to log when the battery gets too hot or cold. // Power management boot protection threshold (millivolts) -#define PWRMGT_VOLTAGE_BOOTLOCK 3100 // Won't boot below this voltage (mV). BB15 battery min voltage is 3v, 3100mV is minimum batt in meshtastic code. +#define PWRMGT_VOLTAGE_BOOTLOCK 3100 // Won't boot below 3.1 V; BB15 battery minimum is 3.0 V. // LPCOMP wake configuration (voltage recovery from SYSTEMOFF) #define PWRMGT_LPCOMP_AIN 7 // AIN7 = P0.31 = PIN_VBAT_READ @@ -120,7 +120,6 @@ static const uint8_t AREF = (PIN_AREF); // not used // * Base Duo -> LR1121 (USE_LR1121) // Both share NSS/SCLK/MISO/MOSI/RESET/BUSY. The only pin difference is the // radio IRQ (DIO1): the SX1262 routes it to P1.06, the LR1121 to P1.08. -// (see Meshtastic muzi_base variant, which defines both radios on this board) #define LORA_NSS (PIN_SPI_NSS) // P1.12 #define LORA_RESET (32+10) // P1.10 @@ -157,7 +156,7 @@ static const uint8_t AREF = (PIN_AREF); // not used #define PIN_GPS_TX PIN_SERIAL1_TX #define PIN_GPS_EN (32+1) // P1.01 PWR_IO2 on schematic. gps power, driven by the sensor manager. -// superIO 3-position mode switch (pins from the meshtastic muzi_base variant). +// superIO 3-position mode switch (board support pin map). // "Mode 2" reads HIGH on PIN_GPS_SWITCH and turns the gps on. #define SWITCH_MODE1 (32+9) // P1.09 #define SWITCH_MODE2 (12) // P0.12 diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 6dde0f13c..b9cbdd003 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -44,8 +44,7 @@ public: bool hasUsbPowerDetect() const override { return true; } bool isUsbPowered() override { - // P0.13 is the dedicated USB / external power-detect line on the M6 PCB - // (same pin the Meshtastic M6 variant uses for USB detection). + // P0.13 is the dedicated USB / external power-detect line on the M6 PCB. return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE; } diff --git a/variants/thinknode_m6/variant.cpp b/variants/thinknode_m6/variant.cpp index f0fb7d43d..07325c077 100644 --- a/variants/thinknode_m6/variant.cpp +++ b/variants/thinknode_m6/variant.cpp @@ -38,6 +38,6 @@ void initVariant() { digitalWrite(PIN_GPS_EN, HIGH); // PIN_GPS_RESET (pin 29 / REINIT) is intentionally left floating (input). // Driving it HIGH holds the L76K silent, so it never streams NMEA and the - // firmware reports "no GPS". Letting it float lets the module run, matching - // the Meshtastic M6 variant. See GPS_RESET (-1) in variant.h. + // firmware reports "no GPS". Letting it float lets the module run. + // See GPS_RESET (-1) in variant.h. } diff --git a/variants/thinknode_m6/variant.h b/variants/thinknode_m6/variant.h index 3428fcd64..7753966e9 100644 --- a/variants/thinknode_m6/variant.h +++ b/variants/thinknode_m6/variant.h @@ -111,8 +111,8 @@ #define PIN_GPS_RESET (29) // REINIT - must FLOAT; driving it (esp. HIGH) silences the L76K // The M6's L76K streams NMEA on its own and must not have its REINIT pin driven. // Tell the location provider there is no reset pin so it never touches pin 29 -// (driving it HIGH holds the module silent). Matches Meshtastic, which leaves -// this pin as an input. See variant.cpp (pin 29 is intentionally not configured). +// (driving it HIGH holds the module silent). See variant.cpp, where pin 29 is +// intentionally left as an input. #define GPS_RESET (-1) #define PIN_GPS_STANDBY (30) // STANDBY #define PIN_GPS_PPS (31) diff --git a/variants/wio-tracker-l2/README.md b/variants/wio-tracker-l2/README.md index 657dabf4b..22fb1886c 100644 --- a/variants/wio-tracker-l2/README.md +++ b/variants/wio-tracker-l2/README.md @@ -63,6 +63,5 @@ PIN shown on the device screen. idle Bluetooth switches to slow advertising. - **Display orientation**: `offset_rotation=1` (landscape). If a unit shows the UI upside-down, use 3 in `WioTrackerL2Display.h`. -- Pin map source: `meshtastic/firmware` [PR #10909](https://github.com/meshtastic/firmware/pull/10909), - since merged as [`variants/esp32s3/seeed_wio_tracker_L2`](https://github.com/meshtastic/firmware/tree/master/variants/esp32s3/seeed_wio_tracker_L2), - and the `meshtastic/device-ui` [`wio-l2` branch](https://github.com/meshtastic/device-ui/tree/wio-l2). +- Pin map follows Seeed's Wio Tracker L2 firmware and display support work. + These pre-release assignments remain subject to hardware revision changes. diff --git a/variants/wio-tracker-l2/platformio.ini b/variants/wio-tracker-l2/platformio.ini index 81039322d..9954d6481 100644 --- a/variants/wio-tracker-l2/platformio.ini +++ b/variants/wio-tracker-l2/platformio.ini @@ -1,6 +1,5 @@ ; Seeed Wio Tracker L2 Pro (alpha) - Wio-S3 module: ESP32-S3R8 + SX1262 -; Pin map derived from Seeed's Meshtastic support branch (meshtastic/firmware -; PR #10909 + device-ui `wio-l2` branch). +; Pin map derived from Seeed's Wio Tracker L2 support work. [Wio_Tracker_L2] extends = esp32_base board = seeed_wio_tracker_l2