From 938f63748799ae73ba00408a657cf76e729c672d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 11:05:21 -0700 Subject: [PATCH] Share radio2 raw transport for Ethernet and negotiated direct LoRa OTA --- docs/cli_commands.md | 41 ++ docs/companion_protocol.md | 2 +- docs/kiss_modem_protocol.md | 41 ++ docs/ota_protocol.md | 67 +- docs/radio_profiles.md | 148 ++++- docs/terminal_chat_cli.md | 26 + examples/companion_radio/DataStore.cpp | 27 + .../companion_radio/EthernetCompanion.cpp | 240 ++++++++ .../companion_radio/MeshtextCompanion.cpp | 23 + examples/companion_radio/MyMesh.cpp | 113 +++- examples/companion_radio/MyMesh.h | 49 +- examples/companion_radio/NodePrefs.h | 26 +- examples/kiss_modem/KissModem.cpp | 57 +- examples/kiss_modem/KissModem.h | 11 +- examples/kiss_modem/main.cpp | 57 +- src/Dispatcher.cpp | 133 +++- src/Dispatcher.h | 30 +- src/Mesh.cpp | 88 ++- src/Mesh.h | 6 + src/RadioProfiles.h | 9 +- src/helpers/CompanionTrafficPrefs.h | 49 ++ src/helpers/CompanionTrafficRouting.h | 98 +++ src/helpers/EthernetKiss.h | 157 +++++ src/helpers/EthernetTunnelConfig.h | 22 + src/helpers/EthernetTunnelSettings.h | 62 ++ src/helpers/MeshtextConfig.h | 9 + src/helpers/RadioProfileCLI.cpp | 57 +- src/helpers/RadioProfileCLI.h | 3 +- src/helpers/esp32/EthernetKissServer.h | 148 +++++ src/helpers/ota/OtaCli.cpp | 5 +- src/helpers/ota/OtaDirectConfig.h | 21 + src/helpers/ota/OtaDirectWire.h | 51 ++ src/helpers/ota/OtaManager.cpp | 176 +++++- src/helpers/ota/OtaManager.h | 42 +- src/helpers/radiolib/CustomSX1262Wrapper.h | 24 +- src/helpers/radiolib/CustomSX1276Wrapper.h | 18 +- src/helpers/radiolib/HilTxFrequencyGuard.h | 41 ++ src/helpers/radiolib/RadioLibWrappers.cpp | 103 +++- src/helpers/radiolib/RadioLibWrappers.h | 11 +- .../companion_uncached_storage/test.cpp | 10 + test/fixtures/ethernet_kiss/Arduino.h | 5 + test/fixtures/ethernet_kiss/WiFi.h | 97 +++ test/fixtures/ethernet_kiss/lwip/sockets.h | 34 + .../ethernet_kiss/test_ethernet_kiss.cpp | 320 ++++++++++ test/fixtures/ota_cli_listing/test.cpp | 39 +- test/fixtures/radio_profiles/chirp_math.cpp | 25 +- test/fixtures/radio_profiles/cli_test.cpp | 10 +- .../test_shared_mota_queue.cpp | 3 + test/mocks/Mesh.h | 1 + test/test_companion_bluetooth_persistence.py | 12 +- test/test_companion_ethernet.py | 566 +++++++++++++++++ .../test_companion_preferences_transaction.py | 92 ++- test/test_companion_prefs_image.py | 31 +- test/test_companion_prefs_tail.py | 25 +- test/test_companion_prefs_values.py | 18 +- ...companion_settings_persistence_contract.py | 14 +- test/test_companion_traffic_routing.py | 419 +++++++++++++ test/test_companion_usb_logging.py | 10 +- test/test_ethernet_kiss.py | 80 +++ test/test_ethernet_radio.py | 334 ++++++++++ test/test_hil_tx_frequency_guard.py | 193 ++++++ test/test_kiss_boot_trace.py | 86 +++ test/test_kiss_ethernet_wire.py | 112 ++++ test/test_kiss_modem/test_tx_backpressure.cpp | 153 ++++- test/test_mota_kiss_direct.py | 396 ++++++++++++ test/test_nrf52_extrafs_contract.py | 58 +- test/test_ota/test_ota_core.cpp | 2 + test/test_ota/test_ota_direct_manager.inc | 467 ++++++++++++++ test/test_ota_cli_listing.py | 2 +- test/test_ota_direct_manager.py | 49 ++ test/test_ota_kiss_direct.py | 472 ++++++++++++++ test/test_ota_nrf52_display_cache.py | 4 +- test/test_peripheral_mode_prefs.py | 7 +- test/test_radio_profile_auto.py | 82 +++ test/test_radio_profile_scan.py | 15 +- tools/lora_ota/README.md | 207 +++++++ tools/lora_ota/mota_kiss_direct.py | 580 ++++++++++++++++++ 77 files changed, 7061 insertions(+), 260 deletions(-) create mode 100644 examples/companion_radio/EthernetCompanion.cpp create mode 100644 src/helpers/CompanionTrafficPrefs.h create mode 100644 src/helpers/CompanionTrafficRouting.h create mode 100644 src/helpers/EthernetKiss.h create mode 100644 src/helpers/EthernetTunnelConfig.h create mode 100644 src/helpers/EthernetTunnelSettings.h create mode 100644 src/helpers/esp32/EthernetKissServer.h create mode 100644 src/helpers/ota/OtaDirectConfig.h create mode 100644 src/helpers/ota/OtaDirectWire.h create mode 100644 src/helpers/radiolib/HilTxFrequencyGuard.h create mode 100644 test/fixtures/ethernet_kiss/Arduino.h create mode 100644 test/fixtures/ethernet_kiss/WiFi.h create mode 100644 test/fixtures/ethernet_kiss/lwip/sockets.h create mode 100644 test/fixtures/ethernet_kiss/test_ethernet_kiss.cpp create mode 100644 test/test_companion_ethernet.py create mode 100644 test/test_companion_traffic_routing.py create mode 100644 test/test_ethernet_kiss.py create mode 100644 test/test_ethernet_radio.py create mode 100644 test/test_hil_tx_frequency_guard.py create mode 100644 test/test_kiss_boot_trace.py create mode 100644 test/test_kiss_ethernet_wire.py create mode 100644 test/test_mota_kiss_direct.py create mode 100644 test/test_ota/test_ota_direct_manager.inc create mode 100644 test/test_ota_direct_manager.py create mode 100644 test/test_ota_kiss_direct.py create mode 100644 tools/lora_ota/README.md create mode 100644 tools/lora_ota/mota_kiss_direct.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 315eb7938..39d19ccbf 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -949,6 +949,47 @@ ACKs and addressed responses. See [Companion routing details and examples](radio_profiles.md#companion-tx-routing-by-contact-or-channel) for temporary profiles, persistence, and the traffic covered by each setting. +## Set Companion media and SSH application TX profiles + +```text +get tx.media +set tx.media auto|radio|radio2|both|off +get tx.ssh +set tx.ssh auto|radio|radio2|both|off +``` + +Choose one value after `set`. Both default to `auto` and are saved across +reboot. Explicit choices override a channel route for the matching application +data only; they do not change ordinary text, adverts or contact ACKs. +`radio2` requires an active `rxtx` MeshCore profile and never falls back to +the primary. `both` uses the available profiles; `off` blocks sending only. +Queries include the effective `active=` selection and warn if radio2 is +unavailable for TX. + +Media covers Open TAK's binary photo/voice and fetch packets. SSH requires +an application to opt into the new `0x5353` channel-data type or raw +`53 53 48 01` envelope; existing untagged SSH/IP tunnel apps are not detected +automatically. See the [application routing contract](radio_profiles.md#companion-media-and-ssh-tx-routing) +for the exact framing, precedence and limits. + +## ESP32 Full Companion Ethernet tunnel + +```text +get ethernet +get ethernet.radio +set ethernet.radio ,,,[,] +set ethernet on +set ethernet off +``` + +This separate `ethernet` mode carries the entire raw Ethernet link over +radio2, including SSH, while primary radio stays MeshCore. It defaults off, +saves its settings, and requires an explicitly configured supported RF tuple. +Supported ESP32 Full SX1262/SX1276 builds expose trusted-LAN KISS-over-TCP on +station port 5003. It does not add an SSH server, use `tx.ssh` tags, or bridge +Ethernet into MeshCore. Conflicting radio2 owners are not overwritten. +See [Ethernet setup, host commands and limits](radio_profiles.md#esp32-companion-ethernet-over-lora-kiss). + ## Set repeater, room-server, and sensor reply profiles ```text diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 71167202e..63dae7c1c 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -213,7 +213,7 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused. | `0x15` | `CMD_SET_TUNING_PARAMS` | Set tuning parameters. | | `0x16` | `CMD_DEVICE_QUERY` | Negotiate protocol support and read device information. | | `0x17` / `0x18` | `CMD_EXPORT_PRIVATE_KEY` / `CMD_IMPORT_PRIVATE_KEY` | Export or import identity key material when enabled. | -| `0x19` | `CMD_SEND_RAW_DATA` | Send an application raw-data packet. | +| `0x19` | `CMD_SEND_RAW_DATA` | Send an application raw-data packet. Known media and opt-in SSH envelopes honor [application TX routing](radio_profiles.md#companion-media-and-ssh-tx-routing); other raw bytes retain normal routing. | | `0x1A`-`0x1D` | `CMD_SEND_LOGIN` through `CMD_LOGOUT` | Manage a server connection. | | `0x1E` | `CMD_GET_CONTACT_BY_KEY` | Look up a contact by public-key prefix. | | `0x1F` / `0x20` | `CMD_GET_CHANNEL` / `CMD_SET_CHANNEL` | Read or write a channel slot. | diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index 52aba4da6..e5ea179b0 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -153,6 +153,8 @@ MeshCore-specific functionality uses the standard KISS SetHardware command. The | GetRadio2 | `0x1C` | - | | SetTempRadio2 | `0x1D` | Radio2 parameters + duration (15) | | GetTempRadio2 | `0x1E` | - | +| SetRadio2Ext | `0x1F` | Radio2 parameters + protocol (14) | +| GetRadio2Ext | `0x20` | - | ### Response Sub-commands (TNC to Host) @@ -184,6 +186,7 @@ Response codes use the high-bit convention: `response = command | 0x80`. Generic | SignalReport | `0x9A` | Status (1): 0x00=disabled, 0x01=enabled | | Radio2 | `0x9C` | Saved radio2 parameters (13) | | TempRadio2 | `0x9E` | Active temporary radio2 + remaining minutes (15) | +| Radio2Ext | `0xA0` | Saved radio2 parameters + protocol (14) | | OK | `0xF0` | - | | Error | `0xF1` | Error code (1) | | TxDone | `0xF8` | Result (1): 0x00=failed, 0x01=success | @@ -242,6 +245,44 @@ All values are little-endian. The mode values are `0=off`, `1=rx`, and frequency and bandwidth values sent by the host even though its radio driver uses floating-point MHz/kHz internally. +### Optional Extended Secondary Radio (SetRadio2Ext / Radio2Ext response) + +These additive commands retain protocol version 2 and all existing OpenHop +commands unchanged. `SetRadio2Ext` takes the exact 13-byte secondary tuple above +followed by one protocol byte: `0` = MeshCore (LoRa sync word `0x12`), or `2` = +raw Ethernet (sync word `0x16`). Other values, unavailable wire support, invalid +modem tuples and lengths other than 14 bytes are rejected without changing the +saved or active profile. `GetRadio2Ext` takes no data and returns the saved tuple +plus that protocol byte, exactly 14 bytes. + +Both modes use explicit-header, CRC-on, normal-IQ LoRa. Ethernet frames remain +raw Data on KISS port 1 (`0x10`); the modem adds no IP stack, ARP implementation +or WiFi server. Hosts provide Ethernet/IPv4/TCP and must respect the one-pending- +TX limit by waiting for `TxDone` before submitting another Data frame. The +standard KISS `TXDELAY` default is 500 ms; hosts may explicitly change it for a +low-delay link. Signal reports retain their existing two-byte format. + +The extended setting is RAM-only and has the same saved/temporary semantics as +`SetRadio2`: an active `SetTempRadio2` lease is not interrupted, and its expiry +or cancellation restores the saved tuple **including its protocol**. The legacy +13-byte `SetRadio2` and 15-byte `SetTempRadio2` always select MeshCore; the legacy +`GetRadio2` remains exactly 13 bytes and does not report the protocol. Hosts that +need Ethernet must first probe `GetRadio2Ext`; older firmware replies +`UnknownCmd`. Do not silently fall back to MeshCore when this probe fails. + +Builders can opt a compatible raw KISS modem into Ethernet wire support with +`MESH_ENABLE_ETHERNET_WIRE=1`, leaving `MESH_ENABLE_ETHERNET_TUNNEL=0`. The wire +flag defaults to the tunnel flag, so existing Companion builds are unchanged. +The radio chip must independently support the requested profile; for example, +SX1276 cannot use SF5 even when Ethernet wire support is compiled in. + +Bench images compiled with `MESH_HIL_TX_FREQ_MHZ` additionally support the +read-only SetHardware `GetLabTxGuard` (`0x21`, no request data). Its response +`0xA1` is the compiled allowed frequency as `u32` little-endian Hz; the standard +909.5 MHz bench lock returns `909500000`. It proves the same frequency boundary +used by the radio TX guard, not merely a requested setting. Production images +without this explicit build flag return `UnknownCmd`; no setter exists. + ### Temporary Secondary Radio (SetTempRadio2 / TempRadio2 response) `SetTempRadio2` is the binary KISS equivalent of `tempradio2`: the 13-byte diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 05d79ef2a..d685449aa 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -35,6 +35,7 @@ the authoritative reference for byte-level details. | Merkle tree + proofs | `MerkleTree.{h,cpp}` | | EndF self-identity | `FirmwareInfo.{h,cpp}` | | Wire message codec | `OtaProtocol.{h,cpp}` | +| Negotiated direct KISS framing / build policy | `OtaDirectWire.h`, `OtaDirectConfig.h` | | Session engine (serve+fetch+discovery) | `OtaManager.{h,cpp}` | | Multi-mota / folder relay | `OtaSource.h`, `MotaSourceSerial.{h,cpp}`, `MotaSeederProto.h` | | Staging stores | `OtaStore.h`, `OtaStoreFlashNrf52.*`, `OtaStoreFlashEsp32.*` | @@ -74,7 +75,7 @@ the authoritative reference for byte-level details. | `MFLAG_BOOTLOADER` | `0x04` | flags bit2; valid only in the exact v3 bootloader profile | | `CODEC_FULL` / `_SEQUENTIAL` / `_INPLACE` | `0` / `1` / `2` | Section 5 | | `PAYLOAD_TYPE_OTA` | `0x0C` | MeshCore packet type (`src/Packet.h`) | -| `MAX_PACKET_PAYLOAD` | `184` | usable bytes per packet (`src/MeshCore.h`) | +| `MAX_PACKET_PAYLOAD` | `184` | ordinary routed packet payload (`src/MeshCore.h`); direct KISS exception: Section 8.5.1 | | Default application block size | `2048` | `block_size_log2 = 0x0B`; deployed 1 KiB application packages remain accepted | | Bootloader-package block size | `1024` | fixed `block_size_log2 = 0x0A` for format 3 | | OTA discovery TX priority | `250` | background (`OTA_TX_PRIORITY`, `src/Mesh.h`) | @@ -675,10 +676,66 @@ OTA_LEAVES: manifest_id[4] frag_idx(1) frag_total(1) bytes[] # up A served mota supports up to 1024 leaves in the default 4 KiB proof scratch (about 2 MiB of payload at the new 2 KiB default, or 1 MiB for a deployed 1 KiB container); larger self-images pass a bigger scratch buffer. +#### 8.5.1 Negotiated zero-hop KISS/raw-radio transport + +`OtaDirectWire.h` defines an opt-in direct transport which bypasses the normal +184-byte `Packet` payload without changing `Packet` layout or enlarging its pool: + +```text +RF frame = F2 00 || ota_msg_type || existing OTA message body + 2 B <----------- at most 253 B -----------> +``` + +`F2` selects reserved MeshCore version 4, `PAYLOAD_TYPE_OTA`, and direct routing; +old MeshCore parsers reject that version. The second byte MUST be zero: no path, +transport codes, relay envelope, or Ethernet header is permitted. Maximum RF +length is **255 bytes**; v2 DATA retains its 9-byte OTA header and 4-byte stream ID, +leaving **240 data bytes**. Thus raw 1 KiB/2 KiB blocks need five/nine DATA frames. +Only transfer opcodes `0x04..0x0B` are legal; discovery `ADV/QUERY/HAVE` MUST retain +ordinary framing. V2 descriptor bits, compression permission and stream identity +are unchanged. The outer framing, not a new opcode or request-mask bit, selects +240-byte geometry. + +A receiver MUST first validate a manifest obtained through ordinary OTA framing. +It MAY probe direct mode only when `ota config hops` is zero, that manifest was +observed at zero relay hops, v2 is usable, and the original temporary-radio +physical session/profile generation remains valid. Direct responses MUST match +that fetch's manifest, transport geometry and physical session. If direct support +has not yet been confirmed, the first adaptive deadline without direct DATA MUST +invalidate that probe and retry ordinary **171-byte v2** framing on the original +session. After confirmation, a wholly empty later flight gets one admitted +same-mode sparse retry before that downgrade. Local queue rejection MUST NOT +consume the retry, and valid direct DATA resets its empty-flight evidence. The +existing 160-byte legacy fallback remains available afterward. Reassembly for uncommitted +blocks is reset when geometry changes; already proof-verified committed blocks +remain staged. Missing-fragment masks continue to drive recovery. + +Endpoints MUST consume or reject this reserved envelope before normal `Packet` +allocation, raw logs and mesh bridges. They MUST NOT relay it or reinterpret it +as routed OTA; malformed, nonzero-path and unsolicited traffic cannot create an +OTA context. A shared bounded raw-radio TX owner retains the selected profile +generation and the OTA cancellation epoch until actual TX completion. It MUST +honor carrier sensing, duty/airtime accounting and adaptive pacing, and discard +pending work after cancellation, profile replacement or temporary-session expiry. +Direct response trains permit the proof immediately after the final completed +DATA frame; adaptive sub-1.0 quiet gaps still apply. KISS queue acceptance is not +RF completion, and actual `F8 TX_DONE` is not receiver delivery acknowledgement. + +Manifest signatures, Merkle proofs, encoded-stream checks, staged bytes and +explicit install authorization are unchanged. This is a transport optimization, +not a new trust or installation path. `MESH_ENABLE_OTA_KISS_DIRECT` defaults on +for `ENABLE_OTA` builds using the shared raw-radio scheduler, except primary +ESP-NOW builds; `-DMESH_ENABLE_OTA_KISS_DIRECT=0` explicitly disables it. Enabling +the build feature alone does not bypass the runtime negotiation/session gates. +The separate host seeder's safe probe/serve examples and lab TX lock are in +[`tools/lora_ota/README.md`](../tools/lora_ota/README.md). + ### 8.6 Temporary-radio and transfer boundary OTA packets may cross normal mesh relay hops, but each participating node processes or relays them only while -its `tempradio` window is actually running. A receiver selects missing blocks in serial order into a bounded +a matching `tempradio` or `tempradio2` window is actually running. Direct transfers remain bound to their +original profile and generation; ending or replacing that window does not silently move them to the other +radio. A receiver selects missing blocks in serial order into a bounded request flight. Every session starts with one block. A clean completed flight increases the next request by one block; a flight requiring fragment/proof recovery halves the next width (`4 -> 2`, `3 -> 2`, `2 -> 1`). The default compiled cap is two blocks; the RAK3401 LoRa-OTA target caps at four, so it probes @@ -887,8 +944,10 @@ deployment runner temporarily uses `0.3` on managed relays. **What to plug into `--serial`.** Use the USB serial console of an OTA-enabled MeshCore node built with `OTA_FOLDER_SERIAL`. The node must have a working LoRa radio plus an `ota folder on` command; that command confirms it can host and advertise the folder. A **KISS modem will not -work**: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI and `mota-seeder` -request/response transport. An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative +work with `motatool serve --serial`**: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI +and `mota-seeder` request/response transport. The separate +[`mota_kiss_direct.py` source](../tools/lora_ota/README.md) implements that full KISS interface. +An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative source connection: use its dedicated seeder port with `motatool serve --tcp :5001`. An nRF52 Full Companion can instead pair with a phone or Linux host, subscribe to its mOTA request characteristic, and use protocol-v14 diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 3f78d1abc..ef7df7c6a 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -241,6 +241,144 @@ its choice. They use reserved bytes in existing saved records, so record sizes and the phone protocol are unchanged. Older firmware ignores these bytes and may reset the choices when it saves the records. +## Companion media and SSH TX routing + +Companions also save independent application-data routes: + +```text +get tx.media +set tx.media radio2 +get tx.ssh +set tx.ssh radio +``` + +Both accept `auto|radio|radio2|both|off` and default to `auto`. An explicit +application route overrides the channel's TX choice for that datagram only; +`auto` preserves the channel/global behavior. `radio2` never falls back to +`radio`, and must be an enabled `rxtx` **MeshCore** profile. `both` uses the +profiles available for MeshCore transmission. `off` blocks transmission, not +reception. Getters show the saved choice and current `active=` mask; `auto` +shows `active=channel/global`, because the effective route depends on the +destination channel (or global rules for raw data). Settings +survive reboot; a failed save returns an error and keeps the previous choice. +Already queued packets retain their original route. + +`tx.media` recognizes Open TAK photo/voice channel data type `0x5643`, raw +clip packets `0x56`/`0x57`, and well-formed binary fetch requests `0x72`. +Text announcements (`VE3:`, `VM1:`, `VI1:`), text fetch requests (`VR3:`), +ordinary chat, contact ACKs, adverts, OTA, and unrelated raw data keep their +normal routing. The format is defined in +[Open TAK's voice/media wire implementation](https://github.com/jade-on-mesh/meshcore-open-tak/blob/main/lib/vox/voice_wire.dart). + +`tx.ssh` uses a **new opt-in application contract**, not automatic inspection +of encrypted SSH or arbitrary IP tunnels. A tunnel client must use either: + +- `CMD_SEND_CHANNEL_DATA` with data type `0x5353` (wire bytes `53 53`), or +- `CMD_SEND_RAW_DATA` with the four-byte prefix `53 53 48 01` (`SSH`, version 1), + followed by at least one application byte, on **every** tunnel packet. + +The firmware does not add/remove the prefix, decrypt the tunnel, or run an +SSH server. Both endpoints must understand this framing. Untagged existing +tunnel apps remain unchanged and are **not** affected by `tx.ssh` until they +adopt the contract. Other channel data types do not acquire an SSH route +merely because their contents start with `SSH`. Raw packets still require +a direct path, including zero-hop; this does not enable raw flooding. + +For a media-only side channel, configure `radio2`/`tempradio2` in `rxtx`, +set `radio2.cross off`, then set `tx.media radio2`. Ordinary text can stay on +the primary profile without routing every raw application onto radio2. + +## ESP32 Companion Ethernet over LoRa (KISS) + +ESP32 **Full Companion** builds with a supported SX1262/SX1276 modem can +carry raw Ethernet on radio2 while the primary profile stays MeshCore. This +separate feature is named `ethernet`, not `tx.ssh`: it transports the +**whole Ethernet link**, including ARP, TCP acknowledgments, IP fragments +and SSH on any port. There is no Ethernet-to-MeshCore forwarding. + +The mode is off by default. It requires an explicit RF tuple; it never +chooses a frequency or starts WiFi automatically. For a dedicated lab link: + +```text +set ethernet.radio 909.5,500,5,5,512 +set ethernet on +get ethernet +get ethernet.radio +get radio2.timing +``` + +Use an appropriate frequency and permitted power for your network. Both +ends must match frequency, bandwidth, SF, coding rate and sync word. +Ethernet uses sync word `0x16`, explicit headers, normal IQ and a LoRa CRC. +The optional preamble defaults to 32 symbols; it can be set explicitly +(8–65528, subject to driver/timing limits). SX1276 requires SF7 or higher. + +Do not assume 32 symbols is enough for a fast SF5/BW500 link. Run +`get radio2.timing` after its switching self-test completes, and accommodate +the slower end of the link. The lab example uses a 32.8 ms preamble and was +tested on a Heltec V4/V3 pair, not an I/O-expander-controlled board. Both +primary and secondary preambles matter if +primary MeshCore traffic must also be heard while time-sharing. Longer +preambles consume airtime; they do not prevent half-duplex collisions. + +On that V4/V3 pair, the actual MeshCore-open SSH backend verified the remote +host key, authenticated with a public key, opened a PTY and received exactly +4096 test bytes with a matching SHA-256 check. Radio1 stayed on the USA +receive profile (`910.525,62.5,7,5`); its TX counter remained zero on both +boards. Raw Ethernet still had losses, so this demonstrates TCP/SSH recovery, +not loss-free RF or a minimum preamble guaranteed for every board. The bench +used an explicit test-image TX-frequency lock; it did not transmit test +packets on the USA profile. + +The settings are saved in a checked atomic NVS blob. `set ethernet off` +closes the host connection and releases only this feature's runtime radio2 +lease; it does not rewrite the saved MeshCore profiles. A manually configured +radio2 must already match the Ethernet RF tuple, including explicit preamble, +and be `rxtx`; its protocol is borrowed only while Ethernet is enabled. +An explicit saved `radio2 off`, a temporary/scheduled profile, or conflicting +`r2-`/`mt-` channels prevents automatic takeover. Those channel aliases cannot +be added while Ethernet is enabled. Clear conflicts or disable Ethernet first. +Only one secondary profile can be active: Meshtext and Ethernet cannot share +radio2 at the same time. + +### Attach a Linux Ethernet interface + +Connect the Companion to station WiFi. Its **station IP** exposes a single +KISS-over-TCP listener on port **5003**, separate from Companion binary TCP +5000, OTA TCP 5001 and the existing text terminal on TCP 5002. +Use [tncattach](https://github.com/markqvist/tncattach) on the host: + +```sh +sudo tncattach -T -H 192.168.1.100 -P 5003 --ethernet --mtu 230 --noipv6 --ipv4 10.10.10.10/24 +``` + +Replace the station IP. Attach the other end with a different IP in the +same subnet, then use normal host tools such as ping or SSH. A peer running +[MeshTNC's Ethernet-over-LoRa mode](https://github.com/datapartyjs/MeshTNC#ethernet-over-lora) +uses the same raw Ethernet framing; configure its PHY to match this link. +No custom `SSH` prefix or MeshCore packet wrapper is added. + +**Enable only on a trusted LAN.** KISS has no authentication and permits raw +RF transmission. The service binds to the station address, not the setup AP; +do not expose port 5003 to the Internet. The ESP32 does not run an SSH server: +SSH connects the attached host computers. + +The host MTU must fit one RF frame: MTU 230 plus the 14-byte Ethernet header +is 244 bytes. Ethernet frames of 14–255 bytes are supported; larger frames +are rejected, never truncated. There is no firmware fragmentation protocol, +compression, retransmission or autonomous Ethernet forwarding. Queues are +bounded and host TCP is backpressured. Host disconnects, WiFi/IP changes, +disabling and profile changes discard stale queued/partial host traffic; +an RF frame already transmitting cannot be unsent. + +One transceiver time-shares reception between the two profiles. Short +preambles on an unmodified peer can be missed while listening to MeshCore; +TCP handles losses but performance is not equivalent to two physical radios. +Host/codec and firmware-build tests do not establish an RF interoperability +or throughput guarantee. nRF52 and non-Full Companion builds do not acquire +this Ethernet service. `tx.media` and tagged `tx.ssh` remain independent +MeshCore application policies; neither controls this raw Ethernet link. + ## Companion channel-name routing aliases Companions recognize the case-sensitive prefixes `r2-` and `mt-`, immediately @@ -567,10 +705,10 @@ it automatically. Explicit values are used as entered, subject to the radio's limits and safe airtime arithmetic. Automatic dual-profile preambles round **up to a multiple of eight**, with a -32-symbol minimum and a 128-symbol maximum. When a second profile activates, +32-symbol minimum and a 512-symbol maximum. When a second profile activates, the radio first self-tests four successful RX-to-RX switches in each direction using the actual tuples. It defers the test while receiving, transmitting or -busy, and never counts a failed switch. The 128-symbol automatic preamble is +busy, and never counts a failed switch. The 512-symbol automatic preamble is provisional until the test completes. The largest successful hop, rounded up with a **10% guard**, then supplies the per-switch allowance for both the automatic preambles and the scan dwell. There is no fixed switch-time floor. @@ -669,8 +807,8 @@ the resulting preamble. `off` disables the dual-profile warning. `get radio2.status` shows self-test progress and the resulting guarded switch budget; its separate `max` field remains a lifetime diagnostic that can include failed/rolled-back retunes and is not used for calibration. If the -recommendation exceeds 128, it additionally reports -`WARN need >128`; an automatic preamble stays at 128, which may not be enough +recommendation exceeds 512, it additionally reports +`WARN need >512`; an automatic preamble stays at 512, which may not be enough for reliable reception on that pair. Explicit preambles are not rewritten and still use the radio's ordinary validation limits. A timing readout does not change settings or transmit a packet. @@ -712,7 +850,7 @@ switch allowance above. Swapping `radio` and `radio2` swaps the allocations auto Production scheduling and automatic preambles use the **self-tested maximum successful switch plus 10%**, without an additional fixed loop allowance. Subsequent successful hops can only raise that allowance until the next profile change. -The measured recommendation is rounded up to eight symbols and capped at 128 +The measured recommendation is rounded up to eight symbols and capped at 512 for automatic settings. The calculator and warnings retain the *uncapped* recommendation so a slow board is not shown as safe merely because it hit the limit. Older V4 tests peaked at 836 us (827 us in the 4.6/0.3 ms run), and diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 26537bfe6..f707ff5c4 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -125,6 +125,32 @@ a channel index, or `key:` for a contact. See the [routing guide](radio_profiles.md#companion-tx-routing-by-contact-or-channel) for which requests/replies are covered and how to reset a choice. +## Companion media and SSH TX profiles + +```text +get tx.media +set tx.media radio2 +get tx.ssh +set tx.ssh radio +``` + +Each setting accepts `auto`, `radio`, `radio2`, `both`, or `off`, defaults to +`auto`, and is saved across reboot. `tx.media` handles Open TAK binary media; +`tx.ssh` requires a tunnel app using the explicit SSH application tag, not +an existing untagged SSH connection. See the +[application routing guide](radio_profiles.md#companion-media-and-ssh-tx-routing) +for the framing and routing rules. Ordinary text keeps its existing route. + +## ESP32 Full Companion Ethernet + +Use `set ethernet.radio ,,,[,]`, then +`set ethernet on`. `get ethernet` reports state; `set ethernet off` closes +the KISS service and releases its radio2 lease. Settings survive reboot. +This is a separate whole-link mode, not the tagged `tx.ssh` application route. +Normal USB/BLE/Companion TCP stay in Companion mode. The host instead uses +KISS-over-TCP port 5003 on trusted station WiFi, with MTU 230. +See [Ethernet setup and radio2 restrictions](radio_profiles.md#esp32-companion-ethernet-over-lora-kiss). + ## Companion USB mode Updated 1.17.1.6 USB Companion builds, including ordinary and Full builds, start diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 41e96f783..803bd843c 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1116,6 +1116,8 @@ bool DataStore::loadPrefsInt(const char *filename, loaded_prefs.usb_debug_enabled = 0; // Old images cannot opt in using an unrelated runtime preference value. loaded_prefs.lost_reply = 0; + loaded_prefs.tx_media = mesh::RADIO_TX_AUTO; + loaded_prefs.tx_ssh = mesh::RADIO_TX_AUTO; #if defined(RAK_4631) loaded_prefs.peripherals_shared = 0; #endif @@ -1201,6 +1203,16 @@ bool DataStore::loadPrefsInt(const char *filename, 333, #else 236, +#endif +#ifdef TBEAM_1W + 243 + mesh::companion::traffic::FooterSize, +#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) + 333 + mesh::companion::traffic::FooterSize, +#elif defined(RAK_4631) + 236 + mesh::peripherals::FooterSize + + mesh::companion::traffic::FooterSize, +#else + 236 + mesh::companion::traffic::FooterSize, #endif }; const uint32_t prefs_size = file.size(); @@ -1378,6 +1390,16 @@ bool DataStore::loadPrefsInt(const char *filename, #endif #endif + // The compact STM32 table and the explicit readers both end at the + // unchanged historical prefix. Consume the common footer only afterward. + if (file.available() != 0) { + uint8_t traffic_footer[mesh::companion::traffic::FooterSize] = {}; + success = success + && file.read(traffic_footer, sizeof(traffic_footer)) == sizeof(traffic_footer) + && mesh::companion::traffic::decodeFooter(traffic_footer, + sizeof(traffic_footer), loaded_prefs.tx_media, loaded_prefs.tx_ssh); + } + // Any bytes left over form only part of a historically appended field. // Preserve the file and defaults rather than treating that tail as EOF. success = success && file.available() == 0; @@ -1582,6 +1604,11 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou && file.write(image, sizeof(image)) == sizeof(image); #endif #endif + uint8_t traffic_footer[mesh::companion::traffic::FooterSize]; + mesh::companion::traffic::encodeFooter(traffic_footer, + _prefs.tx_media, _prefs.tx_ssh); + success = success + && file.write(traffic_footer, sizeof(traffic_footer)) == sizeof(traffic_footer); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); if (!success) MESH_DEBUG_PRINTLN("DataStore: atomic preferences write failed"); diff --git a/examples/companion_radio/EthernetCompanion.cpp b/examples/companion_radio/EthernetCompanion.cpp new file mode 100644 index 000000000..39f1aaae2 --- /dev/null +++ b/examples/companion_radio/EthernetCompanion.cpp @@ -0,0 +1,240 @@ +#include "MyMesh.h" + +#if MESH_ENABLE_ETHERNET_TUNNEL +#include +#include +#include + +void MyMesh::beginEthernetTunnel() { + _ethernet_settings = {}; + _ethernet_settings_valid = false; + Preferences prefs; + if (!prefs.begin("mesh-eth", false)) { _ethernet_status = "settings unreadable"; return; } + const size_t length = prefs.getBytesLength("settings"); + if (!length) _ethernet_settings_valid = true; + else { + uint8_t image[mesh::ethernet::SettingsSize]; + _ethernet_settings_valid = length == sizeof(image) + && prefs.getBytes("settings", image, sizeof(image)) == sizeof(image) + && mesh::ethernet::decodeSettings(image, sizeof(image), _ethernet_settings); + } + prefs.end(); + _ethernet_status = _ethernet_settings_valid ? "off" : "invalid settings; set ethernet.radio to repair"; +} + +bool MyMesh::ethernetChannelConflict() { + if (_ethernet_checked_channels == _ethernet_channel_generation) return _ethernet_channels_conflict; + _ethernet_checked_channels = _ethernet_channel_generation; + _ethernet_channels_conflict = false; + for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { + ChannelDetails channel; + if (!BaseChatMesh::getChannel(i, channel) || !channel.name[0]) continue; + if (parseCompanionChannelAlias(channel.name).kind != CompanionChannelKind::MeshCore) { + _ethernet_channels_conflict = true; + break; + } + } + return _ethernet_channels_conflict; +} + +void MyMesh::releaseEthernetProfile() { + _ethernet_active = false; + _ethernet_kiss.loop(false); + discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Ethernet); + const auto* profiles = _radio->profiles(); + if (_ethernet_owned && profiles + && profiles->secondary.params.protocol == mesh::RadioProfileProtocol::Ethernet) { + _radio_profiles.clearAutoSecondary(); + _radio_profiles.clearSecondaryProtocol(); + } + _ethernet_owned = false; + _ethernet_generation = 0; +} + +bool MyMesh::saveEthernetSettings(const mesh::ethernet::Settings& settings) { + if (hasForeignRadioFrame() || !mesh::ethernet::validSettings(settings)) return false; + uint8_t image[mesh::ethernet::SettingsSize], verify[mesh::ethernet::SettingsSize]; + mesh::ethernet::encodeSettings(image, settings); + Preferences prefs; + if (!prefs.begin("mesh-eth", false)) return false; + const bool ok = prefs.putBytes("settings", image, sizeof(image)) == sizeof(image) + && prefs.getBytes("settings", verify, sizeof(verify)) == sizeof(verify) + && !memcmp(image, verify, sizeof(image)); + prefs.end(); + releaseEthernetProfile(); + if (!ok) { + // The write may have committed even if verification failed. Suspend + // instead of using a different network now than the next boot will load. + _ethernet_settings_valid = false; + _ethernet_status = "settings save failed; suspended"; + return false; + } + _ethernet_settings = settings; + _ethernet_settings_valid = true; + if (settings.enabled && !ethernetChannelConflict()) { + // Removing the last MT channel can leave its RAM lease until the next + // loop. Release that unused owner only after this explicit enable saves. + const auto* profiles = _radio->profiles(); + if (profiles && profiles->secondary.params.protocol == mesh::RadioProfileProtocol::Meshtext) { + _radio_profiles.clearAutoSecondary(); + _radio_profiles.clearSecondaryProtocol(); + } + } + _ethernet_status = settings.enabled ? "waiting for radio2" : "off"; + return true; +} + +bool MyMesh::eraseEthernetSettings() { + releaseEthernetProfile(); + _ethernet_settings_valid = false; + _ethernet_settings.enabled = false; + Preferences prefs; + if (!prefs.begin("mesh-eth", false)) return false; + const bool ok = !prefs.getBytesLength("settings") || prefs.remove("settings"); + prefs.end(); + if (ok) { _ethernet_settings = {}; _ethernet_settings_valid = true; } + _ethernet_status = ok ? "off" : "settings erase failed"; + return ok; +} + +bool MyMesh::ethernetRadioConflict(const mesh::ethernet::Settings& settings) { + if (ethernetChannelConflict() || _radio_profiles.secondaryTemporary()) return true; + if (!_radio_profiles.manualSecondaryConfigured()) return !_radio_profiles.canAutoSecondary(); + auto saved = _radio_profiles.savedSecondaryConfig(); + saved.params.protocol = mesh::RadioProfileProtocol::Ethernet; + return saved.mode != mesh::RadioProfileMode::RxTx || saved.params != settings.radio; +} + +bool MyMesh::handleEthernetTunnelCommand(const char* command, char* reply, size_t capacity) { + const bool status = !strcmp(command, "get ethernet") || !strcmp(command, "get ethernet.status"); + const bool get_radio = !strcmp(command, "get ethernet.radio"); + const bool set = !strncmp(command, "set ethernet", 12) + && (command[12] == 0 || command[12] == ' ' || command[12] == '.'); + if (!status && !get_radio && !set) return false; + if (status) { + const auto& server = _ethernet_kiss.status(); + snprintf(reply, capacity, "> ethernet=%s; %s; TCP %u %s; RX=%lu TX=%lu fail=%lu; radio2 only", + _ethernet_settings.enabled ? "on" : "off", _ethernet_status, + mesh::ethernet::KissServer::Port, server.connected ? "connected" : server.listening ? "listening" : "stopped", + (unsigned long)_ethernet_received, (unsigned long)_ethernet_sent, (unsigned long)_ethernet_failed); + return true; + } + if (get_radio) { + const auto& p = _ethernet_settings.radio; + if (!_ethernet_settings.configured) snprintf(reply, capacity, "> unset; set ethernet.radio ,,,[,]"); + else snprintf(reply, capacity, "> %.3f,%g,%u,%u,%u; sync=0x16; %s", + p.freq, p.bw, p.sf, p.cr, p.preamble, _ethernet_settings_valid ? "saved" : "invalid settings"); + return true; + } + auto settings = _ethernet_settings_valid ? _ethernet_settings : mesh::ethernet::Settings{}; + if (!strncmp(command, "set ethernet.radio ", 19)) { + char tuple[96]; uint16_t preamble; + if (!mesh::RadioProfileCLI::parseSuffix(command + 19, 4, tuple, sizeof(tuple), preamble) + || !mesh::cli::parseRadioTupleStrict(tuple, settings.radio.freq, settings.radio.bw, + settings.radio.sf, settings.radio.cr)) { + snprintf(reply, capacity, "Error: use set ethernet.radio ,,,[,]"); return true; + } + settings.radio.preamble = preamble ? preamble : 32; + settings.configured = true; + if (!mesh::ethernet::validSettings(settings) + || !_radio->supportsEthernetTunnel() || !_radio->validateProfile(settings.radio)) { + snprintf(reply, capacity, "Error: Ethernet modem/tuple unsupported"); return true; + } + // Editing a running tunnel must not silently evict someone else's lease. + // Disable the tunnel before changing to a conflicting RF tuple. + if (settings.enabled && ethernetRadioConflict(settings)) { + snprintf(reply, capacity, "Error: radio2 reserved/conflicting; set ethernet off first"); return true; + } + } else if (!strcmp(command, "set ethernet on")) { + if (!settings.configured || !settings.radio.freq) { + snprintf(reply, capacity, "Error: set ethernet.radio first; no automatic frequency"); return true; + } + if (!_radio->supportsEthernetTunnel() || ethernetRadioConflict(settings)) { + snprintf(reply, capacity, "Error: radio2 reserved/conflicting; remove r2-/mt- channels or match saved radio2"); return true; + } + settings.enabled = true; + } else if (!strcmp(command, "set ethernet off")) settings.enabled = false; + else { snprintf(reply, capacity, "Error: use set ethernet on|off or set ethernet.radio ,,,[,]"); return true; } + if (!saveEthernetSettings(settings)) snprintf(reply, capacity, "Error: Ethernet settings not saved (pending TX/storage failure)"); + else snprintf(reply, capacity, "OK - ethernet %s saved; radio2 only; trusted-LAN KISS TCP %u", settings.enabled ? "on" : "off", mesh::ethernet::KissServer::Port); + return true; +} + +void MyMesh::loopEthernetTunnel() { + const auto* current = _radio->profiles(); + const bool lease_unchanged = _ethernet_active && _ethernet_owned && current + && current->generation[1] == _ethernet_generation + && _radio_profiles.secondaryIntentGeneration() == _ethernet_intent_generation + && current->secondary.mode == mesh::RadioProfileMode::RxTx + && current->secondary.params == _ethernet_settings.radio + && _radio_profiles.secondaryRuntimeAvailable(); + const char* blocked = nullptr; + if (!_ethernet_settings_valid) blocked = "invalid settings"; + else if (!_ethernet_settings.enabled) blocked = "off"; + else if (!_ethernet_settings.configured) blocked = "radio unset"; + else if (!_radio_available || !_radio->supportsEthernetTunnel()) blocked = "modem unavailable"; + else if (ethernetChannelConflict()) blocked = "conflicting r2-/mt- channel"; + else if (_radio_profiles.secondaryTemporary()) blocked = "temporary radio2 owns modem"; + // Validation/timing preview and profile publication happen only on an + // ownership/configuration edge, never on every RF polling pass. + if (!blocked && !lease_unchanged) { + if (_radio_profiles.manualSecondaryConfigured()) { + auto saved = _radio_profiles.savedSecondaryConfig(); + saved.params.protocol = mesh::RadioProfileProtocol::Ethernet; + if (saved.mode != mesh::RadioProfileMode::RxTx || saved.params != _ethernet_settings.radio + || !_radio_profiles.useSecondaryProtocol(mesh::RadioProfileProtocol::Ethernet)) blocked = "saved radio2 conflicts"; + } else { + mesh::RadioProfileConfig config; + config.mode = mesh::RadioProfileMode::RxTx; config.params = _ethernet_settings.radio; + if (!_radio_profiles.requestAutoSecondary(config)) blocked = "radio2 reserved"; + } + } + if (blocked) { + if (_ethernet_active || _ethernet_owned) releaseEthernetProfile(); + else _ethernet_kiss.loop(false); + _ethernet_status = blocked; + return; + } + const auto* profiles = _radio->profiles(); + if (!profiles || !profiles->enabled() || !profiles->canTransmit(1) + || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Ethernet) { + releaseEthernetProfile(); _ethernet_status = "waiting for radio2"; return; + } + if (_ethernet_generation != profiles->generation[1]) _ethernet_kiss.resetPending(); + _ethernet_generation = profiles->generation[1]; + _ethernet_intent_generation = _radio_profiles.secondaryIntentGeneration(); + _ethernet_owned = _ethernet_active = true; + const auto before = _ethernet_kiss.status().accepted + _ethernet_kiss.status().disconnects; + _ethernet_kiss.loop(true); + const auto after = _ethernet_kiss.status().accepted + _ethernet_kiss.status().disconnects; + if (before != after) discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Ethernet); + _ethernet_status = _ethernet_kiss.status().listening ? "active (RX time-shared)" : "waiting for station WiFi"; + const uint8_t* bytes; size_t length; + if (!hasForeignRadioFrame() && _ethernet_kiss.peekTransmit(bytes, length) + && queueForeignRadioFrame(bytes, length, 1, profiles->generation[1], mesh::RadioProfileProtocol::Ethernet)) + _ethernet_kiss.consumeTransmit(); +} + +bool MyMesh::onEthernetFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation) { + const auto* profiles = _radio->profiles(); + if (!_ethernet_active || !profiles || profile != 1 || generation != profiles->generation[1] + || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Ethernet) return false; + if (!_ethernet_kiss.queueReceive(bytes, length)) return false; + ++_ethernet_received; + return true; +} + +#if !MESH_ENABLE_MESHTEXT_CHAT +bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, + uint32_t generation, float snr, float rssi) { + (void)snr; (void)rssi; + return onEthernetFrame(bytes, length, profile, generation); +} + +void MyMesh::onForeignRadioSendComplete(bool success) { + if (queuedForeignRadioProtocol() != mesh::RadioProfileProtocol::Ethernet) return; + if (success) ++_ethernet_sent; + else ++_ethernet_failed; +} +#endif +#endif diff --git a/examples/companion_radio/MeshtextCompanion.cpp b/examples/companion_radio/MeshtextCompanion.cpp index 771249a98..f13d91294 100644 --- a/examples/companion_radio/MeshtextCompanion.cpp +++ b/examples/companion_radio/MeshtextCompanion.cpp @@ -286,6 +286,16 @@ bool MyMesh::validateMeshtextChannelChange(int index, const ChannelDetails& cand } void MyMesh::refreshMeshtextProfile() { +#if MESH_ENABLE_ETHERNET_TUNNEL + // This owner must never revoke an Ethernet lease simply because it has + // no Meshtext channels. Explicit channel conflicts suspend Ethernet too. + if (_ethernet_settings.enabled) { + _mt_active = false; + _mt_status = "radio2 reserved for Ethernet"; + _mt_profile_dirty = true; + return; + } +#endif const auto* current_profiles = _radio->profiles(); const uint32_t primary_generation = current_profiles ? current_profiles->generation[0] : 0; const uint32_t secondary_generation = current_profiles ? current_profiles->generation[1] : 0; @@ -424,6 +434,12 @@ bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) { bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation, float snr, float rssi) { +#if MESH_ENABLE_ETHERNET_TUNNEL + const auto* receiving_profiles = _radio->profiles(); + if (receiving_profiles && profile < 2 + && receiving_profiles->params(profile).protocol == mesh::RadioProfileProtocol::Ethernet) + return onEthernetFrame(bytes, length, profile, generation); +#endif (void)rssi; const auto* profiles = _radio->profiles(); if (!_mt_active || !profiles || profile != 1 || generation != profiles->generation[1]) return false; @@ -454,6 +470,13 @@ bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t pr } void MyMesh::onForeignRadioSendComplete(bool success) { +#if MESH_ENABLE_ETHERNET_TUNNEL + if (queuedForeignRadioProtocol() == mesh::RadioProfileProtocol::Ethernet) { + if (success) ++_ethernet_sent; + else ++_ethernet_failed; + return; + } +#endif if (success) ++_mt_sent; else ++_mt_failed; } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0f8b0fb93..07f459b04 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2,6 +2,9 @@ #include #include #include +#if defined(MESH_HIL_TX_FREQ_MHZ) +#include +#endif #if defined(RAK_4631) #include #endif @@ -9,6 +12,7 @@ #include "NotificationSettingsFile.h" #include "MyMesh.h" #include +#include #include #include #include "CompanionBluetooth.h" @@ -1713,7 +1717,15 @@ bool MyMesh::getChannel(int channel_idx, ChannelDetails& channel) { bool MyMesh::setChannel(int channel_idx, const ChannelDetails& channel) { if (parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Invalid) return false; - return BaseChatMesh::setChannel(channel_idx, channel); +#if MESH_ENABLE_ETHERNET_TUNNEL + if (_ethernet_settings.enabled && channel.name[0] + && parseCompanionChannelAlias(channel.name).kind != CompanionChannelKind::MeshCore) return false; +#endif + const bool saved = BaseChatMesh::setChannel(channel_idx, channel); +#if MESH_ENABLE_ETHERNET_TUNNEL + if (saved) ++_ethernet_channel_generation; +#endif + return saved; } int MyMesh::searchChannelsForPacket(const mesh::Packet* packet, const uint8_t* hash, @@ -3164,6 +3176,9 @@ void MyMesh::begin(bool has_display, bool radio_available, #if MESH_ENABLE_TX_BEACONS beginBeacons(); #endif +#if MESH_ENABLE_ETHERNET_TUNNEL + beginEthernetTunnel(); +#endif #if MESH_ENABLE_MESHTEXT_CHAT beginMeshtext(); #endif @@ -3566,6 +3581,58 @@ bool MyMesh::handleCadCommand(const char* command, char* reply, return true; } +bool MyMesh::handleTrafficRoutingCommand(const char* command, char* reply, size_t reply_size) { + if (!reply || !reply_size) return false; + const auto parsed = mesh::companion::parseTrafficRouteCommand(command); + if (!parsed.matched) return false; + const char* kind = parsed.kind == mesh::companion::TrafficKind::Media ? "media" : "ssh"; + if (!parsed.valid) { + snprintf(reply, reply_size, "Error: use get tx.%s or set tx.%s auto|radio|radio2|both|off", kind, kind); + return true; + } + uint8_t& policy = parsed.kind == mesh::companion::TrafficKind::Media ? _prefs.tx_media : _prefs.tx_ssh; + if (parsed.set) { + const uint8_t previous = policy; + policy = parsed.policy; + // Even an unchanged value must have durable storage before we say saved. + if (!savePrefs()) { + policy = previous; + snprintf(reply, reply_size, "Error: tx.%s was not saved", kind); + return true; + } + } + const uint8_t mask = mesh::companion::trafficTransmitMask(policy, _radio->profiles()); + snprintf(reply, reply_size, "%s tx.%s %s; active=%s%s", parsed.set ? "OK -" : ">", kind, + mesh::radioTxPolicyName(policy), policy == mesh::RADIO_TX_AUTO ? "channel/global" + : mask ? mesh::radioTxPolicyName(mask) : "off", + (policy == mesh::RADIO_TX_SECONDARY || policy == mesh::RADIO_TX_BOTH) && !(mask & 2) + ? " (radio2 unavailable for TX)" : ""); + return true; +} + +bool MyMesh::sendClientGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len, + uint16_t data_type, const uint8_t* data, int data_len) { + if (data_len < 0 || data_len > MAX_GROUP_DATA_LENGTH || (data_len && !data)) return false; + const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::groupTrafficKind(data_type), + _prefs.tx_media, _prefs.tx_ssh); + if (policy == mesh::RADIO_TX_AUTO) return sendGroupData(channel, path, path_len, data_type, data, data_len); + // Override only this datagram, not the saved channel, PSK, alias or text route. + mesh::GroupChannel routed = channel; + routed.tx_radio = policy; + return sendGroupData(routed, path, path_len, data_type, data, data_len); +} + +bool MyMesh::sendClientRawData(const uint8_t* data, size_t data_len, uint8_t* path, uint8_t path_len) { + if (!data || data_len < 4) return false; + auto pkt = createRawData(data, data_len); + if (!pkt) return false; + const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::rawTrafficKind(data, data_len), + _prefs.tx_media, _prefs.tx_ssh); + if (policy != mesh::RADIO_TX_AUTO) pkt->tx_radio = policy; + // sendDirect owns/releases the packet on either success or failure. + return sendDirect(pkt, path, path_len); +} + bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t reply_size) { const auto parsed = mesh::companion::parseTxRouteCommand(command); if (!parsed.matched) return false; @@ -3672,6 +3739,9 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, size_t reply_size) { if (!command || !reply || reply_size == 0) return false; while (*command == ' ') command++; +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (mesh::handleHilTxFrequencyCommand(command, reply, reply_size)) return true; +#endif #if defined(RAK_4631) // This helper is also reached only after normal remote-admin admission. if (mesh::peripherals::handleCommand(_prefs.peripherals_shared, command, @@ -3693,6 +3763,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } +#if MESH_ENABLE_ETHERNET_TUNNEL + if (handleEthernetTunnelCommand(command, reply, reply_size)) return true; +#endif + if (handleTrafficRoutingCommand(command, reply, reply_size)) return true; if (handleTxRoutingCommand(command, reply, reply_size)) return true; #if MESH_ENABLE_TX_BEACONS if (handleBeaconCommand(command, reply, reply_size)) return true; @@ -6069,7 +6143,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (payload_len > MAX_GROUP_DATA_LENGTH) { MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { + } else if (sendClientGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { writeOKFrame(); } else { writeErrFrame(ERR_CODE_TABLE_FULL); @@ -6580,18 +6654,17 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) { int i = 1; uint8_t path_len = cmd_frame[i++]; - if (path_len >= 0 && mesh::Packet::isValidPathLen(path_len)) { + if (mesh::Packet::isValidPathLen(path_len)) { uint8_t path[MAX_PATH_SIZE]; - i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); - if (i + 4 > len) { // min payload 4 bytes + const size_t path_bytes = static_cast(path_len & 63) * ((path_len >> 6) + 1); + if (static_cast(i) + path_bytes + 4 > len) { // min payload 4 bytes, before reading path writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - auto pkt = createRawData(&cmd_frame[i], len - i); - if (pkt) { - sendDirect(pkt, path, path_len); + i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); + if (sendClientRawData(&cmd_frame[i], len - i, path, path_len)) { writeOKFrame(); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); + writeErrFrame(ERR_CODE_BAD_STATE); } } } else { @@ -10086,6 +10159,13 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, #endif #if COMPANION_FEATURE_READER terminalOutput().print(" get reader [:[-]] [page] (World English Bible, offline)\r\n"); +#endif + terminalOutput().print(" get/set tx.media auto|radio|radio2|both|off (binary media)\r\n" + " get/set tx.ssh auto|radio|radio2|both|off (tagged tunnel data)\r\n"); +#if MESH_ENABLE_ETHERNET_TUNNEL + terminalOutput().print(" get ethernet / get ethernet.radio\r\n" + " set ethernet.radio ,,,[,]\r\n" + " set ethernet on|off (saved; trusted-LAN KISS TCP 5003, radio2 only)\r\n"); #endif terminalOutput().print(" get pwrmgt.bootreason\r\n"); #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS @@ -10354,6 +10434,12 @@ bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_ stopContactsIterator(); resetContacts(); mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, dirty_contacts_failures); +#if MESH_ENABLE_ETHERNET_TUNNEL + if (!eraseEthernetSettings()) { + snprintf(reply, reply_size, "File system erased; Error: Ethernet settings erase failed"); + return true; + } +#endif #if MESH_ENABLE_MESHTEXT_CHAT if (!eraseMeshtextSettings()) { snprintf(reply, reply_size, "File system erased; Error: MT settings erase failed"); @@ -10916,6 +11002,12 @@ complete_line: suspendBeacons(); #endif bool success = _store->formatFileSystem(); +#if MESH_ENABLE_ETHERNET_TUNNEL + if (success && !eraseEthernetSettings()) { + output.println(" Error: file system erased, but Ethernet settings erase failed"); + return; + } +#endif #if MESH_ENABLE_MESHTEXT_CHAT if (success && !eraseMeshtextSettings()) { output.println(" Error: file system erased, but MT settings erase failed"); @@ -11178,6 +11270,9 @@ void MyMesh::loop() { #if MESH_ENABLE_MESHTEXT_CHAT refreshMeshtextProfile(); #endif +#if MESH_ENABLE_ETHERNET_TUNNEL + if (!mesh::morseTimingActive(_radio)) loopEthernetTunnel(); +#endif #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection(); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b5978edfa..d92e3c2f8 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -29,6 +29,10 @@ #include #include #include +#if MESH_ENABLE_ETHERNET_TUNNEL +#include +#include +#endif namespace mesh { class BeaconManager; } #if MESH_ENABLE_MESHTEXT_CHAT #include @@ -334,6 +338,10 @@ public: void beginUsbLoggingReplyBarrier(BaseSerialInterface* route); void endUsbLoggingReplyBarrier(bool reply_queued); bool handleTxRoutingCommand(const char* command, char* reply, size_t reply_size); + bool handleTrafficRoutingCommand(const char* command, char* reply, size_t reply_size); + bool sendClientGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len, + uint16_t data_type, const uint8_t* data, int data_len); + bool sendClientRawData(const uint8_t* data, size_t data_len, uint8_t* path, uint8_t path_len); int getRecentlyHeard(AdvertPath dest[], int max_num); @@ -375,6 +383,41 @@ protected: const mesh::GroupChannel& channel, uint8_t* data, size_t len) override; bool isAliasRadio2Defined() const; bool resolveChannelAlias(ChannelDetails& channel) const; +#if MESH_ENABLE_ETHERNET_TUNNEL + mesh::ethernet::Settings _ethernet_settings; + mesh::ethernet::KissServer _ethernet_kiss; + bool _ethernet_settings_valid = true, _ethernet_active = false, _ethernet_owned = false; + uint32_t _ethernet_generation = 0; + uint32_t _ethernet_intent_generation = 0, _ethernet_channel_generation = 0; + uint32_t _ethernet_checked_channels = UINT32_MAX; + bool _ethernet_channels_conflict = false; + uint32_t _ethernet_received = 0, _ethernet_sent = 0, _ethernet_failed = 0; + const char* _ethernet_status = "off"; + void beginEthernetTunnel(); + bool ethernetChannelConflict(); + bool ethernetRadioConflict(const mesh::ethernet::Settings& settings); + void releaseEthernetProfile(); + bool saveEthernetSettings(const mesh::ethernet::Settings& settings); + bool eraseEthernetSettings(); + bool handleEthernetTunnelCommand(const char* command, char* reply, size_t capacity); + void loopEthernetTunnel(); + bool onEthernetFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation); +#endif +#if MESH_ENABLE_FOREIGN_RADIO + bool onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, + uint32_t generation, float snr, float rssi) override; + void onForeignRadioSendComplete(bool success) override; + bool allowForeignRadioTransmit() const override { + return _radio_available && _scheduled_reboot_at == 0 && ( +#if MESH_ENABLE_MESHTEXT_CHAT + _mt_active || +#endif +#if MESH_ENABLE_ETHERNET_TUNNEL + _ethernet_active || +#endif + false); + } +#endif #if MESH_ENABLE_MESHTEXT_CHAT void beginMeshtext(); void refreshMeshtextIdentity(); @@ -387,12 +430,6 @@ protected: bool eraseMeshtextSettings(); bool sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length); bool sendMeshtextNodeInfo(const ChannelDetails& channel); - bool onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, - uint32_t generation, float snr, float rssi) override; - void onForeignRadioSendComplete(bool success) override; - bool allowForeignRadioTransmit() const override { - return _radio_available && _scheduled_reboot_at == 0 && _mt_active; - } mesh::mt::Region meshtextRegion() const; #endif bool queueChannelText(uint8_t index, const uint8_t secret[32], float snr, diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 659589990..9b5a655ab 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -10,6 +10,7 @@ #include #include #include +#include #include "BluetoothName.h" @@ -121,6 +122,9 @@ public: // A checked footer follows every existing on-flash field, never an insertion. uint8_t peripherals_shared = 0; // applied at boot, not on a live CLI save #endif + // Checked footer after all previously published fields/board footers. + uint8_t tx_media = mesh::RADIO_TX_AUTO; + uint8_t tx_ssh = mesh::RADIO_TX_AUTO; private: class RadioPrefs : public CommonRadioPrefs { @@ -259,31 +263,23 @@ public: // untouched, and leave a normally constructed snapshot's adapters local. bool copyPersistedValuesFrom(const CompanionNodePrefs& source) { if (&source == this) return true; -#if defined(RAK_4631) - const auto& last_value = peripherals_shared; -#else - const auto& last_value = lost_reply; -#endif - static_assert(sizeof(lost_reply) == sizeof(uint8_t), - "The published lost reply preference is one byte"); + const auto& last_value = tx_ssh; + static_assert(sizeof(tx_ssh) == sizeof(uint8_t), + "The last persisted traffic policy is one byte"); const uintptr_t first = reinterpret_cast(&airtime_factor); const uintptr_t last = reinterpret_cast(&last_value); if (last < first - || last - first > sizeof(*this) - sizeof(lost_reply)) { + || last - first > sizeof(*this) - sizeof(tx_ssh)) { return false; } const size_t size = static_cast(last - first) - + sizeof(lost_reply); + + sizeof(tx_ssh); const auto bounded = [size](const CompanionNodePrefs& values) -> bool { const uintptr_t object = reinterpret_cast(&values); const uintptr_t begin = reinterpret_cast(&values.airtime_factor); const uintptr_t end = reinterpret_cast( -#if defined(RAK_4631) - &values.peripherals_shared -#else - &values.lost_reply -#endif + &values.tx_ssh ); const uintptr_t radio_begin = reinterpret_cast(&values.radio); const uintptr_t custom_begin = reinterpret_cast(&values.custom); @@ -292,7 +288,7 @@ public: return begin >= object && begin - object <= sizeof(values) && size <= sizeof(values) - static_cast(begin - object) && end >= begin - && end - begin == size - sizeof(values.lost_reply) + && end - begin == size - sizeof(values.tx_ssh) && radio_begin >= begin && size <= radio_begin - begin && custom_begin >= begin && size <= custom_begin - begin; }; diff --git a/examples/kiss_modem/KissModem.cpp b/examples/kiss_modem/KissModem.cpp index 9436284cb..b4ba13374 100644 --- a/examples/kiss_modem/KissModem.cpp +++ b/examples/kiss_modem/KissModem.cpp @@ -460,6 +460,29 @@ void KissModem::handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint case HW_CMD_GET_TEMPRADIO2: handleGetTempRadio2(); break; + case HW_CMD_SET_RADIO2_EXT: + handleSetRadio2(data, len, true); + break; + case HW_CMD_GET_RADIO2_EXT: + if (len != 0) writeHardwareError(HW_ERR_INVALID_LENGTH); + else handleGetRadio2(true); + break; +#if defined(MESH_HIL_TX_FREQ_MHZ) + case HW_CMD_GET_LAB_TX_GUARD: { + if (len != 0) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + break; + } + // This same compile-time boundary guards startSendRaw(). Never infer + // bench safety from a host-requested profile or a generic version byte. + static_assert(MESH_HIL_TX_FREQ_MHZ >= 150 && MESH_HIL_TX_FREQ_MHZ <= 2500, + "HIL TX frequency must be an explicit valid MHz value"); + uint8_t hz[4]; + writeU32LE(hz, frequencyToHz(static_cast(MESH_HIL_TX_FREQ_MHZ))); + writeHardwareFrame(HW_RESP(HW_CMD_GET_LAB_TX_GUARD), hz, sizeof(hz)); + break; + } +#endif default: writeHardwareError(HW_ERR_UNKNOWN_CMD); break; @@ -821,8 +844,8 @@ void KissModem::handleGetRadio() { writeHardwareFrame(HW_RESP(HW_CMD_GET_RADIO), buf, sizeof(buf)); } -void KissModem::handleSetRadio2(const uint8_t* data, uint16_t len) { - if (len != KISS_RADIO2_PARAMS_SIZE) { +void KissModem::handleSetRadio2(const uint8_t* data, uint16_t len, bool extended) { + if (len != (extended ? KISS_RADIO2_EXT_PARAMS_SIZE : KISS_RADIO2_PARAMS_SIZE)) { writeHardwareError(HW_ERR_INVALID_LENGTH); return; } @@ -833,7 +856,27 @@ void KissModem::handleSetRadio2(const uint8_t* data, uint16_t len) { } Radio2Config config; - if (!decodeRadio2Config(data, config) || !validateRadio2Config(config)) { + if (!decodeRadio2Config(data, config)) { + writeHardwareError(HW_ERR_INVALID_PARAM); + return; + } + if (extended) { + const uint8_t protocol = data[KISS_RADIO2_PARAMS_SIZE]; + if (protocol != (uint8_t)mesh::RadioProfileProtocol::MeshCore + && protocol != (uint8_t)mesh::RadioProfileProtocol::Ethernet) { + writeHardwareError(HW_ERR_INVALID_PARAM); + return; + } + config.profile.params.protocol = (mesh::RadioProfileProtocol)protocol; + // Reject unavailable wire support even for an off/zero tuple. The host + // must never mistake an accepted setting for a supported modem format. + if (config.profile.params.protocol == mesh::RadioProfileProtocol::Ethernet + && !_radio.supportsEthernetTunnel()) { + writeHardwareError(HW_ERR_INVALID_PARAM); + return; + } + } + if (!validateRadio2Config(config)) { writeHardwareError(HW_ERR_INVALID_PARAM); return; } @@ -845,14 +888,16 @@ void KissModem::handleSetRadio2(const uint8_t* data, uint16_t len) { writeHardwareFrame(HW_RESP_OK, nullptr, 0); } -void KissModem::handleGetRadio2() { +void KissModem::handleGetRadio2(bool extended) { if (!_radio.profiles()) { writeHardwareError(HW_ERR_NO_CALLBACK); return; } - uint8_t buf[KISS_RADIO2_PARAMS_SIZE]; + uint8_t buf[KISS_RADIO2_EXT_PARAMS_SIZE]; encodeRadio2Config(_saved_radio2, buf); - writeHardwareFrame(HW_RESP(HW_CMD_GET_RADIO2), buf, sizeof(buf)); + if (extended) buf[KISS_RADIO2_PARAMS_SIZE] = (uint8_t)_saved_radio2.profile.params.protocol; + writeHardwareFrame(HW_RESP(extended ? HW_CMD_GET_RADIO2_EXT : HW_CMD_GET_RADIO2), buf, + extended ? KISS_RADIO2_EXT_PARAMS_SIZE : KISS_RADIO2_PARAMS_SIZE); } void KissModem::handleSetTempRadio2(const uint8_t* data, uint16_t len) { diff --git a/examples/kiss_modem/KissModem.h b/examples/kiss_modem/KissModem.h index 9537c0ebc..b6c7f8318 100644 --- a/examples/kiss_modem/KissModem.h +++ b/examples/kiss_modem/KissModem.h @@ -69,6 +69,9 @@ #define HW_CMD_GET_RADIO2 0x1C #define HW_CMD_SET_TEMPRADIO2 0x1D #define HW_CMD_GET_TEMPRADIO2 0x1E +#define HW_CMD_SET_RADIO2_EXT 0x1F +#define HW_CMD_GET_RADIO2_EXT 0x20 +#define HW_CMD_GET_LAB_TX_GUARD 0x21 /* Response code = command code | 0x80. Generic / unsolicited use 0xF0+. */ #define HW_RESP(cmd) ((cmd) | 0x80) @@ -91,10 +94,12 @@ #define KISS_RADIO_PARAMS_SIZE 10 #define KISS_RADIO2_PARAMS_SIZE 13 +#define KISS_RADIO2_EXT_PARAMS_SIZE 14 #define KISS_TEMPRADIO2_PARAMS_SIZE 15 #define KISS_MAX_TEMPRADIO2_MINUTES 10080 -/* Version 2 adds the logical secondary-radio KISS port and radio2 commands. */ +/* Version 2 adds the logical secondary-radio KISS port and radio2 commands. + * Extended radio2 commands are additive; old firmware returns UnknownCmd. */ #define KISS_FIRMWARE_VERSION 2 typedef void (*SetRadioCallback)(float freq, float bw, uint8_t sf, uint8_t cr); @@ -215,8 +220,8 @@ class KissModem { void handleSetRadio(const uint8_t* data, uint16_t len); void handleSetTxPower(const uint8_t* data, uint16_t len); void handleGetRadio(); - void handleSetRadio2(const uint8_t* data, uint16_t len); - void handleGetRadio2(); + void handleSetRadio2(const uint8_t* data, uint16_t len, bool extended = false); + void handleGetRadio2(bool extended = false); void handleSetTempRadio2(const uint8_t* data, uint16_t len); void handleGetTempRadio2(); void handleGetTxPower(); diff --git a/examples/kiss_modem/main.cpp b/examples/kiss_modem/main.cpp index d965bf8d5..2ca12fc01 100644 --- a/examples/kiss_modem/main.cpp +++ b/examples/kiss_modem/main.cpp @@ -29,6 +29,18 @@ #define NOISE_FLOOR_CALIB_INTERVAL_MS 2000 #define AGC_RESET_INTERVAL_MS 30000 +// Lab-only boot diagnostics must never contaminate a native-USB or dedicated +// UART KISS stream. Ordinary builds compile all stage calls away. +#if defined(ESP32) && defined(MESH_HIL_KISS_BOOT_TRACE) && MESH_HIL_KISS_BOOT_TRACE \ + && (!defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT) \ + && !(defined(KISS_UART_RX) && defined(KISS_UART_TX)) + #define KISS_UART_BOOT_TRACE 1 + #define KISS_BOOT_STAGE(stage) do { Serial.print("KISS_BOOT "); Serial.println(stage); } while (0) +#else + #define KISS_UART_BOOT_TRACE 0 + #define KISS_BOOT_STAGE(stage) ((void)0) +#endif + #if MESH_ESP32_HWCDC_SESSION_GUARD && !(defined(KISS_UART_RX) && defined(KISS_UART_TX)) #define KISS_HWCDC_TRANSPORT 1 #else @@ -72,7 +84,6 @@ void loadOrCreateIdentity() { InternalFS.begin(); IdentityStore store(InternalFS, ""); #elif defined(ESP32) - SPIFFS.begin(true); IdentityStore store(SPIFFS, "/identity"); #elif defined(RP2040_PLATFORM) LittleFS.begin(); @@ -98,21 +109,28 @@ void loadOrCreateIdentity() { } } #else + KISS_BOOT_STAGE("identity.load.before"); const IdentityLoadResult identity_load = store.loadResult("_main", identity); + KISS_BOOT_STAGE("identity.load.after"); #endif const bool needs_identity = identity_load == IdentityLoadResult::Missing || (identity_load == IdentityLoadResult::Loaded && mesh::hasReservedIdentityPrefix(identity)); bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { + KISS_BOOT_STAGE("identity.generate.before"); identity_ready = mesh::generateUsableLocalIdentity(identity, radio_new_identity); + KISS_BOOT_STAGE("identity.generate.after"); + KISS_BOOT_STAGE("identity.save.before"); if (identity_ready) identity_ready = store.saveWithRetry("_main", identity); + KISS_BOOT_STAGE("identity.save.after"); } #if defined(ESP32_PLATFORM) mesh::discardESP32TrueRandom(); #endif if (!identity_ready) { + KISS_BOOT_STAGE("identity.failed"); MESH_DEBUG_PRINTLN("Identity generation or persistence failed after retries; rebooting"); board.reboot(); halt(); // Never let setup continue if a platform's reboot returns. @@ -144,13 +162,35 @@ void onGetStats(uint32_t* rx, uint32_t* tx, uint32_t* errors) { } void setup() { +#if KISS_UART_BOOT_TRACE + Serial.begin(115200); + KISS_BOOT_STAGE("setup.enter"); +#endif #if KISS_HWCDC_TRANSPORT // KISS owns the entire serial byte stream, including gaps between chunks. mesh::setUsbLoggingEnabled(false); mesh::setUsbDebugEnabled(false); #endif + KISS_BOOT_STAGE("board.before"); board.begin(); + KISS_BOOT_STAGE("board.after"); +#if defined(ESP32) + // Mount (and, on a virgin store, format) before radio interrupts start, + // matching the Companion startup order. Never generate a replacement + // identity when primary storage has failed to become available. + KISS_BOOT_STAGE("filesystem.before"); + const bool filesystem_ready = SPIFFS.begin(true); + KISS_BOOT_STAGE("filesystem.after"); + if (!filesystem_ready) { + KISS_BOOT_STAGE("filesystem.failed"); + MESH_DEBUG_PRINTLN("SPIFFS initialization failed; rebooting"); + board.reboot(); + halt(); + } +#endif + + KISS_BOOT_STAGE("radio.before"); int radioinit_attempts = 0; while (!radio_init()) { ++radioinit_attempts; @@ -161,14 +201,24 @@ void setup() { } delay(500); } + KISS_BOOT_STAGE("radio.after"); + KISS_BOOT_STAGE("driver.before"); radio_driver.begin(); + KISS_BOOT_STAGE("driver.after"); + KISS_BOOT_STAGE("rng.before"); rng.begin(radio_driver.getRngSeed()); + KISS_BOOT_STAGE("rng.after"); + KISS_BOOT_STAGE("identity.before"); loadOrCreateIdentity(); + KISS_BOOT_STAGE("identity.after"); + KISS_BOOT_STAGE("sensors.before"); sensors.begin(); + KISS_BOOT_STAGE("sensors.after"); + KISS_BOOT_STAGE("serial.before"); #if defined(KISS_UART_RX) && defined(KISS_UART_TX) #if defined(ESP32) Serial1.setPins(KISS_UART_RX, KISS_UART_TX); @@ -207,13 +257,18 @@ void setup() { #endif #endif + KISS_BOOT_STAGE("serial.after"); modem->setRadioCallback(onSetRadio); modem->setTxPowerCallback(onSetTxPower); modem->setGetCurrentRssiCallback(onGetCurrentRssi); modem->setGetStatsCallback(onGetStats); + KISS_BOOT_STAGE("modem.before"); modem->begin(); + KISS_BOOT_STAGE("modem.after"); + KISS_BOOT_STAGE("boot_complete.before"); board.onBootComplete(); + KISS_BOOT_STAGE("setup.complete"); } void loop() { diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 5c5288864..c5d5b8177 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -1,5 +1,8 @@ #include "Dispatcher.h" #include "helpers/ota/OtaTiming.h" +#if MESH_ENABLE_OTA_KISS_DIRECT +#include "helpers/ota/OtaDirectWire.h" +#endif #if MESH_PACKET_LOGGING #include @@ -51,7 +54,7 @@ void Dispatcher::setRadioAvailable(bool available) { if (!available) cancelBeaconRadioFrame(); #endif radio_available = available; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO if (!available && hasForeignRadioFrame()) { if (foreign_radio_sending) _radio->onSendFinished(); finishForeignRadioFrame(false, foreign_radio_sending); @@ -83,7 +86,7 @@ void Dispatcher::begin() { cancelBeaconRadioFrame(); if (hasBeaconRadioFrame()) return; // restoration owns the peripheral #endif -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO if (hasForeignRadioFrame()) { if (foreign_radio_sending && radio_available) _radio->onSendFinished(); finishForeignRadioFrame(false, foreign_radio_sending); @@ -279,32 +282,84 @@ void Dispatcher::failOutboundTransmit() { outbound_cancellation = OutboundCancellation::None; } +#if MESH_ENABLE_FOREIGN_RADIO +bool Dispatcher::supportsForeignRadioProtocol(RadioProfileProtocol protocol) const { + switch (protocol) { #if MESH_ENABLE_MESHTEXT_CHAT + case RadioProfileProtocol::Meshtext: return _radio->supportsMeshtext(); +#endif +#if MESH_ENABLE_ETHERNET_TUNNEL + case RadioProfileProtocol::Ethernet: return _radio->supportsEthernetTunnel(); +#endif + default: return false; + } +} + bool Dispatcher::isForeignRadioCurrent() const { const auto* profiles = _radio->profiles(); - return radio_available && _radio->supportsMeshtext() && profiles +#if MESH_ENABLE_OTA_KISS_DIRECT + if (foreign_radio_protocol == RadioProfileProtocol::MeshCore) { + return radio_available && profiles && foreign_radio_profile < 2 + && profiles->canTransmit(foreign_radio_profile) + && profiles->params(foreign_radio_profile).protocol == RadioProfileProtocol::MeshCore + && foreign_radio_generation == profiles->generation[foreign_radio_profile] + && foreign_radio_ota_epoch != 0 && foreign_radio_ota_epoch == directOtaEpoch() + && allowDirectOtaTransmit(foreign_radio_profile, foreign_radio_generation); + } +#endif + return radio_available && supportsForeignRadioProtocol(foreign_radio_protocol) && profiles && allowForeignRadioTransmit() && foreign_radio_profile == 1 && profiles->enabled() && profiles->canTransmit(foreign_radio_profile) - && profiles->params(foreign_radio_profile).protocol == RadioProfileProtocol::Meshtext + && profiles->params(foreign_radio_profile).protocol == foreign_radio_protocol && foreign_radio_generation == profiles->generation[foreign_radio_profile]; } +#if MESH_ENABLE_OTA_KISS_DIRECT +bool Dispatcher::queueDirectOtaRadioFrame(const uint8_t* message, uint16_t length, + uint8_t profile, uint32_t generation) { +#if MESH_ENABLE_TX_BEACONS + if (hasBeaconRadioFrame()) return false; +#endif + const auto* profiles = _radio->profiles(); + const uint32_t epoch = directOtaEpoch(); + if (hasForeignRadioFrame() || !radio_available || _radio->isCarrierWaveActive() + || !epoch + || !profiles || profile >= 2 || !profiles->canTransmit(profile) + || profiles->params(profile).protocol != RadioProfileProtocol::MeshCore + || generation != profiles->generation[profile] + || !allowDirectOtaTransmit(profile, generation)) return false; + const uint16_t encoded = ota::ota_direct_wire_encode( + foreign_radio_bytes, sizeof(foreign_radio_bytes), message, length); + if (!encoded) return false; + foreign_radio_length = encoded; + foreign_radio_profile = profile; + foreign_radio_protocol = RadioProfileProtocol::MeshCore; + foreign_radio_generation = generation; + foreign_radio_ota_epoch = epoch; + foreign_radio_retry_at = foreign_radio_prepare_deadline = 0; + foreign_radio_sending = false; + return true; +} +#endif + bool Dispatcher::queueForeignRadioFrame(const uint8_t* bytes, size_t length, - uint8_t profile, uint32_t generation) { + uint8_t profile, uint32_t generation, + RadioProfileProtocol protocol) { #if MESH_ENABLE_TX_BEACONS if (hasBeaconRadioFrame()) return false; #endif const auto* profiles = _radio->profiles(); if (!bytes || !length || length > sizeof(foreign_radio_bytes) || hasForeignRadioFrame() - || !radio_available || !_radio->supportsMeshtext() || _radio->isCarrierWaveActive() + || !radio_available || !supportsForeignRadioProtocol(protocol) || _radio->isCarrierWaveActive() || !allowForeignRadioTransmit() || !profiles || profile != 1 || !profiles->enabled() || !profiles->canTransmit(profile) - || profiles->params(profile).protocol != RadioProfileProtocol::Meshtext + || profiles->params(profile).protocol != protocol || generation != profiles->generation[profile]) return false; memcpy(foreign_radio_bytes, bytes, length); foreign_radio_length = (uint16_t)length; foreign_radio_profile = profile; + foreign_radio_protocol = protocol; foreign_radio_generation = generation; foreign_radio_retry_at = foreign_radio_prepare_deadline = 0; foreign_radio_sending = false; @@ -313,9 +368,14 @@ bool Dispatcher::queueForeignRadioFrame(const uint8_t* bytes, size_t length, void Dispatcher::finishForeignRadioFrame(bool success, bool account_airtime) { if (!hasForeignRadioFrame()) return; + const bool direct_ota = foreign_radio_protocol == RadioProfileProtocol::MeshCore; if (account_airtime) { const uint32_t elapsed = (uint32_t)_ms->getMillis() - foreign_radio_start; total_air_time += elapsed; + if (direct_ota) { + ota_tx_finished_at = _ms->getMillis(); + ota_tx_airtime = elapsed; + } updateTxBudget(); tx_budget_ms = elapsed >= tx_budget_ms ? 0 : tx_budget_ms - elapsed; if (tx_budget_ms < MIN_TX_BUDGET_RESERVE_MS) { @@ -328,11 +388,24 @@ void Dispatcher::finishForeignRadioFrame(bool success, bool account_airtime) { // explicit user message. No retry is scheduled after any failure/timeout. foreign_radio_length = 0; foreign_radio_sending = false; +#if MESH_ENABLE_OTA_KISS_DIRECT + foreign_radio_ota_epoch = 0; +#endif foreign_radio_yield_meshcore = true; profile_cad_busy[foreign_radio_profile] = 0; foreign_radio_retry_at = foreign_radio_prepare_deadline = 0; radio_nonrx_start = _ms->getMillis(); - onForeignRadioSendComplete(success); + // A direct OTA packet is not a Meshtext/Ethernet send, and must not change + // either transport's application counters or trigger its callbacks. + if (direct_ota) { + if (success) ++n_sent_direct; + } else onForeignRadioSendComplete(success); +} + +bool Dispatcher::discardQueuedForeignRadioFrame(RadioProfileProtocol protocol) { + if (!hasForeignRadioFrame() || foreign_radio_sending || foreign_radio_protocol != protocol) return false; + finishForeignRadioFrame(false, false); + return true; } bool Dispatcher::serviceForeignRadioFrame() { @@ -365,6 +438,11 @@ bool Dispatcher::checkForeignRadioSend() { } if (!restoreOutboundTxOverrides() || _radio->isCarrierWaveActive()) return false; const uint32_t now = _ms->getMillis(); + if (foreign_radio_protocol == RadioProfileProtocol::MeshCore && ota_tx_airtime) { + const uint32_t quiet = ota::packetQuietTime(ota_tx_airtime, getOtaPacketSpeedFactor()); + if (now - ota_tx_finished_at < quiet) return false; + ota_tx_airtime = 0; + } // Continuous explicit foreign chat must leave due MeshCore traffic a turn // between messages; a blocked/absent MeshCore queue does not stall MT. if (foreign_radio_yield_meshcore && _mgr->getOutboundCount(now) > 0) { @@ -472,7 +550,7 @@ void Dispatcher::logPacketLine(const char* direction, const Packet* packet, bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { if (isDualRadioActive()) { delay_millis = 0; return true; } -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO if (hasForeignRadioFrame()) { delay_millis = 0; return true; } #endif const uint32_t now = _ms->getMillis(); @@ -576,7 +654,7 @@ void Dispatcher::loop() { // A maintenance carrier has no TxDone packet. Keep its deadline serviced, // but leave queued packets and RX/AGC watchdogs alone until normal RX returns. if (_radio->isCarrierWaveActive()) return; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO // Use the same physical-radio owner and cleanup path as MeshCore TX. A // foreign packet never enters the MeshCore retry/forwarding machinery. if (serviceForeignRadioFrame()) return; @@ -840,7 +918,7 @@ void Dispatcher::loop() { #if MESH_ENABLE_TX_BEACONS if (checkBeaconRadioSend()) { releaseDroppedOutbound(); return; } #endif -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO if (!checkForeignRadioSend()) checkSend(); #else checkSend(); @@ -942,13 +1020,23 @@ void Dispatcher::checkRecv() { const uint8_t profile = _radio->receiveProfile(); const auto* profiles = _radio->profiles(); + // A logical tuple may have been replaced while the physical modem was + // completing RX. Never reinterpret old Ethernet/Meshtext bytes under + // the new MeshCore tuple, even for raw logs or bridge hooks. + if (profiles && (profile >= 2 + || _radio->receiveProfileGeneration() != profiles->generation[profile])) { + _radio->onReceiveProcessed(); + return; + } // Protocol is selected by the receiving modem, never guessed from a // payload byte. No foreign bytes may leak into MeshCore raw sinks, // parsing, duplicate suppression, direct forwarding, or app counters. if (profiles && profile < 2 - && profiles->params(profile).protocol == RadioProfileProtocol::Meshtext) { -#if MESH_ENABLE_MESHTEXT_CHAT - if (profiles->enabled() && _radio->receiveProfileGeneration() == profiles->generation[profile]) { + && profiles->params(profile).protocol != RadioProfileProtocol::MeshCore) { +#if MESH_ENABLE_FOREIGN_RADIO + if (profile == 1 && profiles->enabled() + && supportsForeignRadioProtocol(profiles->params(profile).protocol) + && _radio->receiveProfileGeneration() == profiles->generation[profile]) { rx_air_time += _radio->getProfileAirtime(profile, len); ++_radio->profiles()->rx_packets[profile]; onForeignRadioFrame(raw, (size_t)len, profile, profiles->generation[profile], snr, rssi); @@ -958,6 +1046,23 @@ void Dispatcher::checkRecv() { return; } +#if MESH_ENABLE_OTA_KISS_DIRECT + // Consume the reserved direct extension before Packet allocation and + // before normal raw logs/bridges. Old firmware rejects its version. + // Malformed/unsolicited frames never become routed MeshCore traffic. + if (ota::ota_direct_wire_marker(raw, (size_t)len)) { + if (profiles && profile < 2 + && onDirectOtaRadioFrame(raw, (size_t)len, profile, + _radio->receiveProfileGeneration())) { + rx_air_time += _radio->getProfileAirtime(profile, len); + ++_radio->profiles()->rx_packets[profile]; + ++n_recv_direct; + } + _radio->onReceiveProcessed(); + return; + } +#endif + // TRACE and CONTROL are direct-only. Reject a flood framing before // raw hooks can expose it to USB logs, MQTT, ESP-NOW, or another bridge. // The header check is deliberate: it also holds when the packet pool is diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 400f423d9..0a0ce667c 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -92,6 +92,7 @@ public: virtual const RadioProfiles* profiles() const { return nullptr; } virtual bool validateProfile(const RadioProfileParams&) const { return false; } virtual bool supportsMeshtext() const { return false; } + virtual bool supportsEthernetTunnel() const { return false; } #if MESH_ENABLE_TX_BEACONS virtual RadioParamApplyResult prepareBeaconTransmit(const RadioProfileParams&, int8_t) { return RadioParamApplyResult::FAILED; @@ -395,17 +396,22 @@ class Dispatcher { unsigned long tx_budget_ms; unsigned long last_budget_update; unsigned long duty_cycle_window_ms; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO // One bounded user-send slot; never placed in the MeshCore packet pool. uint8_t foreign_radio_bytes[MAX_TRANS_UNIT] = {}; uint16_t foreign_radio_length = 0; uint8_t foreign_radio_profile = 0; + RadioProfileProtocol foreign_radio_protocol = RadioProfileProtocol::MeshCore; uint32_t foreign_radio_generation = 0; +#if MESH_ENABLE_OTA_KISS_DIRECT + uint32_t foreign_radio_ota_epoch = 0; +#endif uint32_t foreign_radio_retry_at = 0, foreign_radio_prepare_deadline = 0; uint32_t foreign_radio_start = 0, foreign_radio_expiry = 0; bool foreign_radio_sending = false; bool foreign_radio_yield_meshcore = false; bool isForeignRadioCurrent() const; + bool supportsForeignRadioProtocol(RadioProfileProtocol protocol) const; bool serviceForeignRadioFrame(); bool checkForeignRadioSend(); void finishForeignRadioFrame(bool success, bool account_airtime); @@ -490,13 +496,29 @@ protected: virtual void onTracePacketQueuedForSend(Packet* packet) { } virtual void onSendComplete(Packet* packet) { } virtual void onSendFail(Packet* packet) { } -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO // Foreign RX is consumed here before MeshCore parsing or any raw bridge. // Returning false drops the frame; it never falls through to MeshCore. virtual bool onForeignRadioFrame(const uint8_t*, size_t, uint8_t, uint32_t, float, float) { return false; } virtual void onForeignRadioSendComplete(bool) { } virtual bool allowForeignRadioTransmit() const { return true; } - bool queueForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation); +#if MESH_ENABLE_OTA_KISS_DIRECT + // The KISS host framing is off-air. These are bounded, zero-hop OTA radio + // frames, never Packet objects and never a second physical TX owner. + virtual bool onDirectOtaRadioFrame(const uint8_t*, size_t, uint8_t, uint32_t) { return false; } + virtual bool allowDirectOtaTransmit(uint8_t, uint32_t) const { return false; } + // Captured when queued: abort, fallback and workspace replacement retire + // this token even when the physical temporary profile remains unchanged. + virtual uint32_t directOtaEpoch() const { return 0; } + bool queueDirectOtaRadioFrame(const uint8_t* message, uint16_t length, + uint8_t profile, uint32_t generation); +#endif + bool queueForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation, + RadioProfileProtocol protocol = RadioProfileProtocol::Meshtext); + RadioProfileProtocol queuedForeignRadioProtocol() const { return foreign_radio_protocol; } + // Cancel host work that has not reached RF. An in-flight frame cannot be + // unsent and retains normal completion/airtime accounting. + bool discardQueuedForeignRadioFrame(RadioProfileProtocol protocol); bool hasForeignRadioFrame() const { return foreign_radio_length != 0; } #endif virtual void onRadioProfileCopyQueued(Packet* packet, const Packet* original, uint8_t priority) { } @@ -597,7 +619,7 @@ public: #if MESH_ENABLE_TX_BEACONS || hasBeaconRadioFrame() #endif -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_FOREIGN_RADIO || hasForeignRadioFrame() #endif ; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index f9dfcb9d9..7fdf438ca 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -7,6 +7,9 @@ #include "helpers/ota/OtaContext.h" // OTA mesh-integration is centralized here so every role gets it #include "helpers/ota/OtaSpeedConfig.h" #include "helpers/ota/OtaProtocol.h" // decode_adv -> the `ota neighbors` discovery table +#if MESH_ENABLE_OTA_KISS_DIRECT +#include "helpers/ota/OtaDirectWire.h" +#endif #if !defined(OTA_SEEDER_ONLY) #include "helpers/ota/OtaSelf.h" // ota_self_firmware -> auto-advertise our own image #endif @@ -196,6 +199,15 @@ static int queuedPacedOtaResponses(PacketManager* manager) { bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) { Mesh* m = (Mesh*)ctx; if (!m->isAnyTempRadioActive()) return false; + const auto* context = ota::ota_context_if_active(); + const auto route = context ? context->manager.replyRoute() : ota::OtaReplyRoute{}; +#if MESH_ENABLE_OTA_KISS_DIRECT + if (route.direct) { + // KISS/raw frames bypass the routed Packet pool entirely. The shared + // single-frame owner retains exact profile affinity until actual TxDone. + return m->queueDirectOtaRadioFrame(msg, len, route.profile, route.generation); + } +#endif const bool paced = isPacedOtaResponse(msg, len); const int queued = paced ? queuedPacedOtaResponses(m->_mgr) : 0; // Routine OTA backpressure must not attempt an allocation, which would @@ -205,16 +217,17 @@ bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*fl Packet* p = m->createOtaPacket(msg, len); if (!p) return false; p->radio_reply = len && ota::ota_is_response_message(msg[0]); - if (p->radio_reply) { - if (const auto* context = ota::ota_context_if_active()) { - const auto route = context->manager.replyRoute(); - if (route.profile < 2) { - // DATA, PROOF and MANIFEST are emitted after the RX call stack ends. - // Restore origin only; normal reply policy still decides one/both TX. - p->radio_profile = p->radio_origin = route.profile; - p->radio_generation = p->radio_origin_generation = route.generation; - p->radio_local = false; - } + if (route.profile < 2) { + // DATA, PROOF and MANIFEST are emitted after the RX call stack ends. + // Restore origin only; normal reply policy still decides one/both TX. + p->radio_profile = p->radio_origin = route.profile; + p->radio_generation = p->radio_origin_generation = route.generation; + p->radio_local = false; + // A direct negotiation fallback stays on its original physical + // session instead of silently moving onto a replacement profile. + if (!p->radio_reply) { + p->radio_bound = true; + p->tx_radio = route.profile == 0 ? RADIO_TX_PRIMARY : RADIO_TX_SECONDARY; } } const uint8_t mask = m->getTransmitProfileMask(p); @@ -233,6 +246,46 @@ bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*fl // Runtime OTA flood reach (`ota config hops`, persisted in NodePrefs): accept packets up to N hops away and // relay those still under N hops. 0 = direct only. Overridable per-role by subclassing. uint8_t Mesh::getOtaHopLimit() const { return ota::ota_hop_limit(); } + +#if MESH_ENABLE_OTA_KISS_DIRECT +uint32_t Mesh::directOtaEpoch() const { + const auto* context = ota::ota_context_if_active(); + return context ? context->manager.directTransportEpoch() : 0; +} + +bool Mesh::allowDirectOtaTransmit(uint8_t profile, uint32_t generation) const { + if (!isAnyTempRadioActive() || !allowPacketTransmit(nullptr)) return false; + const auto* profiles = _radio->profiles(); + if (!profiles || profile >= 2 || generation != profiles->generation[profile] + || profiles->params(profile).protocol != RadioProfileProtocol::MeshCore + || !profiles->canTransmit(profile)) return false; + // Never infer a normal-channel TX opportunity from a temporary window on + // the other tuple. Explicit cross policy retains its existing meaning. + return (profile == 0 ? (isTempRadioActive() || profiles->primary_temporary) + : profiles->secondary_temporary) + || profiles->cross == RadioCrossMode::On; +} + +bool Mesh::onDirectOtaRadioFrame(const uint8_t* bytes, size_t length, + uint8_t profile, uint32_t generation) { + const uint8_t* message; + uint16_t message_len; + if (!allowDirectOtaTransmit(profile, generation) + || !ota::ota_direct_wire_decode(bytes, length, message, message_len)) return false; + auto* context = ota::ota_context_if_active(); + if (!context) return false; // unsolicited bytes cannot allocate an OTA arena + syncOtaTiming(context->manager); + context->manager.set_direct_transport_enabled(true); + context->manager.note_rx_path_hops(0); + ota::OtaReplyRoute route; + route.profile = profile; + route.generation = generation; + route.direct = true; + context->manager.on_message(message, message_len, route); + context->track_session(context->manager.fetchState(), _ms->getMillis()); + return true; +} +#endif #endif void Mesh::begin() { @@ -463,6 +516,9 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { if (!ota_active) { if (_ota_temp_was_active) { ota::ota_ctx().manager.clearPendingEgress(); +#if MESH_ENABLE_OTA_KISS_DIRECT + discardQueuedForeignRadioFrame(RadioProfileProtocol::MeshCore); +#endif resetOtaRelayBackoff(); } _ota_temp_was_active = false; @@ -485,7 +541,11 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { syncOtaTiming(ota::ota_ctx().manager); ota::ota_ctx().manager.serviceEgress([](void* ctx, const ota::OtaReplyRoute& route) { if (route.profile == 0xFF) return true; - const auto* profiles = static_cast(ctx)->_radio->profiles(); + const auto* mesh = static_cast(ctx); + const auto* profiles = mesh->_radio->profiles(); +#if MESH_ENABLE_OTA_KISS_DIRECT + if (route.direct) return mesh->allowDirectOtaTransmit(route.profile, route.generation); +#endif // A completed/reconfigured temp session must not leak its retained replies // onto the replacement channel, or pin a stale descriptor behind backpressure. return route.profile < 2 && (!profiles ? route.profile == 0 @@ -1412,6 +1472,12 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { ota::OtaReplyRoute route; route.profile = pkt->radio_profile; route.generation = pkt->radio_generation; +#if MESH_ENABLE_OTA_KISS_DIRECT + if (!context->manager.directFetchActive()) { + context->manager.set_direct_transport_enabled( + allowDirectOtaTransmit(route.profile, route.generation)); + } +#endif terminal_ota = context->manager.on_message(pkt->payload, pkt->payload_len, route); context->track_session(context->manager.fetchState(), _ms->getMillis()); onOtaRecv(pkt); diff --git a/src/Mesh.h b/src/Mesh.h index 9b8a3e843..39dd82e19 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -558,6 +558,12 @@ protected: // OTA mesh-integration is centralized in Mesh::begin()/loop()/dispatch, so every role (repeater, // companion, room, sensor, ...) gets fetch/serve/apply without per-example wiring. static bool otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool flood); +#if MESH_ENABLE_OTA_KISS_DIRECT + bool allowDirectOtaTransmit(uint8_t profile, uint32_t generation) const override; + uint32_t directOtaEpoch() const override; + bool onDirectOtaRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, + uint32_t generation) override; +#endif void syncOtaTiming(ota::OtaManager& manager); unsigned long _next_ota_tick = 0; ota::LongTimer _ota_announce_timer; // supports long scaled intervals across millis rollovers diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index 47181f996..8836e16c5 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -9,7 +9,7 @@ namespace mesh { enum class RadioProfileMode : uint8_t { Off = 0, Rx = 1, RxTx = 2 }; enum class RadioCrossMode : uint8_t { Auto = 0, On = 1, Off = 2 }; -enum class RadioProfileProtocol : uint8_t { MeshCore = 0, Meshtext = 1 }; +enum class RadioProfileProtocol : uint8_t { MeshCore = 0, Meshtext = 1, Ethernet = 2 }; // These are modulation profiles of ONE transceiver, never additional radios. struct RadioProfileParams { @@ -42,7 +42,7 @@ class RadioProfiles { static constexpr double MinFastListenSymbols = MinListenSymbols; static constexpr uint8_t AcquisitionSymbols = 8; static constexpr uint16_t MaxPreamble = 65528; - static constexpr uint16_t MaxAutomaticPreamble = 128; + static constexpr uint16_t MaxAutomaticPreamble = 512; // Successful RX-to-RX hops self-test the board-specific switch allowance. // Packet receptions can extend a visit. No fixed loop allowance is added to // the self-tested, guarded switch time in the idle-scan timing model. @@ -120,7 +120,8 @@ class RadioProfiles { static bool valid(const RadioProfileParams& p) { if (!isfinite(p.freq) || !isfinite(p.bw) || p.freq < 150 || p.freq > 2500 || p.sf < 5 || p.sf > 12 || p.cr < 5 || p.cr > 8 - || (p.protocol != RadioProfileProtocol::MeshCore && p.protocol != RadioProfileProtocol::Meshtext) + || (p.protocol != RadioProfileProtocol::MeshCore && p.protocol != RadioProfileProtocol::Meshtext + && p.protocol != RadioProfileProtocol::Ethernet) || (p.preamble != 0 && (p.preamble < 8 || p.preamble > MaxPreamble))) return false; static constexpr float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125, 250, 500, 1000, 812.5f, 1625}; @@ -247,7 +248,7 @@ class RadioProfiles { for (uint8_t i = 0; i < (enabled() ? 2 : 1); ++i) { if (!valid(params(i)) || !safePreamble(params(i), preamble(i, 32))) return false; } - // Recommendations over 128 are reported as unsafe, but the profile can + // Recommendations above the automatic cap remain visible, but the profile can // still run at the cap when its physical preamble is legal. // A foreign network fixes its own preamble and cannot advertise our scan // cycle. Accept safe wire settings, but do not claim loss-free scanning. diff --git a/src/helpers/CompanionTrafficPrefs.h b/src/helpers/CompanionTrafficPrefs.h new file mode 100644 index 000000000..dcd1c94e4 --- /dev/null +++ b/src/helpers/CompanionTrafficPrefs.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace mesh { namespace companion { namespace traffic { + +// A checked append-only preference footer. The existing preference image and +// any board-specific footer retain their published positions. +static constexpr size_t FooterSize = 8; + +inline uint16_t checksum(const uint8_t* bytes, size_t size) { + uint16_t crc = 0xffff; + for (size_t i = 0; i < size; ++i) { + crc ^= static_cast(bytes[i]) << 8; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc & 0x8000) ? static_cast((crc << 1) ^ 0x1021) + : static_cast(crc << 1); + } + return crc; +} + +inline void encodeFooter(uint8_t out[FooterSize], uint8_t media, uint8_t ssh) { + out[0] = 'T'; out[1] = 'X'; out[2] = 'R'; out[3] = 1; + out[4] = encodeRadioTxPolicy(media); + out[5] = encodeRadioTxPolicy(ssh); + const uint16_t crc = checksum(out, 6); + out[6] = static_cast(crc >> 8); + out[7] = static_cast(crc); +} + +inline bool validPolicyByte(uint8_t value) { + return value == 0 || (value >= 0xa1 && value <= 0xa4); +} + +inline bool decodeFooter(const uint8_t* in, size_t size, + uint8_t& media, uint8_t& ssh) { + if (!in || size != FooterSize || in[0] != 'T' || in[1] != 'X' + || in[2] != 'R' || in[3] != 1 + || !validPolicyByte(in[4]) || !validPolicyByte(in[5]) + || checksum(in, 6) != static_cast((in[6] << 8) | in[7])) + return false; + media = decodeRadioTxPolicy(in[4]); + ssh = decodeRadioTxPolicy(in[5]); + return true; +} + +} } } // namespace mesh::companion::traffic diff --git a/src/helpers/CompanionTrafficRouting.h b/src/helpers/CompanionTrafficRouting.h new file mode 100644 index 000000000..88a05d671 --- /dev/null +++ b/src/helpers/CompanionTrafficRouting.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include + +namespace mesh { namespace companion { + +enum class TrafficKind : uint8_t { Other, Media, Ssh }; + +// Channel application IDs, not new MeshCore packet types. SSH is an opt-in +// contract for tunnel clients, not a claim that opaque bytes can be inspected. +static constexpr uint16_t MEDIA_CHANNEL_DATA_TYPE = 0x5643; // Open TAK 'VC' +static constexpr uint16_t SSH_CHANNEL_DATA_TYPE = 0x5353; // 'SS' + +inline TrafficKind groupTrafficKind(uint16_t data_type) { + return data_type == MEDIA_CHANNEL_DATA_TYPE ? TrafficKind::Media + : data_type == SSH_CHANNEL_DATA_TYPE ? TrafficKind::Ssh : TrafficKind::Other; +} + +inline TrafficKind rawTrafficKind(const uint8_t* data, size_t length) { + if (!data || !length) return TrafficKind::Other; + // Open TAK's VE3/VOX binary clip packets also carry photos. Do not capture + // every RAW_CUSTOM application just because the media app uses that API. + if ((data[0] == 0x56 && length >= 7) || (data[0] == 0x57 && length >= 8)) + return TrafficKind::Media; + if (data[0] == 0x72 && length >= 13) { + const uint8_t flags = data[5]; + const size_t count = data[12]; + if ((flags & ~7U) || (!(flags & 1) && count) || count > length - 13) + return TrafficKind::Other; + const size_t end = 13 + count; + if (!(flags & 4)) return length == end ? TrafficKind::Media : TrafficKind::Other; + if (length > end && data[end] <= 63 && length - end - 1 == data[end]) + return TrafficKind::Media; + return TrafficKind::Other; + } + // Tunnel apps must opt in on EVERY packet, including their encrypted data, + // ACKs and retries: ['S']['S']['H'][version=1][application bytes...]. The + // envelope is passed through unchanged; it is not an SSH implementation. + if (length >= 5 && !memcmp(data, "SSH\x01", 4)) return TrafficKind::Ssh; + return TrafficKind::Other; +} + +inline uint8_t trafficPolicy(TrafficKind kind, uint8_t media, uint8_t ssh) { + return kind == TrafficKind::Media ? media + : kind == TrafficKind::Ssh ? ssh : static_cast(RADIO_TX_AUTO); +} + +inline uint8_t trafficTransmitMask(uint8_t policy, const RadioProfiles* profiles) { + uint8_t mask = policy == RADIO_TX_AUTO ? (profiles ? profiles->transmitMask(0) : 1) + : explicitRadioTxMask(policy, profiles && profiles->canTransmit(1)); + // Match Dispatcher: MeshCore datagrams cannot be sent on a Meshtext profile, + // even if that profile is configured rxtx. + if (profiles) { + for (uint8_t i = 0; i < 2; ++i) { + if (profiles->params(i).protocol != RadioProfileProtocol::MeshCore) + mask &= static_cast(~(1U << i)); + } + } + return mask; +} + +struct TrafficRouteCommand { + bool matched = false; + bool valid = false; + bool set = false; + TrafficKind kind = TrafficKind::Other; + uint8_t policy = RADIO_TX_AUTO; +}; + +inline TrafficRouteCommand parseTrafficRouteCommand(const char* text) { + TrafficRouteCommand result; + if (!text) return result; + if (!strncmp(text, "get ", 4)) result.set = false; + else if (!strncmp(text, "set ", 4)) result.set = true; + else return result; + text += 4; + if (!strncmp(text, "tx.media", 8)) result.kind = TrafficKind::Media; + else if (!strncmp(text, "tx.ssh", 6)) result.kind = TrafficKind::Ssh; + else return result; + text += result.kind == TrafficKind::Media ? 8 : 6; + if (*text && *text != ' ' && *text != '\t') return result; + result.matched = true; + while (*text == ' ' || *text == '\t') ++text; + const char* end = text + strlen(text); + while (end > text && (end[-1] == ' ' || end[-1] == '\t')) --end; + if (!result.set) { result.valid = end == text; return result; } + char value[8]; + const size_t length = end - text; + if (!length || length >= sizeof(value)) return result; + memcpy(value, text, length); + value[length] = 0; + result.valid = parseRadioTxPolicy(value, result.policy); + return result; +} + +} } // namespace mesh::companion diff --git a/src/helpers/EthernetKiss.h b/src/helpers/EthernetKiss.h new file mode 100644 index 000000000..97465bf7a --- /dev/null +++ b/src/helpers/EthernetKiss.h @@ -0,0 +1,157 @@ +#pragma once + +#include +#include +#include + +namespace mesh { namespace ethernet { + +static constexpr size_t MinimumFrameSize = 14; +static constexpr size_t MaxFrameSize = 255; +static constexpr size_t QueueDepth = 2; +static constexpr size_t MaxEncodedSize = 2 * MaxFrameSize + 3; +static constexpr uint8_t Fend = 0xc0; +static constexpr uint8_t Fesc = 0xdb; +static constexpr uint8_t Tfend = 0xdc; +static constexpr uint8_t Tfesc = 0xdd; + +// Only standard port-0 DATA frames are accepted. Ethernet bytes are not +// interpreted, wrapped, padded or truncated here: ARP and all IP fragments +// must traverse the same link as the TCP connection they support. +class KissCodec { + struct Frame { + uint16_t length = 0; + uint8_t bytes[MaxFrameSize]; + }; + struct EncodedFrame { + uint16_t length = 0; + uint8_t bytes[MaxEncodedSize]; + }; + Frame _transmit[QueueDepth]; + EncodedFrame _receive[QueueDepth]; + uint8_t _transmitHead = 0, _transmitCount = 0; + uint8_t _receiveHead = 0, _receiveCount = 0; + uint16_t _outputOffset = 0; + uint8_t _input[MaxFrameSize]; + uint16_t _inputLength = 0; + bool _framed = false, _command = false, _discard = false, _escaped = false; + uint32_t _receivedFrames = 0, _sentFrames = 0, _malformed = 0, _dropped = 0; + + void rejectInput() { + if (!_discard) ++_malformed; + _discard = true; + } + +public: + // Session resets deliberately preserve lifetime diagnostic counters. + void resetPending() { + _transmitHead = _transmitCount = _receiveHead = _receiveCount = 0; + _outputOffset = _inputLength = 0; + _framed = _command = _discard = _escaped = false; + } + + bool canAcceptByte() const { return _transmitCount < QueueDepth; } + + // False means the caller must retain this byte and apply TCP backpressure. + // The socket adapter checks canAcceptByte() BEFORE consuming a socket byte. + bool feed(uint8_t byte) { + if (!canAcceptByte()) return false; + if (byte == Fend) { + if (_framed && _command && !_discard) { + if (_escaped || (_inputLength && _inputLength < MinimumFrameSize)) { + rejectInput(); + } else if (_inputLength >= MinimumFrameSize) { + Frame& frame = _transmit[(_transmitHead + _transmitCount) % QueueDepth]; + memcpy(frame.bytes, _input, _inputLength); + frame.length = _inputLength; + ++_transmitCount; + ++_receivedFrames; + } + } + // One FEND can close the last frame and start the next one. + _framed = true; + _command = _discard = _escaped = false; + _inputLength = 0; + return true; + } + if (!_framed || _discard) return true; + if (!_command) { + _command = true; + // Other ports and control commands are ignored until the next boundary. + if (byte != 0) _discard = true; + return true; + } + if (_escaped) { + _escaped = false; + if (byte == Tfend) byte = Fend; + else if (byte == Tfesc) byte = Fesc; + else { rejectInput(); return true; } + } else if (byte == Fesc) { + _escaped = true; + return true; + } + if (_inputLength >= MaxFrameSize) { rejectInput(); return true; } + _input[_inputLength++] = byte; + return true; + } + + bool peekTransmit(const uint8_t*& bytes, size_t& length) const { + if (!_transmitCount) { bytes = nullptr; length = 0; return false; } + bytes = _transmit[_transmitHead].bytes; + length = _transmit[_transmitHead].length; + return true; + } + + void consumeTransmit() { + if (!_transmitCount) return; + _transmitHead = (_transmitHead + 1) % QueueDepth; + --_transmitCount; + } + + bool queueReceive(const uint8_t* bytes, size_t length) { + if (!bytes || length < MinimumFrameSize || length > MaxFrameSize + || _receiveCount >= QueueDepth) { ++_dropped; return false; } + EncodedFrame& frame = _receive[(_receiveHead + _receiveCount) % QueueDepth]; + size_t at = 0; + frame.bytes[at++] = Fend; + frame.bytes[at++] = 0; // KISS port-0 DATA, never a companion binary frame. + for (size_t i = 0; i < length; ++i) { + const uint8_t byte = bytes[i]; + if (byte == Fend || byte == Fesc) { + frame.bytes[at++] = Fesc; + frame.bytes[at++] = byte == Fend ? Tfend : Tfesc; + } else frame.bytes[at++] = byte; + } + frame.bytes[at++] = Fend; + frame.length = static_cast(at); + ++_receiveCount; + return true; + } + + bool peekOutput(const uint8_t*& bytes, size_t& length) const { + if (!_receiveCount) { bytes = nullptr; length = 0; return false; } + bytes = _receive[_receiveHead].bytes + _outputOffset; + length = _receive[_receiveHead].length - _outputOffset; + return true; + } + + void consumeOutput(size_t length) { + if (!_receiveCount) return; + const size_t remaining = _receive[_receiveHead].length - _outputOffset; + if (length >= remaining) { + _outputOffset = 0; + _receiveHead = (_receiveHead + 1) % QueueDepth; + --_receiveCount; + ++_sentFrames; + } else _outputOffset += static_cast(length); + } + + uint8_t pendingTransmit() const { return _transmitCount; } + uint8_t pendingReceive() const { return _receiveCount; } + uint32_t receivedFrames() const { return _receivedFrames; } + uint32_t sentFrames() const { return _sentFrames; } + uint32_t malformedFrames() const { return _malformed; } + uint32_t droppedFrames() const { return _dropped; } +}; + +} } // namespace mesh::ethernet diff --git a/src/helpers/EthernetTunnelConfig.h b/src/helpers/EthernetTunnelConfig.h new file mode 100644 index 000000000..469063989 --- /dev/null +++ b/src/helpers/EthernetTunnelConfig.h @@ -0,0 +1,22 @@ +#pragma once + +// Ethernet tunnelling is a host-assisted ESP32 Full Companion transport. +// Deliberately independent of radio-chip macros: some Arduino variants reveal +// their chip only after this header is consumed in other translation units. +// A concrete driver must additionally advertise the wire capability. +#ifndef MESH_ENABLE_ETHERNET_TUNNEL + #if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL) \ + && defined(WIFI_SSID) \ + && !(defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) + #define MESH_ENABLE_ETHERNET_TUNNEL 1 + #else + #define MESH_ENABLE_ETHERNET_TUNNEL 0 + #endif +#endif + +// Raw KISS modems may opt into the same LoRa wire format without including +// Companion ownership, its foreign-frame dispatcher slot, or a WiFi server. +// Keep this independent of board macros so every translation unit agrees. +#ifndef MESH_ENABLE_ETHERNET_WIRE + #define MESH_ENABLE_ETHERNET_WIRE MESH_ENABLE_ETHERNET_TUNNEL +#endif diff --git a/src/helpers/EthernetTunnelSettings.h b/src/helpers/EthernetTunnelSettings.h new file mode 100644 index 000000000..c31ea1392 --- /dev/null +++ b/src/helpers/EthernetTunnelSettings.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +namespace mesh { namespace ethernet { + +struct Settings { + bool enabled = false; + bool configured = false; + RadioProfileParams radio; + Settings() { radio.protocol = RadioProfileProtocol::Ethernet; } +}; +static constexpr size_t SettingsSize = 32; + +inline uint32_t settingsChecksum(const uint8_t* bytes, size_t length) { + uint32_t crc = 0xffffffff; + while (length--) { + crc ^= *bytes++; + for (unsigned i = 0; i < 8; ++i) crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0); + } + return crc; +} + +inline bool validSettings(const Settings& value) { + return (!value.enabled || value.configured) + && (!value.configured || (value.radio.protocol == RadioProfileProtocol::Ethernet + && RadioProfiles::valid(value.radio) && value.radio.preamble >= 8 + && RadioProfiles::safePreamble(value.radio, value.radio.preamble))); +} + +inline void encodeSettings(uint8_t image[SettingsSize], const Settings& value) { + memset(image, 0, SettingsSize); + memcpy(image, "ETH1", 4); + image[4] = value.enabled; image[5] = value.configured; + image[6] = value.radio.sf; image[7] = value.radio.cr; + memcpy(image + 8, &value.radio.freq, 4); + memcpy(image + 12, &value.radio.bw, 4); + memcpy(image + 16, &value.radio.preamble, 2); + const uint32_t crc = settingsChecksum(image, SettingsSize - 4); + memcpy(image + SettingsSize - 4, &crc, 4); +} + +inline bool decodeSettings(const uint8_t* image, size_t length, Settings& value) { + if (!image || length != SettingsSize || memcmp(image, "ETH1", 4) + || image[4] > 1 || image[5] > 1) return false; + uint32_t crc; + memcpy(&crc, image + SettingsSize - 4, 4); + if (crc != settingsChecksum(image, SettingsSize - 4)) return false; + for (size_t i = 18; i < SettingsSize - 4; ++i) if (image[i]) return false; + Settings loaded; + loaded.enabled = image[4]; loaded.configured = image[5]; + loaded.radio.sf = image[6]; loaded.radio.cr = image[7]; + memcpy(&loaded.radio.freq, image + 8, 4); + memcpy(&loaded.radio.bw, image + 12, 4); + memcpy(&loaded.radio.preamble, image + 16, 2); + if (!validSettings(loaded)) return false; + value = loaded; + return true; +} + +} } // namespace mesh::ethernet diff --git a/src/helpers/MeshtextConfig.h b/src/helpers/MeshtextConfig.h index cfe189a41..a1321bcc4 100644 --- a/src/helpers/MeshtextConfig.h +++ b/src/helpers/MeshtextConfig.h @@ -1,6 +1,8 @@ #pragma once #include "BeaconFeatureConfig.h" +#include "EthernetTunnelConfig.h" +#include "ota/OtaDirectConfig.h" // Resolve the chip in every translation unit, including Dispatcher.cpp which // includes this before Arduino.h. Otherwise different class layouts could be @@ -40,3 +42,10 @@ #define MESH_ENABLE_MESHTEXT_WIRE 0 #endif #endif + +// Foreign transports and negotiated direct KISS OTA share one bounded raw +// dispatcher slot. Direct OTA retains its temporary MeshCore profile; foreign +// transports keep their existing radio2-only ownership. +#ifndef MESH_ENABLE_FOREIGN_RADIO + #define MESH_ENABLE_FOREIGN_RADIO (MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_OTA_KISS_DIRECT) +#endif diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 5b88ddd0e..a944bc16f 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -3,6 +3,7 @@ #include "RadioProfileCommandUtils.h" #include "FilePresence.h" #include "radiolib/RXPowerSaving.h" +#include "radiolib/HilTxFrequencyGuard.h" #include #include #include @@ -147,7 +148,7 @@ bool RadioProfileCLI::prepareSavedImage(const char* path, const RadioProfileConf uint16_t preamble, RadioCrossMode cross, int8_t manual_secondary) { if (!fs_ || hold_) return false; uint8_t image[ImageSize] = {'R', '2', 1, (uint8_t)config.mode}; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL // Version 2 keeps the 24-byte layout and CRC; bit 7 records explicit radio2 // intent. Other settings can save an unused Off slot without claiming it. image[2] = 2; @@ -188,7 +189,7 @@ void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool in recoverable_corrupt_store_ = false; manual_secondary_ = false; secondary_intent_generation_ = 0; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL auto_secondary_requested_ = secondary_protocol_overlay_ = false; owns_secondary_runtime_ = external_secondary_override_ = false; #endif @@ -277,19 +278,19 @@ void RadioProfileCLI::publish() { ? (validReplySetting(reply_setting_) ? reply_setting_ & 7 : RADIO_TX_BOTH) : RADIO_TX_AUTO; radio_->profiles()->reply_force = infrastructure_replies_ && validReplySetting(reply_setting_) && (reply_setting_ & 8); -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL protectExternalSecondary(); if (external_secondary_override_) return; #endif if (publish_pending_ && (int32_t)(millis() - publish_after_ms_) < 0) return; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL // An accepted remote command owns the old RX generation until its reply has // completed (or its atomic storage commit has succeeded). if (hasReplyMutation()) return; #endif publish_pending_ = false; RadioProfileConfig config = temp_active_ ? temporary_ : saved_; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (auto_secondary_requested_ && canAutoSecondary()) config = auto_secondary_; else if (secondary_protocol_overlay_ && manualSecondaryConfigured() && !secondaryRuntimeBusy()) { config.params.protocol = secondary_protocol_; @@ -297,7 +298,7 @@ void RadioProfileCLI::publish() { } #endif radio_->profiles()->setSecondary(config, temp_active_); -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL owns_secondary_runtime_ = auto_secondary_requested_ || secondary_protocol_overlay_; if (owns_secondary_runtime_) { owned_secondary_snapshot_ = config; @@ -306,7 +307,7 @@ void RadioProfileCLI::publish() { #endif } -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL bool RadioProfileCLI::runtimeSecondaryUnchanged() const { if (!owns_secondary_runtime_ || !radio_ || !radio_->profiles()) return true; const auto* profiles = radio_->profiles(); @@ -331,7 +332,7 @@ bool RadioProfileCLI::hasSecondarySchedule() const { } bool RadioProfileCLI::secondaryRuntimeBusy() const { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (external_secondary_override_ || !runtimeSecondaryUnchanged()) return true; #endif return !fs_ || !radio_ || !radio_->profiles() || hold_ || temp_pending_ || temp_active_ @@ -341,14 +342,14 @@ bool RadioProfileCLI::secondaryRuntimeBusy() const { void RadioProfileCLI::noteSecondaryManualChange() { ++secondary_intent_generation_; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL auto_secondary_requested_ = secondary_protocol_overlay_ = false; owns_secondary_runtime_ = external_secondary_override_ = false; #endif } bool RadioProfileCLI::canAutoSecondary() const { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (manual_secondary_ || saved_.mode != RadioProfileMode::Off || secondaryRuntimeBusy()) return false; const auto& current = radio_->profiles()->secondary; return current.mode == RadioProfileMode::Off || (auto_secondary_requested_ @@ -359,7 +360,7 @@ bool RadioProfileCLI::canAutoSecondary() const { } bool RadioProfileCLI::requestAutoSecondary(const RadioProfileConfig& config) { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (!canAutoSecondary() || config.mode == RadioProfileMode::Off || (uint8_t)config.mode > (uint8_t)RadioProfileMode::RxTx || !radio_->validateProfile(config.params)) return false; @@ -379,7 +380,7 @@ bool RadioProfileCLI::requestAutoSecondary(const RadioProfileConfig& config) { } void RadioProfileCLI::clearAutoSecondary() { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (!auto_secondary_requested_) return; protectExternalSecondary(); auto_secondary_requested_ = false; @@ -389,7 +390,7 @@ void RadioProfileCLI::clearAutoSecondary() { } bool RadioProfileCLI::autoSecondaryActive() const { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (!auto_secondary_requested_ || !canAutoSecondary()) return false; const auto& current = radio_->profiles()->secondary; return !radio_->profiles()->secondary_temporary && current.mode == auto_secondary_.mode @@ -400,17 +401,27 @@ bool RadioProfileCLI::autoSecondaryActive() const { } bool RadioProfileCLI::useSecondaryProtocol(RadioProfileProtocol protocol) { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (protocol == RadioProfileProtocol::MeshCore) { clearSecondaryProtocol(); return true; } - if (protocol != RadioProfileProtocol::Meshtext || !manualSecondaryConfigured() + const bool supported_protocol = +#if MESH_ENABLE_MESHTEXT_CHAT + protocol == RadioProfileProtocol::Meshtext || +#endif +#if MESH_ENABLE_ETHERNET_TUNNEL + protocol == RadioProfileProtocol::Ethernet || +#endif + false; + if (!supported_protocol || !manualSecondaryConfigured() || secondaryRuntimeBusy()) return false; + // Never silently replace another foreign owner's overlay. + if (secondary_protocol_overlay_ && secondary_protocol_ != protocol) return false; auto preview = *radio_->profiles(); auto config = saved_; config.params.protocol = protocol; - config.params.preamble = 16; + if (protocol == RadioProfileProtocol::Meshtext) config.params.preamble = 16; preview.setSecondary(config, false); if (!radio_->validateProfile(config.params) || !preview.automaticPreambleFits()) return false; secondary_protocol_ = protocol; @@ -424,7 +435,7 @@ bool RadioProfileCLI::useSecondaryProtocol(RadioProfileProtocol protocol) { } void RadioProfileCLI::clearSecondaryProtocol() { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (!secondary_protocol_overlay_) return; protectExternalSecondary(); secondary_protocol_overlay_ = false; @@ -632,7 +643,8 @@ void RadioProfileCLI::appendChirpWarning(char* reply, size_t capacity, const Rad const bool over_a = timing.preamble[0] > RadioProfiles::MaxAutomaticPreamble; const bool over_b = timing.preamble[1] > RadioProfiles::MaxAutomaticPreamble; if (used < capacity && (over_a || over_b)) - snprintf(reply + used, capacity - used, "; WARN need >128: %s", + snprintf(reply + used, capacity - used, "; WARN need >%u: %s", + unsigned(RadioProfiles::MaxAutomaticPreamble), over_a && over_b ? "radio,radio2" : over_a ? "radio" : "radio2"); used = strlen(reply); const bool short_a = preview.primary.preamble && preview.primary.preamble < timing.preamble[0]; @@ -679,6 +691,9 @@ void RadioProfileCLI::formatConfig(char* reply, size_t capacity, const RadioProf } bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, bool remote_origin) { +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (handleHilTxFrequencyCommand(command, reply, capacity)) return true; +#endif if (handleCarrierWaveCommand(radio_, command, reply, capacity)) return true; const char* text = command; enum { Get, Set, Delete } verb = Get; @@ -816,7 +831,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, return true; } if ((base || temporary) && verb == Get && !*args) { -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (base && autoSecondaryActive()) { formatConfig(reply, capacity, auto_secondary_, false, 0, true); return true; @@ -831,7 +846,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, temporary, temp_pending_ ? pending_duration_ms_ : temp_remaining_ms_); return true; } -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL if (base && verb == Set && !strcmp(args, "auto")) { // This clears only saved permission, never an active/pending lease or a // schedule. Remote acknowledgement staging must not weaken that contract. @@ -991,7 +1006,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, noteSecondaryManualChange(); pending_temporary_ = config; pending_duration_ms_ = minutes * 60000UL; temp_start_ms_ = millis() + 2000; temp_pending_ = true; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL publish_pending_ = true; publish_after_ms_ = temp_start_ms_; #endif } diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index 0af499443..f431e898c 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -32,7 +32,7 @@ class RadioProfileCLI { // Saved Off can be an explicit veto, rather than an unused secondary slot. bool manual_secondary_ = false; uint32_t secondary_intent_generation_ = 0; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL RadioProfileConfig auto_secondary_; bool auto_secondary_requested_ = false; bool secondary_protocol_overlay_ = false; @@ -110,6 +110,7 @@ class RadioProfileCLI { void clearAutoSecondary(); bool autoSecondaryActive() const; bool canAutoSecondary() const; + bool secondaryRuntimeAvailable() const { return !secondaryRuntimeBusy(); } // Borrow a manually configured RF tuple without persisting its wire protocol // or the foreign network's fixed preamble. Temporary sessions take priority. bool useSecondaryProtocol(RadioProfileProtocol protocol); diff --git a/src/helpers/esp32/EthernetKissServer.h b/src/helpers/esp32/EthernetKissServer.h new file mode 100644 index 000000000..8837741e5 --- /dev/null +++ b/src/helpers/esp32/EthernetKissServer.h @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace mesh { namespace ethernet { + +// Opt-in, unauthenticated KISS-over-TCP on a trusted station LAN only. This +// service never joins WiFi, opens an AP, selects an RF profile, or adds bytes +// to the Ethernet frame. The owner must explicitly enable and service RF. +class KissServer { +public: + // Full Companion already uses 5000/5001/5002 for binary, OTA and text. + static constexpr uint16_t Port = 5003; + static constexpr size_t ReadBudget = 128; + static constexpr size_t WriteBudget = 128; + struct Status { + bool enabled, listening, connected; + uint32_t accepted, rejected, disconnects, rxFrames, txFrames; + uint32_t malformed, dropped, listenFailures; + uint8_t pendingTransmit, pendingReceive; + }; + +private: + WiFiServer _server; + WiFiClient _client; + KissCodec _codec; + uint32_t _address = 0, _lastListenAttempt = 0; + bool _enabled = false, _listening = false, _connected = false; + bool _listenAttempted = false; + uint32_t _accepted = 0, _rejected = 0, _disconnects = 0, _listenFailures = 0; + + void disconnect() { + if (_connected) ++_disconnects; + _connected = false; + _client.stop(); + _codec.resetPending(); + } + + void stop() { + disconnect(); + _server.end(); + _listening = _listenAttempted = false; + _address = 0; + } + +public: + KissServer() : _server(Port, 2) {} + + // Closing the stream AND pending accepts discards unread SDK/socket bytes. + // Merely clearing the decoder would let pre-change work survive in a socket. + // The next enabled poll immediately rebinds to the same station address. + void resetPending() { + disconnect(); + _server.end(); + _listening = _listenAttempted = false; + } + + void loop(bool enabled) { + _enabled = enabled; + const bool station = (WiFi.getMode() & WIFI_STA) + && WiFi.status() == WL_CONNECTED; + const uint32_t address = station ? static_cast(WiFi.localIP()) : 0; + if (!enabled || !address) { + if (_address || _listening || _connected) stop(); + return; + } + if (_address != address) { + stop(); + _address = address; + // Binding the station address prevents this unauthenticated service from + // appearing on a simultaneously enabled setup AP. + _server = WiFiServer(IPAddress(address), Port, 2); + } + const uint32_t now = static_cast(millis()); + if (_listening && !static_cast(_server)) { + _server.end(); + _listening = _listenAttempted = false; + } + if (!_listening && (!_listenAttempted + || static_cast(now - _lastListenAttempt) >= 1000U)) { + _lastListenAttempt = now; + _listenAttempted = true; + _server.begin(); + _listening = static_cast(_server); + if (!_listening) { ++_listenFailures; _server.end(); } + } + if (_connected && !_client.connected()) disconnect(); + if (_listening) { + // Accept at most one queued connection per poll. An idle valid owner is + // never displaced by another host or by that host reconnecting early. + WiFiClient incoming = _server.available(); + if (incoming) { + if (_connected) { incoming.stop(); ++_rejected; } + else { + _codec.resetPending(); + _client = incoming; + _client.setNoDelay(true); + _connected = true; + ++_accepted; + } + } + } + if (!_connected) return; + // WiFiClient::write may retry blocking select calls for seconds. One + // MSG_DONTWAIT send, with a fixed byte budget, leaves LoRa scanning runnable. + const uint8_t* bytes; + size_t length; + if (_codec.peekOutput(bytes, length)) { + if (length > WriteBudget) length = WriteBudget; + const int written = ::send(_client.fd(), bytes, length, MSG_DONTWAIT); + if (written > 0) _codec.consumeOutput(static_cast(written)); + else if (written < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + disconnect(); + return; + } + } + for (size_t consumed = 0; consumed < ReadBudget && _codec.canAcceptByte(); ++consumed) { + uint8_t byte; + const int received = ::recv(_client.fd(), &byte, 1, MSG_DONTWAIT); + if (received == 1) _codec.feed(byte); + else { + if (received == 0 || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) + disconnect(); + break; + } + } + } + + bool peekTransmit(const uint8_t*& bytes, size_t& length) const { + return _codec.peekTransmit(bytes, length); + } + void consumeTransmit() { _codec.consumeTransmit(); } + bool queueReceive(const uint8_t* bytes, size_t length) { + return _connected && _codec.queueReceive(bytes, length); + } + Status status() const { + return {_enabled, _listening, _connected, _accepted, _rejected, _disconnects, + _codec.receivedFrames(), _codec.sentFrames(), _codec.malformedFrames(), + _codec.droppedFrames(), _listenFailures, + _codec.pendingTransmit(), _codec.pendingReceive()}; + } +}; + +} } // namespace mesh::ethernet diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 99d88518e..6a6ba974b 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -113,6 +113,7 @@ static const char* fetch_error_word(OtaManager::FetchError error) { case OtaManager::FETCH_ERROR_INTEGRITY: return "integrity check"; case OtaManager::FETCH_ERROR_MANIFEST_TIMEOUT: return "manifest timeout"; case OtaManager::FETCH_ERROR_LEAVES_TIMEOUT: return "leaves timeout"; + case OtaManager::FETCH_ERROR_RADIO_SESSION: return "radio session changed"; default: return "none"; } } @@ -178,7 +179,9 @@ static __attribute__((noinline)) bool handle_status(char* reply, OtaContext& c) else snprintf(dl, sizeof dl, "%sdownload: %s %u/%u (%u%%) id=%s %us", c.manager.fetched_is_bootloader() ? "bootloader " : "", - state_word(fs), have, tot, pct, midhx, age); + fs == OtaManager::FETCHING && c.manager.directFetchActive() ? + (c.manager.directFetchConfirmed() ? "direct240" : "direct240 probe") : state_word(fs), + have, tot, pct, midhx, age); } const char* hw = (c.hw_id[0]) ? c.hw_id : "?"; #if OTA_TARGET_NAME_TABLE && OTA_TARGET_NAME_FRONT_CODED diff --git a/src/helpers/ota/OtaDirectConfig.h b/src/helpers/ota/OtaDirectConfig.h new file mode 100644 index 000000000..d667da1d8 --- /dev/null +++ b/src/helpers/ota/OtaDirectConfig.h @@ -0,0 +1,21 @@ +#pragma once + +// One bounded raw-radio slot, shared with the existing KISS/foreign scheduler. +// Normal Packet storage, routed OTA and non-OTA builds remain unchanged. +#ifndef MESH_ENABLE_OTA_KISS_DIRECT + #if defined(ENABLE_OTA) && !(defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) \ + && !(defined(MESH_ENABLE_FOREIGN_RADIO) && !MESH_ENABLE_FOREIGN_RADIO) + #define MESH_ENABLE_OTA_KISS_DIRECT 1 + #else + #define MESH_ENABLE_OTA_KISS_DIRECT 0 + #endif +#endif +#if MESH_ENABLE_OTA_KISS_DIRECT != 0 && MESH_ENABLE_OTA_KISS_DIRECT != 1 +#error "MESH_ENABLE_OTA_KISS_DIRECT must be 0 or 1" +#endif +#if MESH_ENABLE_OTA_KISS_DIRECT && !defined(ENABLE_OTA) +#error "direct KISS OTA requires ENABLE_OTA" +#endif +#if MESH_ENABLE_OTA_KISS_DIRECT && defined(MESH_ENABLE_FOREIGN_RADIO) && !MESH_ENABLE_FOREIGN_RADIO +#error "direct KISS OTA requires the shared raw-radio scheduler" +#endif diff --git a/src/helpers/ota/OtaDirectWire.h b/src/helpers/ota/OtaDirectWire.h new file mode 100644 index 000000000..17adfef34 --- /dev/null +++ b/src/helpers/ota/OtaDirectWire.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include "OtaFormat.h" + +namespace mesh { namespace ota { + +// KISS DATA carries these radio bytes unchanged. KISS escaping stays on the +// host cable, never on RF. The reserved MeshCore version makes old endpoints +// reject the frame before interpreting any OTA bytes; it is always zero-hop. +// This bypasses Packet's routed 184-byte payload, without enlarging its pool. +static constexpr uint8_t OTA_DIRECT_HEADER = 0xF2; // v4, OTA, DIRECT +static constexpr uint16_t OTA_DIRECT_RF_MAX = 255; +static constexpr uint16_t OTA_DIRECT_HEADER_BYTES = 2; +static constexpr uint16_t OTA_DIRECT_MESSAGE_MAX = + OTA_DIRECT_RF_MAX - OTA_DIRECT_HEADER_BYTES; +static constexpr uint16_t OTA_DIRECT_FRAG_DATA = + OTA_DIRECT_MESSAGE_MAX - 9 - 4; // OTA_DATA framing + v2 stream identity + +inline bool ota_direct_wire_marker(const uint8_t* bytes, size_t length) { + return bytes && length && bytes[0] == OTA_DIRECT_HEADER; +} + +inline bool ota_direct_wire_decode(const uint8_t* bytes, size_t length, + const uint8_t*& message, uint16_t& message_len) { + message = nullptr; + message_len = 0; + if (!ota_direct_wire_marker(bytes, length) || length < 3 + || length > OTA_DIRECT_RF_MAX || bytes[1] != 0 + || !ota_is_transfer_message(bytes[2])) return false; + message = bytes + OTA_DIRECT_HEADER_BYTES; + message_len = static_cast(length - OTA_DIRECT_HEADER_BYTES); + return true; +} + +inline uint16_t ota_direct_wire_encode(uint8_t* output, size_t capacity, + const uint8_t* message, uint16_t length) { + if (!output || !message || !length || length > OTA_DIRECT_MESSAGE_MAX + || capacity < size_t(length) + OTA_DIRECT_HEADER_BYTES + || !ota_is_transfer_message(message[0])) return 0; + output[0] = OTA_DIRECT_HEADER; + output[1] = 0; // no path, transport codes, forwarding or Ethernet overhead + memcpy(output + OTA_DIRECT_HEADER_BYTES, message, length); + return static_cast(length + OTA_DIRECT_HEADER_BYTES); +} + +static_assert(OTA_DIRECT_FRAG_DATA == 240, "direct KISS OTA payload budget"); + +} } diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index eed472468..21d780b23 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -10,6 +10,21 @@ namespace mesh { namespace ota { +namespace { +// Construction and transfer state belong to the single mesh-loop owner. This +// process token outlives heap/shared context recreation, without another queue. +uint32_t direct_epoch = 0; +} + +OtaManager::OtaManager() { + invalidateDirectTransport(); +} + +void OtaManager::invalidateDirectTransport() { + direct_epoch = ota_advance_direct_epoch(direct_epoch); + _direct_transport_epoch = direct_epoch; +} + OtaManager::~OtaManager() { free(_catalog_heap); free(_leaves_buf); @@ -17,6 +32,12 @@ OtaManager::~OtaManager() { free(_scratch); } +void OtaManager::set_direct_transport_enabled(bool on) { + if (_direct_transport_enabled != on) invalidateDirectTransport(); + _direct_transport_enabled = on; + if (!on && _direct_session) downgradeDirectTransport(); +} + bool OtaManager::set_speed(float speed) { if (!validSpeed(speed)) return false; if (_speed == speed) return true; @@ -124,6 +145,7 @@ void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) { _fstate = IDLE; _have = 0; _fbc = 0; _paused_from = IDLE; _fetch_error = FETCH_ERROR_NONE; + _direct_session = false; _direct_confirmed = false; _fetch_route = {}; _archive_fetch = false; _validate = false; clearFetchIntent(); _resume_verify_idx = 0; _resume_invalidated = false; @@ -598,6 +620,7 @@ uint32_t OtaManager::proofEgressGapMs() const { } void OtaManager::clearPendingEgress() { + invalidateDirectTransport(); _n_manifest_jobs = 0; _n_serve_jobs = 0; _serve_block_len = 0; @@ -713,13 +736,8 @@ bool OtaManager::loadActiveServeBlock() { if (bs == 0 || bs > OTA_MAX_BLOCK) return false; uint32_t off = (uint32_t)job.block * bs; uint32_t blen = (off + bs <= v->m.payload_size) ? bs : (v->m.payload_size - off); - if (v->read) { - if (!v->read(v->read_ctx, off, _serve_block, blen)) return false; - } else { - memcpy(_serve_block, v->m.payload + off, blen); - } _serve_block_len = (uint16_t)blen; - _serve_wire_block = _serve_block; + _serve_wire_block = nullptr; _serve_wire_len = _serve_block_len; _serve_wire_deflated = false; @@ -731,22 +749,40 @@ bool OtaManager::loadActiveServeBlock() { v->scratch_sz = v->scratch ? OTA_MAX_BLOCK : 0; } _source_buffer_used_at = _now_ms; - if (job.wire_v2 && job.allow_deflate && v->scratch && v->scratch_sz >= blen) { + // A host-precompressed representation is enough to serve DATA: proofs use + // authenticated leaves. Do not pull the same raw block over BLE/USB first. + if (job.wire_v2 && job.allow_deflate && v->read_deflated && + v->scratch && v->scratch_sz >= blen) { uint16_t encoded_len = 0; - bool encoded = false; - if (v->read_deflated) { - encoded = v->read_deflated(v->deflate_ctx, job.block, v->scratch, - (uint16_t)blen, &encoded_len); - } else if (_deflate_encode) { - encoded = _deflate_encode(_deflate_encode_ctx, _serve_block, (uint16_t)blen, - v->scratch, (uint16_t)blen, &encoded_len); - } + const bool encoded = v->read_deflated(v->deflate_ctx, job.block, v->scratch, + (uint16_t)blen, &encoded_len); if (encoded && encoded_len > 0 && encoded_len < blen) { _serve_wire_block = v->scratch; _serve_wire_len = encoded_len; _serve_wire_deflated = true; } } + if (!_serve_wire_block) { + if (v->read) { + if (!v->read(v->read_ctx, off, _serve_block, blen)) return false; + } else { + memcpy(_serve_block, v->m.payload + off, blen); + } + _serve_wire_block = _serve_block; + // Device encoders need raw input; host codec failures retain the existing + // raw fallback instead of allocating another encoder workspace. + if (job.wire_v2 && job.allow_deflate && !v->read_deflated && _deflate_encode && + v->scratch && v->scratch_sz >= blen) { + uint16_t encoded_len = 0; + if (_deflate_encode(_deflate_encode_ctx, _serve_block, (uint16_t)blen, + v->scratch, (uint16_t)blen, &encoded_len) && + encoded_len > 0 && encoded_len < blen) { + _serve_wire_block = v->scratch; + _serve_wire_len = encoded_len; + _serve_wire_deflated = true; + } + } + } mh4(_serve_wire_id, _serve_wire_block, _serve_wire_len); _serve_block_loaded = true; return true; @@ -805,15 +841,16 @@ bool OtaManager::handleReq(const uint8_t* m, uint16_t n) { const uint32_t off = idx * block_size; if (off + blen > v->m.payload_size) blen = v->m.payload_size - off; const bool wire_v2 = ota_req_is_v2(rq.items[i].want_mask); + if (_request_route.direct && !wire_v2) continue; const bool allow_deflate = wire_v2 && (rq.items[i].want_mask & OTA_REQ_V2_ALLOW_DEFLATE) != 0; const bool extended_length = wire_v2 && ota_req_v2_extended_length(rq.items[i].want_mask); const bool block_requires_extended = v->m.block_size() > OTA_DATA_V2_LEGACY_MAX_ENCODED; if (wire_v2 && extended_length != block_requires_extended) continue; - const uint16_t valid_mask = frag_full_mask((blen + - (wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA) - 1) / - (wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA)); + const uint16_t fragment_data = _request_route.direct ? OTA_DIRECT_FRAG_DATA : + (wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA); + const uint16_t valid_mask = frag_full_mask((blen + fragment_data - 1) / fragment_data); const uint16_t requested = wire_v2 ? ota_req_v2_fragments(rq.items[i].want_mask) : rq.items[i].want_mask; const uint16_t want = (uint16_t)(requested & valid_mask); @@ -835,6 +872,10 @@ bool OtaManager::handleReqProof(const uint8_t* m, uint16_t n) { } void OtaManager::serviceEgress(OtaReplyRouteValid route_valid, void* route_ctx) { + if (_fstate == FETCHING && _fetch_route.profile < 2 && route_valid && + !route_valid(route_ctx, _fetch_route)) { + failFetch(FETCH_ERROR_RADIO_SESSION); + } // Metadata comes first so an older receiver gets every manifest fragment before its retry horizon. The // send callback supplies radio-queue backpressure, and a rejected fragment remains in this descriptor. if (serviceManifestEgress(route_valid, route_ctx)) return; @@ -871,12 +912,13 @@ void OtaManager::serviceEgress(OtaReplyRouteValid route_valid, void* route_ctx) popServeJob(); return; } - const uint16_t fragment_data = job.wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA; + const uint16_t fragment_data = job.route.direct ? OTA_DIRECT_FRAG_DATA : + (job.wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA); const uint16_t valid_mask = frag_full_mask( (_serve_wire_len + fragment_data - 1u) / fragment_data); job.pending_mask &= valid_mask; if (job.pending_mask == 0) { - job.proof_ready_at = armedDeadline(_now_ms, proofEgressGapMs()); + job.proof_ready_at = job.route.direct ? 0 : armedDeadline(_now_ms, proofEgressGapMs()); return; } uint8_t fragment = 0; @@ -891,7 +933,7 @@ void OtaManager::serviceEgress(OtaReplyRouteValid route_valid, void* route_ctx) DataMsg dm; memcpy(dm.manifest_id, job.mid, 4); dm.block_idx = job.block; - uint8_t v2_data[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2]; + uint8_t v2_data[OTA_DATA_V2_STREAM_ID_BYTES + OTA_DIRECT_FRAG_DATA]; if (job.wire_v2) { const bool packed = job.extended_length ? ota_data_v2_pack_extended(fragment, _serve_wire_len, dm.frag_off) @@ -909,7 +951,7 @@ void OtaManager::serviceEgress(OtaReplyRouteValid route_valid, void* route_ctx) dm.data = _serve_block + frag_off; dm.data_len = (uint16_t)frag_len; } - uint8_t b[MAX_PACKET_PAYLOAD]; + uint8_t b[OTA_DIRECT_MESSAGE_MAX]; if (emit(b, encode_data(b, sizeof(b), dm), false, &job.route)) { const uint16_t bit = (uint16_t)(1u << fragment); job.pending_mask &= (uint16_t)~bit; @@ -919,7 +961,7 @@ void OtaManager::serviceEgress(OtaReplyRouteValid route_valid, void* route_ctx) // proactive proof into the radio queue at the same instant: the two half-duplex transmissions would // collide and force the receiver onto its multi-second retry tick. Include every response that the // adapter can already have admitted ahead of this proof, then retain a bounded fast-link floor. - job.proof_ready_at = armedDeadline(_now_ms, proofEgressGapMs()); + job.proof_ready_at = job.route.direct ? 0 : armedDeadline(_now_ms, proofEgressGapMs()); } } return; @@ -1274,6 +1316,7 @@ bool OtaManager::anyWireDataReceived() const { void OtaManager::downgradeWireToLegacy() { if (!_wire_v2_session) return; + if (_direct_session) downgradeDirectTransport(); _wire_v2_session = false; _wire_v2_confirmed = false; _wire_allow_deflate = false; @@ -1294,6 +1337,26 @@ void OtaManager::downgradeWireToLegacy() { OTA_DBG("OTA: source uses legacy 160-byte DATA geometry\n"); } +void OtaManager::downgradeDirectTransport() { + if (!_direct_session) return; + invalidateDirectTransport(); + _direct_session = false; + _direct_confirmed = false; + _fetch_route.direct = false; // keep the original physical session binding + _wire_v2_confirmed = false; + _wire_empty_stalls = 0; + for (uint8_t i = 0; i < OTA_FETCH_PIPELINE; ++i) { + const uint32_t block = _reasm[i].block; + clearReassemblySlot(i); + if (block == NO_BLOCK) continue; + _reasm[i].block = block; + const uint16_t fragment_data = fetchFragmentData(); + _reasm[i].need = frag_full_mask((blockLen(block) + fragment_data - 1u) / fragment_data); + } + _flight_dirty = true; + OTA_DBG("OTA: direct source unavailable; retain blocks and use routed DATA geometry\n"); +} + int OtaManager::findReassemblySlot(uint32_t block) const { for (uint8_t slot = 0; slot < OTA_FETCH_PIPELINE; slot++) { if (_reasm[slot].block == block) return slot; @@ -1345,7 +1408,7 @@ uint32_t OtaManager::fetchRetryTimeoutMs() const { : (uint32_t)count_mask_bits((uint16_t)(slot.need & ~slot.mask)) + 1u; } if (active == 0) { - const uint32_t fragment_data = _wire_v2_session ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA; + const uint32_t fragment_data = fetchFragmentData(); const uint32_t fragments = (_fbs + fragment_data - 1) / fragment_data; packets += (fragments + 1) * _pipeline_width; } @@ -1429,6 +1492,7 @@ void OtaManager::clearFetchIntent() { } void OtaManager::failFetch(FetchError error) { + invalidateDirectTransport(); if (error == FETCH_ERROR_STORAGE && _fetch && _fetch->canReconnect() && pauseFetchForDisconnect()) return; if (!_have_desired_mid && _desired_target == 0 && !_archive_fetch) { @@ -1451,6 +1515,7 @@ void OtaManager::failFetch(FetchError error) { } void OtaManager::completeFetch() { + invalidateDirectTransport(); _auto_failure_delay = 0; _fetch_error = FETCH_ERROR_NONE; _fstate = COMPLETE; @@ -1501,6 +1566,7 @@ OtaManager::PullResult OtaManager::startFetch(const uint8_t* mid, uint32_t targe _wire_v2_confirmed = false; _wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr; _wire_empty_stalls = 0; + _direct_session = false; _direct_confirmed = false; _fetch_route = {}; _fexpected_target = target; _validate = validate; // motatool folder-capture warm-start (seed leaf-diff) // A validate pull is a FRESH seed capture, not a resume: the store already holds the seed's payload (not a @@ -1526,6 +1592,7 @@ bool OtaManager::resumeStagedExplicit(const uint8_t* want_mid, uint32_t expected _wire_v2_confirmed = false; _wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr; _wire_empty_stalls = 0; + _direct_session = false; _direct_confirmed = false; _fetch_route = {}; _validate = false; _archive_fetch = false; _desired_target = expected_target; @@ -1539,6 +1606,7 @@ bool OtaManager::resumeStagedExplicit(const uint8_t* want_mid, uint32_t expected bool OtaManager::pauseFetchForDisconnect() { if (!fetchActive()) return false; + invalidateDirectTransport(); if (_fstate != PAUSED) _paused_from = _fstate; _fetch_error = FETCH_ERROR_STORAGE; _fstate = PAUSED; @@ -1673,6 +1741,17 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { _fpoff = payload_off; _floff = leaves_off; _fpsize = payload_size; _fbc = bc; _fbs = bs; _ftotal = total; _have = 0; clearReassembly(); // fresh transfer: drop any prior per-block state + // The successful legacy manifest exchange proves a local source exists on + // this exact radio session. A new zero-hop request probes its direct codec; + // old sources drop the reserved wire version and receive a normal retry. + _direct_session = _direct_transport_enabled && _wire_v2_session && + _max_hops == 0 && _observed_path_transmissions == 1 && + _request_route.profile < 2 && !_request_route.direct; + _direct_confirmed = false; + if (_direct_session) { + _fetch_route = _request_route; + _fetch_route.direct = true; + } // Warm-start (folder-capture): if requested and the image is small enough for the fixed leaves bitmap, // bulk-fetch the target leaves and diff the seed already staged in the store; else just transfer normally. if (_validate && beginLeafDiff()) { @@ -1824,6 +1903,7 @@ bool OtaManager::resumeStagedFor(const uint8_t* want_mid, bool automatic_resume) _wire_v2_confirmed = false; _wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr; _wire_empty_stalls = 0; + _direct_session = false; _direct_confirmed = false; _fetch_route = {}; memcpy(_fid, m.merkle_root, 4); memcpy(_froot, m.merkle_root, 4); _fflags = m.flags; @@ -1945,6 +2025,11 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { DataMsg dm; if (!decode_data(m, n, dm) || !_fetch) return false; if (_fstate != FETCHING || memcmp(dm.manifest_id, _fid, 4) != 0) return false; + if (_request_route.direct && (!_direct_session || + !(_request_route == _fetch_route))) return false; + if (!_request_route.direct && _direct_session && + (_direct_confirmed || _request_route.profile != _fetch_route.profile || + _request_route.generation != _fetch_route.generation)) return false; if (dm.block_idx >= _fbc) return false; bool present; if (!readBlockPresent(dm.block_idx, present)) { @@ -1958,6 +2043,7 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { uint32_t blen = blockLen(dm.block_idx); if ((dm.frag_off & OTA_DATA_V2_MARK) == 0) { + if (_request_route.direct) return false; // direct wire is explicitly v2-only if (dm.frag_off % OTA_FRAG_DATA != 0) return false; // canonical legacy-aligned slices only if ((uint32_t)dm.frag_off + dm.data_len > blen) return false; uint32_t expected_len = blen - dm.frag_off; @@ -1965,6 +2051,7 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { if (dm.data_len != expected_len) return false; // do not mark a short slice as complete uint32_t kf = dm.frag_off / OTA_FRAG_DATA; if (kf >= 16) return false; + if (_direct_session) downgradeDirectTransport(); // canonical old response, no geometry mixing // A deployed source ignores the v2 request bits, masks them against its seven-fragment geometry, and // responds here. Only a fully canonical legacy slice may select that profile; malformed matching-MID // traffic must not force a session-wide downgrade. Never mix it with already accepted v2 bytes. @@ -1989,11 +2076,13 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { if (!unpacked) return false; if (encoded_len > blen || (deflated ? encoded_len >= blen : encoded_len != blen)) return false; const uint16_t fragment_len = (uint16_t)(dm.data_len - OTA_DATA_V2_STREAM_ID_BYTES); - const uint32_t fragment_off = (uint32_t)fragment * OTA_FRAG_DATA_V2; + const uint16_t fragment_data = _request_route.direct ? OTA_DIRECT_FRAG_DATA : OTA_FRAG_DATA_V2; + const uint32_t fragment_off = (uint32_t)fragment * fragment_data; if (fragment_off >= encoded_len || fragment_off + fragment_len > encoded_len) return false; uint32_t expected_len = encoded_len - fragment_off; - if (expected_len > OTA_FRAG_DATA_V2) expected_len = OTA_FRAG_DATA_V2; + if (expected_len > fragment_data) expected_len = fragment_data; if (fragment_len != expected_len) return false; + if (_direct_session && !_request_route.direct) downgradeDirectTransport(); _wire_v2_confirmed = true; const uint8_t* stream_id = dm.data; @@ -2003,7 +2092,7 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { slot.deflated = deflated; memcpy(slot.stream_id, stream_id, sizeof(slot.stream_id)); slot.mask = 0; - slot.need = frag_full_mask((encoded_len + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2); + slot.need = frag_full_mask((encoded_len + fragment_data - 1) / fragment_data); } else if (!slot.wire_v2 || slot.encoded_len != encoded_len || slot.deflated != deflated || memcmp(slot.stream_id, stream_id, sizeof(slot.stream_id)) != 0) { return false; // another seeder's representation; never mix @@ -2011,6 +2100,7 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { _wire_empty_stalls = 0; // a valid v2 source is still serving this flight const uint16_t bit = (uint16_t)(1u << fragment); if (!(slot.need & bit)) return false; + if (_request_route.direct) _direct_confirmed = true; if (slot.mask & bit) return true; memcpy(slot.buf + fragment_off, dm.data + OTA_DATA_V2_STREAM_ID_BYTES, fragment_len); slot.mask |= bit; @@ -2044,7 +2134,8 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { if (!decoded || decoded_len != blen) { _wire_allow_deflate = false; // retry this/future blocks as raw v2 slot.mask = 0; - slot.need = frag_full_mask((blen + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2); + const uint16_t fragment_data = fetchFragmentData(); + slot.need = frag_full_mask((blen + fragment_data - 1) / fragment_data); slot.encoded_len = 0; slot.wire_v2 = false; slot.deflated = false; @@ -2067,6 +2158,8 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) { ProofMsg pm; if (!decode_proof(m, n, pm) || !_fetch) return false; if (_fstate != FETCHING || memcmp(pm.manifest_id, _fid, 4) != 0) return false; + if (_request_route.direct != _direct_session || + (_request_route.direct && !(_request_route == _fetch_route))) return false; if (pm.block_idx >= _fbc) return false; int slot_index = findReassemblySlot(pm.block_idx); if (slot_index < 0 || !_reasm[slot_index].awaiting_proof) { @@ -2146,7 +2239,7 @@ bool OtaManager::requestSlot(uint8_t slot_index) { if (slot.awaiting_proof) { ReqProofMsg rp; memcpy(rp.manifest_id, _fid, 4); rp.block_idx = (uint16_t)slot.block; uint8_t b[16]; - bool sent = emit(b, encode_req_proof(b, sizeof(b), rp), false); + bool sent = emit(b, encode_req_proof(b, sizeof(b), rp), false, fetchReplyRoute()); if (sent) { slot.proof_request_at = 0; noteFetchActivity(); @@ -2162,7 +2255,7 @@ bool OtaManager::requestSlot(uint8_t slot_index) { OTA_DBG("OTA: REQ block=%u want=%04x (have=%u/%u mask=%04x window=%u)\n", (unsigned)slot.block, (unsigned)want, (unsigned)_have, (unsigned)_fbc, (unsigned)slot.mask, (unsigned)_req_count); - bool sent = emit(b, encode_req(b, sizeof(b), rq), false); + bool sent = emit(b, encode_req(b, sizeof(b), rq), false, fetchReplyRoute()); if (sent) noteFetchActivity(); return sent; } @@ -2185,7 +2278,7 @@ bool OtaManager::requestFlight() { OTA_DBG("OTA: REQ flight first=%u blocks=%u width=%u/%u\n", (unsigned)_req_start, (unsigned)_req_count, (unsigned)_pipeline_width, (unsigned)OTA_FETCH_PIPELINE); - bool sent = emit(b, encode_req_window(b, sizeof(b), request), false); + bool sent = emit(b, encode_req_window(b, sizeof(b), request), false, fetchReplyRoute()); _flight_request_pending = !sent; if (sent) noteFetchActivity(); return sent; @@ -2203,7 +2296,7 @@ bool OtaManager::fillPipeline() { ReassemblySlot& slot = _reasm[slot_index]; slot.block = block; slot.mask = 0; - const uint16_t fragment_data = _wire_v2_session ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA; + const uint16_t fragment_data = fetchFragmentData(); slot.need = frag_full_mask((blockLen(block) + fragment_data - 1) / fragment_data); slot.awaiting_proof = false; slot.proof_request_at = 0; @@ -2222,6 +2315,18 @@ void OtaManager::requestMissing() { // Keep the original flight and retry it before evaluating network loss. if (_flight_request_pending) { requestFlight(); return; } + // An unanswered first probe must still reach old sources promptly. Once + // direct DATA has proved support, one lost request/burst is not evidence of + // a legacy source: use the sparse retry below before changing geometry. Its + // existing empty-flight counter advances only after local admission, and + // valid DATA resets it, so backpressure never consumes this one retry. + if (_direct_session && !anyWireDataReceived() && + (!_direct_confirmed || _wire_empty_stalls >= 1)) { + downgradeDirectTransport(); + requestFlight(); + return; + } + // No v2 DATA at all means the request may have reached only deployed sources which ignored its profile // bits (or the first response burst was lost). Retry once in the universal legacy profile instead of // pinning the session to a feature it has not observed on air. @@ -2255,7 +2360,7 @@ void OtaManager::requestMissing() { const uint32_t block = reassembly.block; clearReassemblySlot(slot); reassembly.block = block; - const uint16_t fragment_data = _wire_v2_session ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA; + const uint16_t fragment_data = fetchFragmentData(); reassembly.need = frag_full_mask((blockLen(block) + fragment_data - 1u) / fragment_data); } const bool partial_v2 = _wire_v2_session && reassembly.wire_v2 && reassembly.mask != 0 && @@ -2264,7 +2369,8 @@ void OtaManager::requestMissing() { // A v2 seeder may disappear after establishing a representation while only deployed legacy seeders // remain. Keep one sparse retry, then fall back the whole flight to the universal profile. Clearing // every slot prevents geometry/representation mixing and guarantees that old sources can take over. - downgradeWireToLegacy(); + if (_direct_session) downgradeDirectTransport(); + else downgradeWireToLegacy(); } if (requestSlot(slot)) { // Advance fallback evidence only when the sparse retry enters the @@ -2376,6 +2482,8 @@ void OtaManager::loop() { // ---------------- dispatch ---------------- bool OtaManager::on_message(const uint8_t* msg, uint16_t len, OtaReplyRoute route) { + if (route.direct && (!_direct_transport_enabled || route.profile >= 2 || + len > OTA_DIRECT_MESSAGE_MAX)) return false; const OtaReplyRoute previous = _request_route; _request_route = route; const bool consumed = dispatchMessage(msg, len); diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 24788cf9c..dfefbd71f 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -6,6 +6,7 @@ #include "OtaMemoryPolicy.h" #include "OtaTiming.h" #include "OtaProtocol.h" +#include "OtaDirectWire.h" #include "OtaByteIO.h" #include "OtaStore.h" #include "MotaContainer.h" @@ -35,12 +36,18 @@ typedef bool (*OtaSend)(void* ctx, const uint8_t* msg, uint16_t len, bool flood) struct OtaReplyRoute { uint32_t generation = 0; uint8_t profile = 0xFF; // no radio affinity (portable callers) + bool direct = false; // negotiated zero-hop raw/KISS wire, not routed Packet data bool operator==(const OtaReplyRoute& other) const { - return profile == other.profile && generation == other.generation; + return profile == other.profile && generation == other.generation && direct == other.direct; } }; +static_assert(sizeof(OtaReplyRoute) == 8, "Direct OTA must use existing route padding"); typedef bool (*OtaReplyRouteValid)(void* ctx, const OtaReplyRoute& route); +inline constexpr uint32_t ota_advance_direct_epoch(uint32_t previous) { + return previous == UINT32_MAX ? 1u : previous + 1u; +} + // Device admission is separate from portable block transport. Signed-only // automatic fetches must authenticate the manifest before touching staging. typedef bool (*OtaManifestAdmit)(void* ctx, const MotaManifest& manifest); @@ -260,7 +267,7 @@ static_assert(OTA_AUTOFETCH_FAILURE_BACKOFF_MS > 0u && class OtaManager { public: - OtaManager() = default; + OtaManager(); ~OtaManager(); OtaManager(const OtaManager&) = delete; OtaManager& operator=(const OtaManager&) = delete; @@ -292,7 +299,8 @@ public: FETCH_ERROR_STORAGE, FETCH_ERROR_INTEGRITY, FETCH_ERROR_MANIFEST_TIMEOUT, - FETCH_ERROR_LEAVES_TIMEOUT + FETCH_ERROR_LEAVES_TIMEOUT, + FETCH_ERROR_RADIO_SESSION }; // Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is @@ -385,6 +393,13 @@ public: // Defaults on for moving-forward 171-byte transfers. This switch exists for qualification and emergency // rollback; disabling it produces byte-for-byte legacy OTA_REQ/OTA_DATA behavior. void set_wire_v2_enabled(bool on) { _wire_v2_enabled = on; } + // Explicit transport capability, never inferred from block size or a sender. + // A fetch probes only after a validated zero-hop manifest at max_hops=0. + void set_direct_transport_enabled(bool on); + bool directFetchActive() const { return _direct_session; } + bool directFetchConfirmed() const { return _direct_session && _direct_confirmed; } + OtaReplyRoute directFetchRoute() const { return _fetch_route; } + uint32_t directTransportEpoch() const { return _direct_transport_epoch; } // Resume a fetch from a container already persisted in the store (after a reboot). want_mid=nullptr is // automatic adoption and rechecks current autofetch/target/version policy; a non-null MID is an explicit @@ -523,7 +538,10 @@ public: // Max OTA flood reach in HOPS (runtime-tunable, persisted): a node accepts OTA from up to N hops away and // relays packets still under N hops; 0 = direct only (accept path_count 0, never relay). Bounds duty-cycle // when crossing repeaters. Default OTA_HOP_LIMIT_DEFAULT. Set via `ota config hops`. - void set_max_hops(uint8_t h) { _max_hops = h; } + void set_max_hops(uint8_t h) { + _max_hops = h; + if (h != 0 && _direct_session) downgradeDirectTransport(); + } uint8_t max_hops() const { return _max_hops; } bool fetched_is_signed() const { return (_fflags & MFLAG_SIGNED) != 0; } // flags of the fetched manifest bool fetched_is_bootloader() const { return (_fflags & MFLAG_BOOTLOADER) != 0; } @@ -549,6 +567,7 @@ public: // Drop the current fetch session back to IDLE so a fresh `ota pull` / advert starts a new one. void reset_session() { + invalidateDirectTransport(); _fstate = IDLE; _have = 0; _fbc = 0; _ftotal = 0; _fflags = 0; _paused_from = IDLE; _req_count = 0; _mf_retries = 0; @@ -562,6 +581,7 @@ public: _resume_verify_idx = 0; _resume_invalidated = false; _resume_merkle.reset(); _wire_empty_stalls = 0; + _direct_session = false; _direct_confirmed = false; _fetch_route = {}; } FetchState fetchState() const { return _fstate; } @@ -695,6 +715,15 @@ private: uint16_t wireRequestMask(uint32_t block, uint16_t need, uint16_t received, uint16_t encoded_len) const; void downgradeWireToLegacy(); // old source answered/ignored the v2 request profile + void downgradeDirectTransport(); // preserve committed blocks; discard unverified geometry + void invalidateDirectTransport(); // retire already-admitted direct raw frames + uint16_t fetchFragmentData() const { + return _direct_session ? OTA_DIRECT_FRAG_DATA : + (_wire_v2_session ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA); + } + const OtaReplyRoute* fetchReplyRoute() const { + return _fetch_route.profile < 2 ? &_fetch_route : nullptr; + } bool anyWireDataReceived() const; uint32_t blockLen(uint32_t i) const; @@ -808,6 +837,11 @@ private: uint8_t _max_hops = OTA_HOP_LIMIT_DEFAULT; // OTA flood reach in hops; 0=direct only (persisted) uint8_t _fflags = 0; // flags of the manifest currently being fetched bool _wire_v2_enabled = true; // advertise v2 in OTA_REQ while retaining transparent fallback + bool _direct_transport_enabled = false; + bool _direct_session = false; + bool _direct_confirmed = false; + OtaReplyRoute _fetch_route; + uint32_t _direct_transport_epoch; bool _wire_v2_session = true; // false after an old source responds or the first request stalls bool _wire_v2_confirmed = false; // at least one structurally valid v2 DATA packet was observed bool _wire_allow_deflate = false; // session may request compressed blocks only with a decoder diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 8c0c8daba..fc55d2040 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -44,6 +44,9 @@ public: #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif +#if MESH_ENABLE_ETHERNET_WIRE + bool supportsEthernetTunnel() const override { return true; } +#endif protected: float morseReceiverBandwidthKHz() const override { return 4.8f; } @@ -70,15 +73,24 @@ protected: return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE && RadioLibWrapper::restoreCarrierWaveModem(); } -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE bool applyProtocol(mesh::RadioProfileProtocol protocol) override { - if (protocol != mesh::RadioProfileProtocol::MeshCore - && protocol != mesh::RadioProfileProtocol::Meshtext) return false; - // Respect an explicitly disabled wire implementation, including builds - // that retain other beacon or Companion features. + uint8_t sync = 0x12; + if (protocol == mesh::RadioProfileProtocol::Meshtext) { + if (!supportsMeshtext()) return false; + sync = 0x2b; +#if MESH_ENABLE_ETHERNET_WIRE + } else if (protocol == mesh::RadioProfileProtocol::Ethernet) { + if (!supportsEthernetTunnel()) return false; + sync = 0x16; +#endif + } else if (protocol != mesh::RadioProfileProtocol::MeshCore) return false; + // No alternate wire implementation means no modem write is needed. + // Ethernet-only builds must still restore the MeshCore syncword. +#if !MESH_ENABLE_ETHERNET_WIRE if (!supportsMeshtext()) return protocol == mesh::RadioProfileProtocol::MeshCore; +#endif auto* radio = static_cast(_radio); - const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; // Stage shared packet invariants only in a proven owned RX hop; normal // initialization/rollback still repairs the complete RadioLib state. return radio->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index c1e5ab539..ee42f7819 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -16,6 +16,9 @@ public: #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif +#if MESH_ENABLE_ETHERNET_WIRE + bool supportsEthernetTunnel() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -23,11 +26,18 @@ public: } protected: -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE bool applyProtocol(mesh::RadioProfileProtocol protocol) override { - if (protocol != mesh::RadioProfileProtocol::MeshCore - && protocol != mesh::RadioProfileProtocol::Meshtext) return false; - const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; + uint8_t sync = 0x12; + if (protocol == mesh::RadioProfileProtocol::Meshtext) { + if (!supportsMeshtext()) return false; + sync = 0x2b; +#if MESH_ENABLE_ETHERNET_WIRE + } else if (protocol == mesh::RadioProfileProtocol::Ethernet) { + if (!supportsEthernetTunnel()) return false; + sync = 0x16; +#endif + } else if (protocol != mesh::RadioProfileProtocol::MeshCore) return false; return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; } #endif diff --git a/src/helpers/radiolib/HilTxFrequencyGuard.h b/src/helpers/radiolib/HilTxFrequencyGuard.h new file mode 100644 index 000000000..bb3654e4c --- /dev/null +++ b/src/helpers/radiolib/HilTxFrequencyGuard.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +// Opt-in bench safety boundary, not a user setting or production default. +// Receive profiles may use other frequencies; no transmitter may do so. +inline bool hilTransmitFrequencyAllowed(float frequency_mhz) { +#if defined(MESH_HIL_TX_FREQ_MHZ) + static_assert(MESH_HIL_TX_FREQ_MHZ >= 150 && MESH_HIL_TX_FREQ_MHZ <= 2500, + "HIL TX frequency must be an explicit valid MHz value"); + return isfinite(frequency_mhz) + && fabsf(frequency_mhz - static_cast(MESH_HIL_TX_FREQ_MHZ)) <= 0.0001f; +#else + (void)frequency_mhz; + return true; +#endif +} + +// Runtime preflight must identify the same compiled boundary as the RF gate. +// No setter exists, and ordinary production images expose no such command. +inline bool handleHilTxFrequencyCommand(const char* command, char* reply, size_t capacity) { +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!command || !reply || !capacity || strcmp(command, "get lab.tx") != 0) return false; + snprintf(reply, capacity, + "> HIL TX lock=%.3f MHz; off-frequency TX blocked; primary RX-only when off-frequency", + static_cast(MESH_HIL_TX_FREQ_MHZ)); + return true; +#else + (void)command; + (void)reply; + (void)capacity; + return false; +#endif +} + +} // namespace mesh diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 180057f06..68e12c3c8 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -252,6 +252,10 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { ) && _cw_active) return false; if (_cw_active) { if (_cw_stopping) return false; +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!hilPacketTransmitAllowed() || _cw_profile != _active_profile + || _cw_generation != _profile_generation) return false; +#endif // Permit meter sweeps through the board's normal power/PA calibration. // SX127x's power setter enters standby, so re-key without resetting the // modem or extending the original CW deadline. @@ -331,7 +335,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() { restored = _radio->setPreambleLength(currentPreambleLength()) == RADIOLIB_ERR_NONE; } -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE restored = restored && applyProtocol(_physical_protocol); #endif if (_dbm_valid) { @@ -359,7 +363,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, const uint8_t previous_sf = _cur_sf; const uint8_t previous_cr = _cur_cr; const uint16_t previous_preamble = _physical_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE const auto previous_protocol = _physical_protocol; #endif const uint8_t previous_profile = _active_profile; @@ -376,7 +380,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, _physical_preamble = profilePreamble(0); bool success = applyParams(freq, bw, sf, cr); -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE success = success && applyProtocol(requested.protocol); if (success) _physical_protocol = requested.protocol; #endif @@ -410,7 +414,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, _profile_generation = previous_generation; _profile_refresh_required = previous_refresh; _physical_preamble = previous_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE _physical_protocol = previous_protocol; #endif _rx_ps_enabled = previous_rxps; @@ -421,7 +425,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, bool restored = had_previous_params && _radio->standby() == RADIOLIB_ERR_NONE && applyParams(previous_freq, previous_bw, previous_sf, previous_cr); -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE restored = restored && applyProtocol(previous_protocol); #endif @@ -441,6 +445,11 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const { if (!mesh::RadioProfiles::valid(p)) return false; if (p.protocol == mesh::RadioProfileProtocol::Meshtext && !supportsMeshtext()) return false; +#if MESH_ENABLE_ETHERNET_WIRE + if (p.protocol == mesh::RadioProfileProtocol::Ethernet && !supportsEthernetTunnel()) return false; +#else + if (p.protocol == mesh::RadioProfileProtocol::Ethernet) return false; +#endif #if MESH_RADIO_PROFILE_CHIP == 1276 return p.freq >= 137 && p.freq <= 1020 && p.sf >= 7 && p.bw <= 500; #elif MESH_RADIO_PROFILE_CHIP == 1272 @@ -485,12 +494,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { const bool old_refresh = _profile_refresh_required; const bool old_params_valid = _params_valid; const uint16_t old_preamble = _physical_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE const auto old_protocol = _physical_protocol; #endif _physical_preamble = preamble; bool applied = applyParams(p.freq, p.bw, p.sf, p.cr); -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE // Changing only the protocol still changes the sync word. Conversely a // failed write must not publish the new generation or cached wire format. if (applied && (_physical_protocol != p.protocol || _profile_refresh_required)) { @@ -520,12 +529,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { _profile_generation = old_generation; _profile_refresh_required = old_refresh; _physical_preamble = old_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE _physical_protocol = old_protocol; #endif bool restored = old_params_valid && _radio->standby() == RADIOLIB_ERR_NONE && applyParams(old_freq, old_bw, old_sf, old_cr); -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE restored = restored && applyProtocol(old_protocol); #endif if (!restored) restored = restoreAfterDeepInit(); @@ -573,7 +582,7 @@ void RadioLibWrapper::cacheBeaconRestoreParams() { _active_profile = profile; _profile_generation = _profiles.generation[profile]; _physical_preamble = profilePreamble(profile); -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE _physical_protocol = p.protocol; #endif _profile_refresh_required = true; @@ -583,6 +592,10 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit( const mesh::RadioProfileParams& p, int8_t power) { using Result = mesh::RadioParamApplyResult; if (_beacon_owned || _cw_active) return Result::BUSY; +#if defined(MESH_HIL_TX_FREQ_MHZ) + _hil_beacon_tx_frequency_mhz = 0; + if (!mesh::hilTransmitFrequencyAllowed(p.freq)) return Result::FAILED; +#endif if (!_params_valid || !_dbm_valid || !getBeaconAirtime(p, MAX_TRANS_UNIT)) return Result::FAILED; if (isCalibratingNoiseFloor()) return Result::BUSY; @@ -602,7 +615,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit( ? 16 : (p.preamble ? p.preamble : 32); bool applied = _radio->standby() == RADIOLIB_ERR_NONE && applyParams(p.freq, p.bw, p.sf, p.cr); -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE applied = applied && applyProtocol(p.protocol); if (applied) _physical_protocol = p.protocol; #endif @@ -611,6 +624,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit( _rx_hold_continuous = false; state = STATE_IDLE; // Do not startReceive: this is a TX-only excursion, not a third RX profile. +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (applied) _hil_beacon_tx_frequency_mhz = p.freq; +#endif return applied ? Result::APPLIED : Result::FAILED; } @@ -630,6 +646,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() { using Result = mesh::RadioParamApplyResult; if (!_beacon_owned) return Result::APPLIED; if (state == STATE_TX_WAIT) return Result::BUSY; +#if defined(MESH_HIL_TX_FREQ_MHZ) + _hil_beacon_tx_frequency_mhz = 0; +#endif const uint32_t now = millis(); if (_beacon_restore_at && static_cast(now - _beacon_restore_at) < 0) return Result::BUSY; _beacon_restoring = true; @@ -639,7 +658,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() { && _radio->standby() == RADIOLIB_ERR_NONE; beginProfileRetune(false); restored = restored && applyParams(p.freq, p.bw, p.sf, p.cr); -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE restored = restored && applyProtocol(p.protocol); #endif restored = restored && applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE; @@ -682,6 +701,10 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t prof } mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool reply_rx_override) { +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (profile > 1 || !mesh::hilTransmitFrequencyAllowed(_profiles.params(profile).freq)) + return mesh::RadioParamApplyResult::FAILED; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return mesh::RadioParamApplyResult::BUSY; #endif @@ -729,7 +752,7 @@ void RadioLibWrapper::serviceProfileScan() { } if (_profiles.enabled() && !_profiles.switchTestReady()) { // A bounded startup self-test uses the real RX-to-RX retune path for the - // active pair. Keep the 128-symbol provisional preamble until four good + // active pair. Keep the maximum automatic provisional preamble until four good // samples in each direction are available. A packet, TX, or BUSY radio // defers the remaining hops without counting an invalid measurement. for (uint8_t hops = 0; hops < 2 * mesh::RadioProfiles::SwitchTestSamplesPerDirection; @@ -1221,6 +1244,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin if (!duration_ms) return !_cw_active || stopCarrierWave() ? Result::APPLIED : Result::FAILED; if (profile > 1 || duration_ms > CarrierWaveMaxMillis || !_params_valid || !_profiles.canTransmit(profile)) return Result::FAILED; +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!mesh::hilTransmitFrequencyAllowed(_profiles.params(profile).freq)) return Result::FAILED; +#endif if (_cw_active) { if (_morse_owned #if MESH_ENABLE_MORSE_RX @@ -1243,6 +1269,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin startRecv(); return Result::FAILED; } +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!hilPacketTransmitAllowed()) { + startRecv(); + return Result::FAILED; + } +#endif _radio->clearPacketReceivedAction(); state = STATE_IDLE; _rx_ps_armed = false; @@ -1277,6 +1309,9 @@ mesh::MorseTxResult RadioLibWrapper::startMorseTx(float frequency_mhz, const Result valid_text = mesh::MorseTx::validate(text, wpm); if (valid_text != Result::Started) return valid_text; if (!_params_valid || !isfinite(frequency_mhz)) return Result::InvalidFrequency; +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!mesh::hilTransmitFrequencyAllowed(frequency_mhz)) return Result::InvalidFrequency; +#endif // Only the carrier frequency matters here: existing LoRa SF/BW/protocol // constraints must not accidentally limit an unmodulated transmission. if (!validateMorseFrequency(frequency_mhz)) return Result::InvalidFrequency; @@ -1338,6 +1373,12 @@ bool RadioLibWrapper::serviceMorseTx() { if (static_cast(now - _cw_retry_at) >= 0) stopCarrierWave(); return true; } +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!mesh::hilTransmitFrequencyAllowed(_morse_frequency)) { + stopCarrierWave(); + return true; + } +#endif if (_morse_generation[0] != _profiles.generation[0] || _morse_generation[1] != _profiles.generation[1]) { stopCarrierWave(); @@ -1620,6 +1661,12 @@ bool RadioLibWrapper::serviceCarrierWave() { #endif if (serviceMorseTx()) return true; if (_cw_active) { +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!hilPacketTransmitAllowed()) { + stopCarrierWave(); + return true; + } +#endif const uint32_t now = millis(); if ((_cw_stopping && static_cast(now - _cw_retry_at) >= 0) || (!_cw_stopping && (static_cast(now - _cw_until) >= 0 @@ -1996,7 +2043,37 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { return airtime_us / 1000; } +#if defined(MESH_HIL_TX_FREQ_MHZ) +bool RadioLibWrapper::hilPacketTransmitAllowed() const { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) { + return !_beacon_restoring + && mesh::hilTransmitFrequencyAllowed(_hil_beacon_tx_frequency_mhz); + } +#endif + // Check the committed physical cache, not only a requested profile. Failed + // retunes, stale generations, and an unresolved refresh never authorize RF. + if (!_params_valid || _profile_refresh_required || _active_profile > 1 + || (_active_profile == 1 && !_profiles.enabled()) + || _profile_generation != _profiles.generation[_active_profile]) return false; + const auto& p = _profiles.params(_active_profile); + return mesh::hilTransmitFrequencyAllowed(_cur_freq) + && _cur_freq == p.freq && _cur_bw == p.bw && _cur_sf == p.sf && _cur_cr == p.cr + && _physical_preamble == profilePreamble(_active_profile) +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE + && _physical_protocol == p.protocol +#endif + ; +} +#endif + bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { +#if defined(MESH_HIL_TX_FREQ_MHZ) + if (!hilPacketTransmitAllowed()) { + MESH_DEBUG_PRINTLN("HIL: packet TX blocked outside the proven allowed frequency"); + return false; + } +#endif if (_cw_active || hasPendingRadioInterrupt()) return false; _rx_hold_continuous = false; if (_rx_ps_armed) { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 27c8f9e37..1bf5e1211 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -8,6 +8,7 @@ #include "CadTiming.h" #include "RadioAirtime.h" #include "RXPowerSaving.h" +#include "HilTxFrequencyGuard.h" // Fallback RX powersaving timings, only used until setRxPowerSaving() is called // (begin() always applies the persisted values). The authoritative defaults live @@ -89,7 +90,7 @@ protected: mesh::RadioProfiles _profiles; uint8_t _active_profile = 0; uint16_t _physical_preamble = 0; -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore; // Wire settings are a transaction with the modulation tuple. Unsupported // wrappers decline rather than claiming a profile was applied. @@ -104,6 +105,9 @@ protected: bool _profile_rxps_suspended = false; bool _profile_saved_rxps = false; bool _profile_refresh_required = false; +#if defined(MESH_HIL_TX_FREQ_MHZ) + bool hilPacketTransmitAllowed() const; +#endif #if MESH_ENABLE_TX_BEACONS // The normal cache is never overwritten by a transmit-only excursion. // Retain ownership through failed TX and restoration retries. @@ -112,6 +116,11 @@ protected: uint8_t _beacon_restore_profile = 0; uint32_t _beacon_restore_at = 0; uint32_t _beacon_cad_timeout = 0; +#if defined(MESH_HIL_TX_FREQ_MHZ) + // Beacon excursions deliberately leave the normal profile cache intact. + // Zero revokes TX proof until the complete physical setup has succeeded. + float _hil_beacon_tx_frequency_mhz = 0; +#endif void cacheBeaconRestoreParams(); #endif bool _cw_active = false; diff --git a/test/fixtures/companion_uncached_storage/test.cpp b/test/fixtures/companion_uncached_storage/test.cpp index 7781ae9ad..5c3e2de58 100644 --- a/test/fixtures/companion_uncached_storage/test.cpp +++ b/test/fixtures/companion_uncached_storage/test.cpp @@ -166,6 +166,16 @@ class DataStore { #endif struct IdentityAdapter { bool recover(const char*) { return true; } +#if defined(ESP32_PLATFORM) + IdentityLoadResult loadResult(const char* name, mesh::LocalIdentity& identity) { + // The ESP32 entry point now delegates absence/error discrimination to + // IdentityStore, not a separate exists() short circuit. This fixture's + // adapter has no NVS; end-to-end migration cases use the real store. + if (filesystem.stat_error) return IdentityLoadResult::Unreadable; + if (!filesystem.files.count(identity_path)) return IdentityLoadResult::Missing; + return load(name, identity) ? IdentityLoadResult::Loaded : IdentityLoadResult::Unreadable; + } +#endif bool load(const char*, mesh::LocalIdentity&) { File file = filesystem.open(identity_path); uint8_t data[96]; diff --git a/test/fixtures/ethernet_kiss/Arduino.h b/test/fixtures/ethernet_kiss/Arduino.h new file mode 100644 index 000000000..c646593de --- /dev/null +++ b/test/fixtures/ethernet_kiss/Arduino.h @@ -0,0 +1,5 @@ +#pragma once +#include +#include +namespace fake { inline uint32_t now = 0; } +inline unsigned long millis() { return fake::now; } diff --git a/test/fixtures/ethernet_kiss/WiFi.h b/test/fixtures/ethernet_kiss/WiFi.h new file mode 100644 index 000000000..b288e2f05 --- /dev/null +++ b/test/fixtures/ethernet_kiss/WiFi.h @@ -0,0 +1,97 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +static constexpr int WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2, WIFI_AP_STA = 3; +static constexpr int WL_DISCONNECTED = 6, WL_CONNECTED = 3; + +class IPAddress { + uint32_t _value; +public: + explicit IPAddress(uint32_t value = 0) : _value(value) {} + operator uint32_t() const { return _value; } +}; + +struct FakeSocket { + bool connected = true; + bool eof = false; + int readError = 0, writeError = 0; + size_t writeQuota = 128; + size_t reads = 0, writes = 0; + std::deque input; + std::vector output; +}; + +namespace fake { +inline std::map> sockets; +inline std::deque pending; +inline int nextFd = 3; +inline uint32_t boundAddress = 0; +inline uint16_t boundPort = 0; +inline bool listenFails = false; +inline unsigned begins = 0, ends = 0; +inline int newSocket() { + const int fd = nextFd++; + sockets[fd] = std::make_shared(); + pending.push_back(fd); + return fd; +} +} + +class WiFiClient { + std::shared_ptr _socket; + int _fd = -1; +public: + WiFiClient() = default; + explicit WiFiClient(int fd) : _socket(fake::sockets.at(fd)), _fd(fd) {} + explicit operator bool() { return connected(); } + bool connected() { return _socket && _socket->connected; } + void stop() { if (_socket) _socket->connected = false; _socket.reset(); _fd = -1; } + void setNoDelay(bool) {} + int fd() const { return _fd; } +}; + +class WiFiServer { + uint32_t _address; + uint16_t _port; + bool _listening = false; +public: + explicit WiFiServer(uint16_t port = 80, uint8_t = 4) : _address(0), _port(port) {} + WiFiServer(const IPAddress& address, uint16_t port, uint8_t = 4) + : _address(address), _port(port) {} + void begin() { + ++fake::begins; + _listening = !fake::listenFails; + if (_listening) { fake::boundAddress = _address; fake::boundPort = _port; } + } + void end() { + ++fake::ends; + _listening = false; + while (!fake::pending.empty()) { + fake::sockets.at(fake::pending.front())->connected = false; + fake::pending.pop_front(); + } + } + explicit operator bool() { return _listening; } + WiFiClient available() { + if (!_listening || fake::pending.empty()) return WiFiClient(); + const int fd = fake::pending.front(); + fake::pending.pop_front(); + return WiFiClient(fd); + } +}; + +class WiFiClass { +public: + int mode = WIFI_STA; + int state = WL_CONNECTED; + uint32_t address = 0x01020304; + int getMode() const { return mode; } + int status() const { return state; } + IPAddress localIP() const { return IPAddress(address); } +}; +inline WiFiClass WiFi; diff --git a/test/fixtures/ethernet_kiss/lwip/sockets.h b/test/fixtures/ethernet_kiss/lwip/sockets.h new file mode 100644 index 000000000..a755f314d --- /dev/null +++ b/test/fixtures/ethernet_kiss/lwip/sockets.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include + +static constexpr int MSG_DONTWAIT = 0x40; + +inline int recv(int fd, void* destination, size_t length, int flags) { + if (flags != MSG_DONTWAIT) { errno = EINVAL; return -1; } + FakeSocket& socket = *fake::sockets.at(fd); + ++socket.reads; + if (socket.readError) { errno = socket.readError; return -1; } + if (!socket.connected || (socket.eof && socket.input.empty())) return 0; + if (socket.input.empty()) { errno = EAGAIN; return -1; } + length = std::min(length, socket.input.size()); + for (size_t i = 0; i < length; ++i) { + static_cast(destination)[i] = socket.input.front(); + socket.input.pop_front(); + } + return static_cast(length); +} + +inline int send(int fd, const void* source, size_t length, int flags) { + if (flags != MSG_DONTWAIT) { errno = EINVAL; return -1; } + FakeSocket& socket = *fake::sockets.at(fd); + ++socket.writes; + if (socket.writeError) { errno = socket.writeError; return -1; } + if (!socket.connected) { errno = EPIPE; return -1; } + if (!socket.writeQuota) { errno = EAGAIN; return -1; } + length = std::min(length, socket.writeQuota); + const uint8_t* bytes = static_cast(source); + socket.output.insert(socket.output.end(), bytes, bytes + length); + return static_cast(length); +} diff --git a/test/fixtures/ethernet_kiss/test_ethernet_kiss.cpp b/test/fixtures/ethernet_kiss/test_ethernet_kiss.cpp new file mode 100644 index 000000000..0403ea8db --- /dev/null +++ b/test/fixtures/ethernet_kiss/test_ethernet_kiss.cpp @@ -0,0 +1,320 @@ +#include +#include +#include +#include +#include + +using namespace mesh::ethernet; + +static std::vector frame(size_t length = 244) { + std::vector data(length); + for (size_t i = 0; i < length; ++i) data[i] = static_cast(i); + return data; +} + +static std::vector encoded(const std::vector& data, uint8_t command = 0) { + std::vector output{Fend, command}; + for (const uint8_t byte : data) { + if (byte == Fend || byte == Fesc) { + output.push_back(Fesc); + output.push_back(byte == Fend ? Tfend : Tfesc); + } else output.push_back(byte); + } + output.push_back(Fend); + return output; +} + +static void feed(KissCodec& codec, const std::vector& input) { + for (uint8_t byte : input) assert(codec.feed(byte)); +} + +static void expect(KissCodec& codec, const std::vector& expected) { + const uint8_t* bytes; + size_t length; + assert(codec.peekTransmit(bytes, length)); + assert(length == expected.size() && !memcmp(bytes, expected.data(), length)); + codec.consumeTransmit(); +} + +static void append(int fd, const std::vector& data) { + auto& input = fake::sockets.at(fd)->input; + input.insert(input.end(), data.begin(), data.end()); +} + +static int connect(KissServer& server) { + server.loop(true); + const int fd = fake::newSocket(); + server.loop(true); + assert(server.status().connected); + return fd; +} + +static void codecRoundtrip() { + for (size_t length = MinimumFrameSize; length <= MaxFrameSize; ++length) { + const auto data = frame(length); + const auto wire = encoded(data); + for (size_t cut = 0; cut <= wire.size(); ++cut) { + KissCodec codec; + for (size_t i = 0; i < cut; ++i) assert(codec.feed(wire[i])); + for (size_t i = cut; i < wire.size(); ++i) assert(codec.feed(wire[i])); + expect(codec, data); + assert(codec.receivedFrames() == 1); + } + } + KissCodec codec; + const std::vector worst(MaxFrameSize, Fend); + assert(codec.queueReceive(worst.data(), worst.size())); + const uint8_t* bytes; + size_t length; + assert(codec.peekOutput(bytes, length) && length == MaxEncodedSize); + assert(std::vector(bytes, bytes + length) == encoded(worst)); + codec.consumeOutput(length); + assert(codec.sentFrames() == 1); +} + +static void codecMalformed() { + KissCodec codec; + feed(codec, {1, 2, 3, Fesc, Tfend}); + feed(codec, encoded(frame(), 0x10)); // Not port 0. + feed(codec, encoded(frame(), 0x01)); // Not DATA. + feed(codec, encoded(frame(13))); + feed(codec, encoded(frame(256))); + feed(codec, {Fend, 0, 1, Fesc, 42, 2, Fend}); + feed(codec, {Fend, 0, Fesc, Fend}); + assert(codec.pendingTransmit() == 0 && codec.malformedFrames() == 4); + feed(codec, encoded(frame())); + expect(codec, frame()); + assert(!codec.queueReceive(nullptr, 15)); + assert(!codec.queueReceive(frame(13).data(), 13)); + assert(!codec.queueReceive(frame(256).data(), 256)); +} + +static void codecBackpressure() { + KissCodec codec; + const auto data = frame(); + feed(codec, encoded(data)); + feed(codec, encoded(data)); + assert(!codec.canAcceptByte() && !codec.feed(0)); + expect(codec, data); + assert(codec.canAcceptByte()); + feed(codec, encoded(data)); + expect(codec, data); + expect(codec, data); + assert(codec.queueReceive(data.data(), data.size())); + assert(codec.queueReceive(data.data(), data.size())); + assert(!codec.queueReceive(data.data(), data.size())); + std::vector actual; + const uint8_t* bytes; + size_t length; + while (codec.peekOutput(bytes, length)) { + const size_t count = length < 7 ? length : 7; + actual.insert(actual.end(), bytes, bytes + count); + codec.consumeOutput(count); + } + auto expected = encoded(data); + const auto second = expected; + expected.insert(expected.end(), second.begin(), second.end()); + assert(actual == expected && codec.sentFrames() == 2 && codec.droppedFrames() == 1); +} + +static void codecResetFuzz() { + KissCodec codec; + auto data = frame(); + feed(codec, encoded(data)); + feed(codec, {Fend, 0, Fesc}); + assert(codec.queueReceive(data.data(), data.size())); + codec.consumeOutput(5); + codec.resetPending(); + assert(!codec.pendingReceive() && !codec.pendingTransmit()); + assert(codec.receivedFrames() == 1); + feed(codec, {Tfend, 1, 2, 3, Fend}); + assert(!codec.pendingTransmit()); + uint32_t random = 0x12345678; + for (unsigned i = 0; i < 100000; ++i) { + random = random * 1664525U + 1013904223U; + if ((random & 127) == 0) codec.resetPending(); + else if ((random & 7) == 0) codec.consumeTransmit(); + else codec.feed(static_cast(random >> 24)); + assert(codec.pendingTransmit() <= QueueDepth); + } + codec.resetPending(); + feed(codec, encoded(data)); + expect(codec, data); +} + +static void serverStation() { + KissServer server; + server.loop(false); + assert(!fake::begins && !server.status().enabled); + WiFi.mode = WIFI_AP; + server.loop(true); + assert(!fake::begins && !server.status().listening); + WiFi.mode = WIFI_AP_STA; + WiFi.address = 0; + server.loop(true); + assert(!fake::begins); + WiFi.address = 0x0a020304; + server.loop(true); + assert(server.status().listening && fake::boundAddress == WiFi.address); + assert(fake::boundPort == 5003); + const int fd = fake::newSocket(); + server.loop(true); + assert(server.status().accepted == 1); + const int extra = fake::newSocket(); + server.loop(true); + assert(!fake::sockets.at(extra)->connected && fake::sockets.at(fd)->connected); + assert(server.status().rejected == 1); + server.loop(false); + assert(!fake::sockets.at(fd)->connected && !server.status().listening); + assert(server.status().disconnects == 1); +} + +static void serverBudgetBackpressure() { + KissServer server; + const int fd = connect(server); + const auto data = frame(14); + const auto wire = encoded(data); + for (unsigned i = 0; i < 20; ++i) append(fd, wire); + const auto socket = fake::sockets.at(fd); + const size_t before = socket->input.size(); + server.loop(true); + assert(server.status().pendingTransmit == 2); + assert(before - socket->input.size() == wire.size() * 2); + const size_t paused = socket->input.size(); + server.loop(true); + assert(socket->input.size() == paused); + const uint8_t* bytes; + size_t length; + assert(server.peekTransmit(bytes, length)); + assert(length == data.size() && !memcmp(bytes, data.data(), length)); + server.consumeTransmit(); + server.loop(true); + assert(server.status().pendingTransmit == 2); + const int oldPending = fake::newSocket(); + server.resetPending(); + assert(!socket->connected && server.status().pendingTransmit == 0); + assert(!fake::sockets.at(oldPending)->connected && !server.status().listening); + const int next = fake::newSocket(); + server.loop(true); + const auto large = encoded(frame()); + append(next, large); + server.loop(true); + assert(fake::sockets.at(next)->reads == KissServer::ReadBudget + 1); // accept poll read + bounded poll + assert(!server.status().pendingTransmit); + server.loop(true); + assert(server.status().pendingTransmit == 1); +} + +static void serverPartialWrites() { + KissServer server; + const int fd = connect(server); + const auto socket = fake::sockets.at(fd); + const auto data = frame(); + assert(server.queueReceive(data.data(), data.size())); + socket->writeQuota = 0; + for (unsigned i = 0; i < 50; ++i) server.loop(true); + assert(socket->output.empty() && server.status().pendingReceive == 1); + socket->writeQuota = 3; + while (server.status().pendingReceive) { + const size_t before = socket->output.size(); + server.loop(true); + assert(socket->output.size() - before <= 3); + } + assert(socket->output == encoded(data) && server.status().txFrames == 1); + socket->writeQuota = 5000; + assert(server.queueReceive(data.data(), data.size())); + const size_t before = socket->output.size(); + server.loop(true); + assert(socket->output.size() - before == KissServer::WriteBudget); +} + +static void serverSessionReset() { + KissServer server; + int fd = connect(server); + const auto data = frame(14); + append(fd, encoded(data)); + append(fd, {Fend, 0, 1, Fesc}); + server.loop(true); + assert(server.status().pendingTransmit == 1); + assert(server.queueReceive(data.data(), data.size())); + fake::sockets.at(fd)->writeQuota = 1; + server.loop(true); + fake::sockets.at(fd)->connected = false; + const int next = fake::newSocket(); + server.loop(true); + assert(server.status().connected && server.status().accepted == 2); + assert(!server.status().pendingTransmit && !server.status().pendingReceive); + assert(fake::sockets.at(next)->output.empty()); + append(next, {Tfend, 1, 2, Fend}); + server.loop(true); + assert(!server.status().pendingTransmit); + append(next, encoded(data)); + server.loop(true); + assert(server.status().pendingTransmit == 1); + WiFi.address += 1; + server.loop(true); + assert(!server.status().connected && !server.status().pendingTransmit); + assert(!fake::sockets.at(next)->connected && fake::boundAddress == WiFi.address); + fd = fake::newSocket(); + server.loop(true); + append(fd, encoded(data)); + server.loop(true); + WiFi.state = WL_DISCONNECTED; + server.loop(true); + assert(!server.status().listening && !server.status().connected); + assert(!server.status().pendingTransmit); +} + +static void serverFailures() { + KissServer server; + fake::listenFails = true; + server.loop(true); + assert(server.status().listenFailures == 1); + for (unsigned i = 0; i < 100; ++i) server.loop(true); + assert(fake::begins == 1); + fake::now = 1000; + server.loop(true); + assert(fake::begins == 2); + fake::listenFails = false; + fake::now = 2000; + server.loop(true); + assert(server.status().listening); + int fd = fake::newSocket(); + server.loop(true); + fake::sockets.at(fd)->readError = EINTR; + server.loop(true); + assert(server.status().connected); + fake::sockets.at(fd)->readError = ECONNRESET; + server.loop(true); + assert(!server.status().connected); + fd = fake::newSocket(); + server.loop(true); + const auto data = frame(14); + assert(server.queueReceive(data.data(), data.size())); + fake::sockets.at(fd)->writeError = EAGAIN; + server.loop(true); + assert(server.status().connected && server.status().pendingReceive == 1); + fake::sockets.at(fd)->writeError = EPIPE; + server.loop(true); + assert(!server.status().connected && !server.status().pendingReceive); + fd = fake::newSocket(); + server.loop(true); + fake::sockets.at(fd)->eof = true; + server.loop(true); + assert(!server.status().connected); +} + +int main(int argc, char** argv) { + assert(argc == 2); + const std::string test = argv[1]; + if (test == "codec-roundtrip") codecRoundtrip(); + else if (test == "codec-malformed") codecMalformed(); + else if (test == "codec-backpressure") codecBackpressure(); + else if (test == "codec-reset-fuzz") codecResetFuzz(); + else if (test == "server-station") serverStation(); + else if (test == "server-budget-backpressure") serverBudgetBackpressure(); + else if (test == "server-partial-writes") serverPartialWrites(); + else if (test == "server-session-reset") serverSessionReset(); + else if (test == "server-failures") serverFailures(); + else assert(false); +} diff --git a/test/fixtures/ota_cli_listing/test.cpp b/test/fixtures/ota_cli_listing/test.cpp index 119d7e03d..bca7efb92 100644 --- a/test/fixtures/ota_cli_listing/test.cpp +++ b/test/fixtures/ota_cli_listing/test.cpp @@ -39,7 +39,8 @@ struct OtaManager { enum FetchState { IDLE, WANT_MANIFEST, WANT_LEAVES, VERIFYING_STAGED, FETCHING, COMPLETE, PAUSED, FAILED }; enum FetchError { FETCH_ERROR_MANIFEST, FETCH_ERROR_HASH_ALGO, FETCH_ERROR_VERSION, FETCH_ERROR_CODEC, FETCH_ERROR_GEOMETRY, FETCH_ERROR_TOO_LARGE, FETCH_ERROR_STORAGE, - FETCH_ERROR_INTEGRITY, FETCH_ERROR_MANIFEST_TIMEOUT, FETCH_ERROR_LEAVES_TIMEOUT }; + FETCH_ERROR_INTEGRITY, FETCH_ERROR_MANIFEST_TIMEOUT, FETCH_ERROR_LEAVES_TIMEOUT, + FETCH_ERROR_RADIO_SESSION }; struct CatRow { uint8_t mid[4] = {1, 2, 3, 4}; uint32_t target_id = 0, fw_version = 0x01020300, last_ms = 9000; @@ -49,6 +50,8 @@ struct OtaManager { unsigned count = 0, queries = 0, clock_calls = 0, speed_calls = 0; uint32_t target_id = 0; FetchState state = IDLE; + FetchError error = FETCH_ERROR_STORAGE; + bool direct = false, direct_confirmed = false; bool codec_supported = true; uint8_t served = 3; float adaptivePacketSpeed() const { return 2.5f; } @@ -58,6 +61,8 @@ struct OtaManager { uint16_t catalogCount() const { return count; } unsigned sourceCount() const { return 2; } FetchState fetchState() const { return state; } + bool directFetchActive() const { return direct; } + bool directFetchConfirmed() const { return direct && direct_confirmed; } const uint8_t* fetchManifestId() const { return rows[0].mid; } uint32_t target() const { return target_id; } const CatRow* catalogRow(uint16_t index) const { return index < count ? &rows[index] : nullptr; } @@ -66,7 +71,7 @@ struct OtaManager { void servedDigest(uint8_t* digest) const { memcpy(digest, rows[0].mid, 4); } unsigned blocksHave() const { return 1; } unsigned blocksTotal() const { return 2; } - FetchError fetchError() const { return FETCH_ERROR_STORAGE; } + FetchError fetchError() const { return error; } bool fetched_is_bootloader() const { return false; } }; struct OtaContext { @@ -199,6 +204,35 @@ static void listing_tests() { context.manager.rows[0].flags = MFLAG_BOOTLOADER; invoke("ota ls", reply); assert(strstr(reply.text, "[bootloader unsupported]")); } +static void direct_status_tests() { + reset(); Reply reply; + context.manager.state = OtaManager::FETCHING; + context.manager.direct = true; + invoke("ota status", reply); +#if !defined(OTA_SEEDER_ONLY) + assert(strstr(reply.text, "direct240 probe")); + context.manager.direct_confirmed = true; + invoke("ota status", reply); + assert(strstr(reply.text, "direct240") && !strstr(reply.text, "probe")); + context.manager.state = OtaManager::COMPLETE; + invoke("ota status", reply); + assert(strstr(reply.text, "ready to install") && !strstr(reply.text, "direct240")); + context.manager.state = OtaManager::PAUSED; + invoke("ota status", reply); + assert(strstr(reply.text, "folder link lost") && !strstr(reply.text, "direct240")); + context.manager.state = OtaManager::FETCHING; + context.manager.direct = false; + invoke("ota status", reply); + assert(!strstr(reply.text, "direct240")); + context.manager.state = OtaManager::FAILED; + context.manager.error = OtaManager::FETCH_ERROR_RADIO_SESSION; + invoke("ota status", reply); + assert(strstr(reply.text, "radio session changed")); +#else + assert(strstr(reply.text, "OTA seeder | install:disabled")); + assert(!strstr(reply.text, "direct240")); +#endif +} static void name_tests(unsigned id, const char* name) { reset(); Reply reply; context.manager.count = 1; context.manager.rows[0].target_id = id; @@ -212,6 +246,7 @@ static void name_tests(unsigned id, const char* name) { int main() { mesh::ota::dispatch_tests(); mesh::ota::listing_tests(); + mesh::ota::direct_status_tests(); unsigned id; char name[68]; while (scanf("%x %67s", &id, name) == 2) mesh::ota::name_tests(id, name); } diff --git a/test/fixtures/radio_profiles/chirp_math.cpp b/test/fixtures/radio_profiles/chirp_math.cpp index 2fcad3fe7..8a6fdf01d 100644 --- a/test/fixtures/radio_profiles/chirp_math.cpp +++ b/test/fixtures/radio_profiles/chirp_math.cpp @@ -124,7 +124,7 @@ int main() { if (!p.automaticPreambleFits()) continue; if (p.automaticPreamble(0)>Profiles::MaxAutomaticPreamble || p.automaticPreamble(1)>Profiles::MaxAutomaticPreamble) { - assert(p.preamble(0,32)<=128 && p.preamble(1,32)<=128); + assert(p.preamble(0,32)<=512 && p.preamble(1,32)<=512); continue; } auto actual=p.chirpTiming(); @@ -141,7 +141,8 @@ int main() { } Profiles p;p.primary=params(7,62.5);p.secondary.params=params(7,500); p.secondary.mode=mesh::RadioProfileMode::Rx; - assert(p.preamble(0,32)==128 && p.preamble(1,32)==128 && !p.switchTestReady()); + assert(Profiles::MaxAutomaticPreamble==512); + assert(p.preamble(0,32)==512 && p.preamble(1,32)==512 && !p.switchTestReady()); for (unsigned n=0;n128); - assert(measured.preamble(0,32)==128 && measured.chirpTiming().preamble[0]>128); + assert(measured.preamble(0,32)==168 && measured.chirpTiming().preamble[0]==168); + // Ratios requiring 129..512 symbols must no longer be clipped at 128. + Profiles usa_fast; + usa_fast.primary=params(7,62.5);usa_fast.secondary.params=params(5,500); + usa_fast.secondary.mode=mesh::RadioProfileMode::RxTx; + for (unsigned n=0;n128 && usa_fast.automaticPreamble(1)<=512); + assert(usa_fast.preamble(1,32)==usa_fast.automaticPreamble(1)); + assert(usa_fast.preamble(1,32)%8==0); + // Keep the new cap and uncapped diagnostics independently tested. + measured.sampleSwitch(0,1,40000); + assert(measured.automaticPreamble(0)>512 && measured.preamble(0,32)==512); + assert(measured.chirpTiming().preamble[0]>512); measured.secondary.params.preamble=88; // explicit overrides remain unchanged assert(measured.preamble(1,32)==88); auto replacement=measured.secondary;replacement.params.freq=910.5; measured.setSecondary(replacement,true); assert(!measured.switchTestReady() && measured.switchBudgetUs()==0); - assert(measured.preamble(0,32)==128 && measured.preamble(1,32)==88); + assert(measured.preamble(0,32)==512 && measured.preamble(1,32)==88); // A T1000-E-sized 977 us hop calibrates to 1075 us, yet SF8/500 no longer // inherits the historical 88-symbol floor or a fixed 300 us loop pad. diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 66757bc5a..f3c3bedd6 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -487,9 +487,13 @@ int main(int argc, char** argv) { f.advance(2000); f.finishTimingTest(8428); assert(f.radio.p.switchBudgetUs()==9271); - assert(f.radio.p.preamble(0,32)==128); - assert(strstr(f.cmd("get radio2.timing"),"WARN need >128: radio")); - assert(strstr(f.cmd("get radio2.status"),"WARN need >128: radio")); + assert(f.radio.p.preamble(0,32)==168); + assert(!strstr(f.cmd("get radio2.timing"),"WARN need >")); + assert(!strstr(f.cmd("get radio2.status"),"WARN need >")); + f.finishTimingTest(40000); + assert(f.radio.p.preamble(0,32)==512); + assert(strstr(f.cmd("get radio2.timing"),"WARN need >512: radio")); + assert(strstr(f.cmd("get radio2.status"),"WARN need >512: radio")); } { Fixture f; diff --git a/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp b/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp index f36246f30..ef0370df0 100644 --- a/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp +++ b/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp @@ -77,6 +77,7 @@ struct Bluetooth { int motaStream() { return 0; } bool isMotaChannelReady() const { return ready; } bool isMotaStreamActive() const { return active; } + uint16_t motaResponseCapacity() const { return ready && active ? 197 : 0; } void setMotaStreamActive(bool value) { active = value; } } bluetooth_interface; namespace mesh { @@ -91,6 +92,8 @@ public: assert(::bluetooth_interface.active); ++session_resets; } +protected: + virtual uint16_t responseFrameCapacity() const { return 197; } }; } } #include "ble_control_under_test.h" diff --git a/test/mocks/Mesh.h b/test/mocks/Mesh.h index 1e09d8473..89e91800d 100644 --- a/test/mocks/Mesh.h +++ b/test/mocks/Mesh.h @@ -17,6 +17,7 @@ public: virtual RadioProfiles* profiles() { return nullptr; } virtual const RadioProfiles* profiles() const { return nullptr; } virtual bool validateProfile(const RadioProfileParams&) const { return false; } + virtual bool supportsEthernetTunnel() const { return false; } virtual uint8_t receiveProfile() const { return 0; } virtual uint32_t receiveProfileGeneration() const { const RadioProfiles* p = profiles(); diff --git a/test/test_companion_bluetooth_persistence.py b/test/test_companion_bluetooth_persistence.py index 26ffc2498..d1054115e 100644 --- a/test/test_companion_bluetooth_persistence.py +++ b/test/test_companion_bluetooth_persistence.py @@ -32,14 +32,14 @@ int main(){ assert(mesh.savePrefs()); const auto on_disk=mesh.store.fs.files["/new_prefs"]; #if defined(TBEAM_1W) - assert(on_disk.size()==243); + assert(on_disk.size()==251); #elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) - assert(on_disk.size()==333); + assert(on_disk.size()==341); #else - assert(on_disk.size()==236); + assert(on_disk.size()==244); #endif - const size_t bluetooth_offset=on_disk.size()-5; - assert(on_disk[bluetooth_offset]==1&&on_disk.back()==0); + const size_t bluetooth_offset=on_disk.size()-mesh::companion::traffic::FooterSize-5; + assert(on_disk[bluetooth_offset]==1&&on_disk[bluetooth_offset+4]==0); // Both choices survive a fresh instance reading the actual on-device image. for(bool enabled : {false,true,false}){ assert(mesh.setBluetoothEnabledPreference(enabled)); @@ -51,7 +51,7 @@ int main(){ assert(prefs.bluetooth_stealth_mode==2&&prefs.one_key_dm_enabled==1); assert(prefs.wifi_enabled==0&&prefs.usb_logging_enabled==1); const auto& saved=reboot.fs.files["/new_prefs"]; - assert(saved[bluetooth_offset]==enabled&&saved.back()==0); + assert(saved[bluetooth_offset]==enabled&&saved[bluetooth_offset+4]==0); } const auto off_disk=mesh.store.fs.files["/new_prefs"]; // The public setter must roll live state back when the atomic save fails. diff --git a/test/test_companion_ethernet.py b/test/test_companion_ethernet.py new file mode 100644 index 000000000..ed89c6eb5 --- /dev/null +++ b/test/test_companion_ethernet.py @@ -0,0 +1,566 @@ +"""Exercise actual Companion Ethernet settings/lifecycle at host boundaries. + +No PlatformIO, hardware, network listener or NVS device is used. The portable +settings image and extracted Companion method bodies remain production code. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body + + +ROOT = Path(__file__).resolve().parents[1] + + +SETTINGS_HARNESS = r''' +#include +#include +#include +#include +#include +#include +using namespace mesh; +using namespace mesh::ethernet; + +static void checksum(uint8_t* bytes) { + const uint32_t crc=settingsChecksum(bytes,SettingsSize-4); + memcpy(bytes+SettingsSize-4,&crc,sizeof(crc)); +} +static Settings configured() { + Settings value; + value.configured=true; + value.radio.freq=909.5; + value.radio.bw=500; + value.radio.sf=5; + value.radio.cr=5; + value.radio.preamble=32; + return value; +} +int main() { + uint8_t bytes[SettingsSize]; + Settings value,loaded; + assert(validSettings(value)); + encodeSettings(bytes,value); + assert(decodeSettings(bytes,sizeof(bytes),loaded)); + assert(!loaded.enabled && !loaded.configured); + assert(loaded.radio.protocol==RadioProfileProtocol::Ethernet); + assert(!decodeSettings(nullptr,sizeof(bytes),loaded)); + for (size_t size=0;size::infinity(), + std::numeric_limits::quiet_NaN()}) { + value.radio.freq=bad; assert(!validSettings(value)); + encodeSettings(bytes,value); assert(!decodeSettings(bytes,sizeof(bytes),loaded)); + } + value=configured(); + for(float bad : {0.f,124.f,std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + value.radio.bw=bad; assert(!validSettings(value)); + encodeSettings(bytes,value); assert(!decodeSettings(bytes,sizeof(bytes),loaded)); + } + value=configured(); + for(uint16_t bad : {0,1,7,65529,65535}) { + value.radio.preamble=bad; assert(!validSettings(value)); + encodeSettings(bytes,value); assert(!decodeSettings(bytes,sizeof(bytes),loaded)); + } + value=configured(); value.radio.protocol=RadioProfileProtocol::MeshCore; + assert(!validSettings(value)); + // Foreign profiles bypass MeshCore's scan-preamble recommendation, not the + // RadioLib 32-bit microsecond airtime bound. Reject even a CRC-valid tuple + // that would overflow that clock before it can be persisted or used at boot. + value=configured(); value.radio.bw=7.8f; value.radio.sf=12; + value.radio.preamble=65528; + assert(!validSettings(value)); + encodeSettings(bytes,value); assert(!decodeSettings(bytes,sizeof(bytes),loaded)); + value=configured(); + for(uint8_t bad : {0,4,13,255}) { + value.radio.sf=bad; assert(!validSettings(value)); + encodeSettings(bytes,value); assert(!decodeSettings(bytes,sizeof(bytes),loaded)); + } + value=configured(); + for(uint8_t bad : {0,4,9,255}) { + value.radio.cr=bad; assert(!validSettings(value)); + encodeSettings(bytes,value); assert(!decodeSettings(bytes,sizeof(bytes),loaded)); + } +} +''' + + +COMPANION_HARNESS = r''' +@RADIO_FIXTURE@ +#include +#include +#include +#include +#include +#include + +struct Preferences { + static inline std::vector blob; + static inline bool fail_begin=false,fail_write=false,fail_read=false,fail_remove=false; + static inline unsigned writes=0; + bool begin(const char* name,bool read_only) { + assert(!strcmp(name,"mesh-eth") && !read_only); return !fail_begin; + } + size_t getBytesLength(const char* key) { assert(!strcmp(key,"settings")); return blob.size(); } + size_t getBytes(const char*,void* out,size_t size) { + if(fail_read)return 0; + size=std::min(size,blob.size()); memcpy(out,blob.data(),size); return size; + } + size_t putBytes(const char*,const void* in,size_t size) { + ++writes; if(fail_write)return 0; + const auto* bytes=static_cast(in); blob.assign(bytes,bytes+size); return size; + } + bool remove(const char*) { if(fail_remove)return false; blob.clear(); return true; } + void end() {} + static void reset() { blob.clear(); writes=0; fail_begin=fail_write=fail_read=fail_remove=false; } +}; + +namespace mesh { namespace ethernet { +// Socket lifecycle is a boundary substitute. Every byte in both directions +// still traverses the production bounded KISS codec. +class KissServer { + KissCodec codec; +public: + static constexpr uint16_t Port=5003; + struct Status { bool enabled=false,listening=false,connected=false; uint32_t accepted=0,disconnects=0; }; + Status state; + bool wifi=true; + bool attach_pending=false,detach_pending=false; + unsigned resets=0; + void resetPending() { + ++resets; + if(state.connected)++state.disconnects; + state.connected=false; + codec.resetPending(); + } + void loop(bool enabled) { + state.enabled=enabled; state.listening=enabled && wifi; + if(!state.listening) {resetPending();attach_pending=detach_pending=false;return;} + if(detach_pending) {resetPending();detach_pending=false;} + if(attach_pending) {codec.resetPending();state.connected=true;++state.accepted;attach_pending=false;} + } + Status status() const { return state; } + void connect() { assert(state.listening);attach_pending=true; } + void disconnect() { detach_pending=true; } + bool peekTransmit(const uint8_t*& bytes,size_t& size) const { return codec.peekTransmit(bytes,size); } + void consumeTransmit() { codec.consumeTransmit(); } + bool queueReceive(const uint8_t* bytes,size_t size) { return state.connected && codec.queueReceive(bytes,size); } + void feed(const std::vector& frame) { + assert(state.connected); + for(uint8_t byte:frame)assert(codec.feed(byte)); + } + std::vector output() { + const uint8_t* bytes;size_t size; + if(!codec.peekOutput(bytes,size))return {}; + std::vector out(bytes,bytes+size);codec.consumeOutput(size);return out; + } +}; +} } + +static constexpr int MAX_GROUP_CHANNELS=4; +struct ChannelDetails {char name[32]={};}; +struct BaseChatMesh { + ChannelDetails channels[MAX_GROUP_CHANNELS]; + unsigned channel_reads=0; + bool getChannel(int index,ChannelDetails& out) {++channel_reads;out=channels[index];return true;} + bool setChannel(int index,const ChannelDetails& value) {channels[index]=value;return true;} +}; +struct EthernetRadio : Radio { + bool supported=true; + mutable unsigned validations=0; + bool supportsEthernetTunnel() const override {return supported;} + bool validateProfile(const mesh::RadioProfileParams& value) const override { + ++validations;return Radio::validateProfile(value); + } +}; +class MyMesh : public BaseChatMesh { +public: + MemoryFS fs;Clock clock;EthernetRadio radio;mesh::Radio* _radio=&radio; + mesh::RadioProfileCLI _radio_profiles; + mesh::ethernet::Settings _ethernet_settings; + bool _ethernet_settings_valid=true,_ethernet_active=false,_ethernet_owned=false; + uint32_t _ethernet_generation=0; + uint32_t _ethernet_intent_generation=0,_ethernet_channel_generation=0; + uint32_t _ethernet_checked_channels=UINT32_MAX; + bool _ethernet_channels_conflict=false; + const char* _ethernet_status="off"; + mesh::ethernet::KissServer _ethernet_kiss; + uint32_t _ethernet_received=0,_ethernet_sent=0,_ethernet_failed=0; + bool _radio_available=true,foreign_pending=false,admit_foreign=true; + mesh::RadioProfileProtocol foreign_protocol=mesh::RadioProfileProtocol::MeshCore; + uint8_t foreign_profile=255; + uint32_t foreign_generation=0; + unsigned admissions=0,discards=0; + std::vector foreign_bytes; + MyMesh() {g_mock_millis=0;_radio_profiles.begin(&fs,&radio,&clock);} + void beginEthernetTunnel(); + bool ethernetChannelConflict(); + void releaseEthernetProfile(); + bool saveEthernetSettings(const mesh::ethernet::Settings&); + bool eraseEthernetSettings(); + bool ethernetRadioConflict(const mesh::ethernet::Settings&); + bool handleEthernetTunnelCommand(const char*,char*,size_t); + void loopEthernetTunnel(); + bool onEthernetFrame(const uint8_t*,size_t,uint8_t,uint32_t); + bool setChannel(int,const ChannelDetails&); + bool onForeignRadioFrame(const uint8_t*,size_t,uint8_t,uint32_t,float,float); + void onForeignRadioSendComplete(bool); + mesh::RadioProfileProtocol queuedForeignRadioProtocol() const {return foreign_protocol;} + bool hasForeignRadioFrame() const {return foreign_pending;} + void discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol protocol) { + if(foreign_pending && protocol==foreign_protocol) {foreign_pending=false;++discards;} + } + bool queueForeignRadioFrame(const uint8_t* bytes,size_t size,uint8_t profile,uint32_t generation, + mesh::RadioProfileProtocol protocol) { + if(foreign_pending || !admit_foreign)return false; + foreign_pending=true;++admissions;foreign_bytes.assign(bytes,bytes+size); + foreign_profile=profile;foreign_generation=generation;foreign_protocol=protocol;return true; + } + const char* command(const char* text) { + static char reply[256];memset(reply,0x55,sizeof(reply)); + assert(handleEthernetTunnelCommand(text,reply,sizeof(reply))); + assert(memchr(reply,0,sizeof(reply)));return reply; + } + void cli(const char* text) { + char reply[160];assert(_radio_profiles.handle(text,reply,sizeof(reply))); + if(strncmp(reply,"OK",2)) {fprintf(stderr,"%s -> %s\n",text,reply);assert(false);} + g_mock_millis+=2000;clock.epoch+=2;_radio_profiles.loop(); + } +}; +@METHODS@ + +static void ok(MyMesh& node,const char* text) {assert(!strncmp(node.command(text),"OK",2));} +static void error(MyMesh& node,const char* text) {assert(!strncmp(node.command(text),"Error",5));} +static void active(MyMesh& node) { + node.beginEthernetTunnel(); + ok(node,"set ethernet.radio 909.5,500,5,5"); + ok(node,"set ethernet on");node.loopEthernetTunnel(); + assert(node._ethernet_active && node._ethernet_owned && node.radio.p.enabled()); + assert(node.radio.p.secondary.params.protocol==mesh::RadioProfileProtocol::Ethernet); +} +static std::vector ethernet(uint16_t type=0x0800,size_t size=244) { + std::vector frame(size); + for(size_t i=0;i(i); + frame[12]=type>>8;frame[13]=type&255;frame[14]=0xc0;frame[15]=0xdb; + return frame; +} +static std::vector kiss(const std::vector& ethernet) { + std::vector out={0xc0,0}; + for(auto byte:ethernet) { + if(byte==0xc0)out.insert(out.end(),{0xdb,0xdc}); + else if(byte==0xdb)out.insert(out.end(),{0xdb,0xdd}); + else out.push_back(byte); + } + out.push_back(0xc0);return out; +} +static void defaults_cli_and_reboot() { + Preferences::reset();MyMesh node;node.beginEthernetTunnel();node.loopEthernetTunnel(); + assert(node._ethernet_settings_valid && !node._ethernet_active && !node.radio.p.enabled()); + assert(strstr(node.command("get ethernet"),"ethernet=off")); + assert(strstr(node.command("get ethernet.radio"),"unset")); + error(node,"set ethernet on");assert(Preferences::writes==0); + for(const char* bad:{"set ethernet","set ethernet ON","set ethernet on extra", + "set ethernet.radio","set ethernet.radio 909.5,500,5", + "set ethernet.radio 909.5,500,5,5,garbage","set ethernet.radio 909.5,500,4,5", + "set ethernet.radio 909.5,500,5,5,7","set ethernet.radio 0,500,5,5"})error(node,bad); + assert(Preferences::writes==0); + char reply[8];assert(!node.handleEthernetTunnelCommand("get ethernetx",reply,sizeof(reply))); + assert(!node.handleEthernetTunnelCommand("set ethernetx on",reply,sizeof(reply))); + ok(node,"set ethernet.radio 909.5,500,5,5"); + assert(node._ethernet_settings.radio.preamble==32 && !node._ethernet_settings.enabled); + ok(node,"set ethernet on");node.loopEthernetTunnel(); + const auto generation=node.radio.p.generation[1]; + assert(node._ethernet_active && node._ethernet_kiss.status().listening); + assert(node._ethernet_settings.radio.protocol==mesh::RadioProfileProtocol::Ethernet); + assert(node.fs.files.empty()); + const auto validations=node.radio.validations; + const auto scans=node.channel_reads; + for(int i=0;i<100;++i)node.loopEthernetTunnel(); + assert(node.radio.p.generation[1]==generation && Preferences::writes==2); + assert(node.radio.validations==validations && node.channel_reads==scans); + for(const char* command:{"get ethernet","get ethernet.radio","get ethernet.status"}) { + char bounded[4]={'a','b','c','d'}; + assert(node.handleEthernetTunnelCommand(command,bounded+1,1)); + assert(bounded[0]=='a' && bounded[1]==0 && bounded[2]=='c' && bounded[3]=='d'); + bounded[1]='b';assert(node.handleEthernetTunnelCommand(command,bounded+1,0)); + assert(bounded[0]=='a' && bounded[1]=='b' && bounded[2]=='c' && bounded[3]=='d'); + } + MyMesh reboot;reboot.beginEthernetTunnel();reboot.loopEthernetTunnel(); + assert(reboot._ethernet_active && reboot._ethernet_settings.radio==node._ethernet_settings.radio); + ok(node,"set ethernet off");node.loopEthernetTunnel(); + assert(!node._ethernet_active && !node.radio.p.enabled() && !node._ethernet_kiss.status().listening); + MyMesh off;off.beginEthernetTunnel();off.loopEthernetTunnel();assert(!off._ethernet_active); +} +static void persistence_faults() { + for(int fault=0;fault<3;++fault) { + Preferences::reset();MyMesh node;active(node); + const auto before=Preferences::blob; + if(fault==0)Preferences::fail_begin=true; + if(fault==1)Preferences::fail_write=true; + if(fault==2)Preferences::fail_read=true; + error(node,"set ethernet off"); + if(fault==0)assert(node._ethernet_active && node._ethernet_settings_valid); + else assert(!node._ethernet_active && !node._ethernet_settings_valid && !node.radio.p.enabled()); + Preferences::fail_begin=Preferences::fail_write=Preferences::fail_read=false; + if(fault<2)assert(Preferences::blob==before); + MyMesh reboot;reboot.beginEthernetTunnel();reboot.loopEthernetTunnel(); + assert(reboot._ethernet_active==(fault!=2)); + } + Preferences::reset();MyMesh source;active(source);const auto good=Preferences::blob; + for(size_t at=0;at invalid(size,0x11); + assert(!node.onEthernetFrame(invalid.data(),invalid.size(),1,node.radio.p.generation[1])); + } + const auto frame=ethernet();node._ethernet_kiss.feed(kiss(frame));node.loopEthernetTunnel(); + const auto admissions=node.admissions;assert(node.foreign_pending); + node._radio_available=false;node.loopEthernetTunnel(); + assert(!node.foreign_pending && node.discards>=1 && node.admissions==admissions && !node._ethernet_active); + node._radio_available=true;node.loopEthernetTunnel(); + assert(node._ethernet_active && !node._ethernet_kiss.status().connected); + assert(!node.onEthernetFrame(frame.data(),frame.size(),1,node.radio.p.generation[1])); + node._ethernet_kiss.connect();node.loopEthernetTunnel(); + node._ethernet_kiss.feed(kiss(frame));node.loopEthernetTunnel();assert(node.foreign_pending); + node._ethernet_kiss.disconnect();node.loopEthernetTunnel(); + assert(!node.foreign_pending && !node._ethernet_kiss.status().connected); + node._ethernet_kiss.connect();node.loopEthernetTunnel(); + node._ethernet_kiss.feed(kiss(frame));node.loopEthernetTunnel();assert(node.foreign_pending); + assert(node.eraseEthernetSettings());assert(!node.foreign_pending && !node._ethernet_active); +} +static void temporary_and_scheduled_ownership() { + Preferences::reset();MyMesh node;active(node); + node.cli("set tempradio2 910.5,500,5,5,rxtx,1,32");node.loopEthernetTunnel(); + assert(!node._ethernet_active && node.radio.p.secondary_temporary + && node.radio.p.secondary.params.freq==910.5f); + const auto temporary=node.radio.p.secondary.params; + error(node,"set ethernet on");assert(node.radio.p.secondary.params==temporary); + node.cli("set tempradio2 off");node.loopEthernetTunnel();assert(node._ethernet_active); + char command[128]; + snprintf(command,sizeof(command),"set radioat2 911.5,500,5,5,rxtx,%lu,32", + static_cast(node.clock.epoch+120)); + node.cli(command);node.loopEthernetTunnel();assert(!node._ethernet_active); + assert(!node.radio.p.enabled());error(node,"set ethernet on"); + node.cli("del radioat2 all");node.loopEthernetTunnel();assert(node._ethernet_active); +} +static void unused_meshtext_owner_handoff() { + Preferences::reset();MyMesh node;node.beginEthernetTunnel(); + mesh::RadioProfileConfig old; + old.mode=mesh::RadioProfileMode::RxTx;old.params.freq=906.875;old.params.bw=250; + old.params.sf=11;old.params.cr=5;old.params.preamble=16; + old.params.protocol=mesh::RadioProfileProtocol::Meshtext; + assert(node._radio_profiles.requestAutoSecondary(old)); + assert(node._radio_profiles.autoSecondaryActive()); + ok(node,"set ethernet.radio 909.5,500,5,5,32"); + assert(node.radio.p.secondary.params.protocol==mesh::RadioProfileProtocol::Meshtext); + ok(node,"set ethernet on");node.loopEthernetTunnel(); + assert(node._ethernet_active && node.radio.p.secondary.params.protocol==mesh::RadioProfileProtocol::Ethernet); +} +static void real_channel_setter_invalidates_cache_without_evicting() { + Preferences::reset();MyMesh node;active(node); + const auto generation=node._ethernet_channel_generation; + ChannelDetails candidate; + for(const char* name:{"#r2-emergency","#mt-longfast","#r2-"}) { + strcpy(candidate.name,name);assert(!node.setChannel(0,candidate)); + assert(node._ethernet_channel_generation==generation && !node.channels[0].name[0]); + } + strcpy(candidate.name,"#emergency");assert(node.setChannel(0,candidate)); + assert(node._ethernet_channel_generation==generation+1); + node.loopEthernetTunnel();assert(node._ethernet_active && !node.ethernetChannelConflict()); + candidate={};assert(node.setChannel(0,candidate)); + assert(node._ethernet_channel_generation==generation+2); + node.loopEthernetTunnel();assert(node._ethernet_active); +} +int main() { + defaults_cli_and_reboot();persistence_faults();profile_conflicts_and_recovery(); + opaque_frames_and_no_primary_fallback();temporary_and_scheduled_ownership();unused_meshtext_owner_handoff(); + real_channel_setter_invalidates_cache_without_evicting(); +} +''' + + +class CompanionEthernetTest(unittest.TestCase): + def compile_run(self, text, sources=(), includes=(), definitions=()): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-ethernet-companion-") as directory: + work = Path(directory) + (work / "test.cpp").write_text(text, encoding="utf-8") + command = [compiler, "-std=c++17", "-Wall", "-Wextra", + "-fsanitize=address,undefined", "-fno-pie", "-no-pie"] + command.extend("-D" + item for item in definitions) + for path in (*includes, ROOT / "src"): + command.extend(("-I", str(path))) + command.extend((str(work / "test.cpp"), *(str(path) for path in sources), + "-o", str(work / "test"))) + build = subprocess.run(command, capture_output=True, text=True) + diagnostics = build.stdout + build.stderr + errors = "\n".join(line for line in diagnostics.splitlines() + if "error:" in line or "undefined reference" in line) + self.assertEqual(build.returncode, 0, errors or diagnostics) + run = subprocess.run([str(work / "test")], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + def test_checked_settings_image_is_atomic_and_fail_closed(self): + self.compile_run(SETTINGS_HARNESS) + + def test_production_companion_lifecycle_routing_and_persistence(self): + source = (ROOT / "examples/companion_radio/EthernetCompanion.cpp").read_text(encoding="utf-8") + methods = source[source.index("void MyMesh::beginEthernetTunnel()"):].rsplit("#endif", 1)[0] + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") + methods += "\n" + body(companion, "bool MyMesh::setChannel(") + fixture = (ROOT / "test/fixtures/radio_profiles/cli_test.cpp").read_text(encoding="utf-8") + fixture = fixture[:fixture.index("struct Fixture {")] + harness = COMPANION_HARNESS.replace("@RADIO_FIXTURE@", fixture).replace("@METHODS@", methods) + self.compile_run( + harness, + sources=(ROOT / "src/helpers/RadioProfileCLI.cpp",), + includes=(ROOT / "test/fixtures/radio_profiles/mocks", ROOT / "test/mocks"), + definitions=("MESH_ENABLE_MESHTEXT_CHAT=0", + "MESH_ENABLE_TX_BEACONS=0", "MESH_ENABLE_ETHERNET_TUNNEL=1"), + ) + + def test_full_esp32_wifi_only_compile_gate(self): + harness = ("#include \n" + "static_assert(MESH_ENABLE_ETHERNET_TUNNEL==EXPECTED,\"feature gate\");\n" + "int main() {}\n") + for definitions, expected in ( + (("ESP32_PLATFORM=1", "COMPANION_RADIO_FULL=1", "WIFI_SSID=1"), 1), + (("ESP32_PLATFORM=1", "COMPANION_RADIO_FULL=1"), 0), + (("ESP32_PLATFORM=1", "WIFI_SSID=1"), 0), + (("NRF52_PLATFORM=1", "COMPANION_RADIO_FULL=1", "WIFI_SSID=1"), 0), + (("ESP32_PLATFORM=1", "COMPANION_RADIO_FULL=1", "WIFI_SSID=1", "MESH_PRIMARY_ESPNOW=1"), 0), + ): + with self.subTest(definitions=definitions): + self.compile_run(harness, definitions=(*definitions, f"EXPECTED={expected}")) + + def test_production_companion_wiring_keeps_transports_isolated(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") + # begin() contains old alternative platform branches that intentionally + # split a common outer body; take its stable next-method boundary. + begin = source[source.index("void MyMesh::begin("):source.index("bool MyMesh::handleTxRoutingCommand(")] + self.assertLess(begin.index("beginEthernetTunnel();"), begin.index("beginMeshtext();")) + loop = body(source, "void MyMesh::loop()") + self.assertLess(loop.index("loopEthernetTunnel();"), loop.index("BaseChatMesh::loop();")) + self.assertIn("handleEthernetTunnelCommand(command, reply, reply_size)", + body(source, "bool MyMesh::handleLocalControlCommand(")) + self.assertEqual(source.count("if (!eraseEthernetSettings())"), 1) + self.assertEqual(source.count("if (success && !eraseEthernetSettings())"), 1) + setting = body(source, "bool MyMesh::setChannel(") + self.assertIn("_ethernet_settings.enabled", setting) + self.assertIn("CompanionChannelKind::MeshCore", setting) + mt = (ROOT / "examples/companion_radio/MeshtextCompanion.cpp").read_text(encoding="utf-8") + refresh = body(mt, "void MyMesh::refreshMeshtextProfile()") + self.assertLess(refresh.index("if (_ethernet_settings.enabled)"), + refresh.index("_radio_profiles.clearAutoSecondary();")) + self.assertIn("onEthernetFrame(bytes, length, profile, generation)", + body(mt, "bool MyMesh::onForeignRadioFrame(")) + self.assertIn("queuedForeignRadioProtocol() == mesh::RadioProfileProtocol::Ethernet", + body(mt, "void MyMesh::onForeignRadioSendComplete(")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index f215b23d2..7e0a7bf3c 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -23,6 +23,9 @@ HARNESS = r''' #endif #include "examples/companion_radio/NodePrefs.h" #include "examples/companion_radio/PrefsStorageLayout.h" +#if defined(RAK_4631) +#include +#endif #define MESH_DEBUG_PRINTLN(...) ((void)0) struct DataStore { MemoryFS fs; @@ -59,6 +62,11 @@ int main(int argc,char** argv){ original.gps_sync_interval_hours=336; original.usb_debug_enabled=1; original.lost_reply=2; + original.tx_media=mesh::RADIO_TX_SECONDARY; + original.tx_ssh=mesh::RADIO_TX_OFF; +#if defined(RAK_4631) + original.peripherals_shared=1; +#endif #ifdef TBEAM_1W strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42; #endif @@ -67,6 +75,11 @@ int main(int argc,char** argv){ #endif assert(store.savePrefs(original,47.1,-122.2)); const auto disk=store.fs.files["/new_prefs"]; + const size_t historical_size=disk.size()-mesh::companion::traffic::FooterSize +#if defined(RAK_4631) + -mesh::peripherals::FooterSize +#endif + ; CompanionNodePrefs changed=original;changed.freq=910.1f; if(scenario==0){ store.fs.fail_write=true; @@ -98,6 +111,10 @@ int main(int argc,char** argv){ assert(loaded.gps_sync_interval_hours==336); assert(loaded.usb_debug_enabled==1); assert(loaded.lost_reply==2); + assert(loaded.tx_media==mesh::RADIO_TX_SECONDARY&&loaded.tx_ssh==mesh::RADIO_TX_OFF); +#if defined(RAK_4631) + assert(loaded.peripherals_shared==1); +#endif #ifdef TBEAM_1W assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42); #endif @@ -203,7 +220,7 @@ int main(int argc,char** argv){ } else if(scenario==7){ // Pre-consent image: all earlier settings survive with default-off consent. DataStore legacy;legacy.fs.files["/new_prefs"]=disk; - legacy.fs.files["/new_prefs"].resize(disk.size()-6); + legacy.fs.files["/new_prefs"].resize(historical_size-6); CompanionNodePrefs loaded;double lat=0,lon=0; assert(legacy.loadPrefs(loaded,lat,lon)); assert(loaded.flood_retry_advert_enabled==0); @@ -212,7 +229,7 @@ int main(int argc,char** argv){ // The previous complete image loads with the board policy even when the // receiving prefs object previously held an explicit configured interval. DataStore legacy;legacy.fs.files["/new_prefs"]=disk; - legacy.fs.files["/new_prefs"].resize(disk.size()-4); + legacy.fs.files["/new_prefs"].resize(historical_size-4); CompanionNodePrefs loaded;loaded.gps_sync_interval_hours=24;loaded.usb_debug_enabled=1; double lat=0,lon=0; assert(legacy.loadPrefs(loaded,lat,lon)); @@ -220,12 +237,12 @@ int main(int argc,char** argv){ assert(loaded.usb_debug_enabled==0); // Half of the two-byte append is never accepted as a complete image. DataStore torn;torn.fs.files["/new_prefs"]=disk; - torn.fs.files["/new_prefs"].resize(disk.size()-3); + torn.fs.files["/new_prefs"].resize(historical_size-3); assert(!torn.loadPrefsInt("/new_prefs",loaded,lat,lon)); // The complete published GPS image is accepted without borrowing either // GPS byte as the new debug preference. DataStore gps_image;gps_image.fs.files["/new_prefs"]=disk; - gps_image.fs.files["/new_prefs"].resize(disk.size()-2); + gps_image.fs.files["/new_prefs"].resize(historical_size-2); loaded.usb_debug_enabled=1; assert(gps_image.loadPrefsInt("/new_prefs",loaded,lat,lon)); assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0); @@ -235,6 +252,68 @@ int main(int argc,char** argv){ oversized.fs.files["/new_prefs"].resize(disk.size()+65536); assert(!oversized.loadPrefsInt("/new_prefs",loaded,lat,lon)); assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0); + } else if(scenario==10){ + // Even a checksum-consistent unknown policy tag must not become AUTO or + // update just one output. Version/size and both enum fields are checked. + for(unsigned value=0;value<256;++value){ + uint8_t footer[mesh::companion::traffic::FooterSize]; + mesh::companion::traffic::encodeFooter(footer,mesh::RADIO_TX_SECONDARY,mesh::RADIO_TX_OFF); + for(unsigned field : {4u,5u}){ + mesh::companion::traffic::encodeFooter(footer,mesh::RADIO_TX_SECONDARY,mesh::RADIO_TX_OFF); + footer[field]=uint8_t(value); + uint16_t crc=mesh::companion::traffic::checksum(footer,6); + footer[6]=uint8_t(crc>>8);footer[7]=uint8_t(crc); + uint8_t media=mesh::RADIO_TX_BOTH,ssh=mesh::RADIO_TX_PRIMARY; + bool valid=value==0||(value>=0xa1&&value<=0xa4); + assert(mesh::companion::traffic::decodeFooter(footer,sizeof(footer),media,ssh)==valid); + if(!valid)assert(media==mesh::RADIO_TX_BOTH&&ssh==mesh::RADIO_TX_PRIMARY); + } + } + // Legacy preference files cannot borrow a configured live traffic route. + for(size_t size : {historical_size, disk.size()}){ + DataStore boot;boot.fs.files["/new_prefs"]=disk; + boot.fs.files["/new_prefs"].resize(size); + CompanionNodePrefs loaded;loaded.tx_media=mesh::RADIO_TX_BOTH; + loaded.tx_ssh=mesh::RADIO_TX_PRIMARY;double lat=1,lon=2; + assert(boot.loadPrefsInt("/new_prefs",loaded,lat,lon)); + assert(loaded.tx_media==(size==disk.size()?mesh::RADIO_TX_SECONDARY:mesh::RADIO_TX_AUTO)); + assert(loaded.tx_ssh==(size==disk.size()?mesh::RADIO_TX_OFF:mesh::RADIO_TX_AUTO)); + assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin); + } +#if defined(RAK_4631) + DataStore old_rak;old_rak.fs.files["/new_prefs"]=disk; + old_rak.fs.files["/new_prefs"].resize(disk.size()-mesh::companion::traffic::FooterSize); + CompanionNodePrefs rak;rak.tx_media=mesh::RADIO_TX_BOTH;double lat=1,lon=2; + assert(old_rak.loadPrefsInt("/new_prefs",rak,lat,lon)); + assert(rak.peripherals_shared==1&&rak.tx_media==mesh::RADIO_TX_AUTO); +#endif + // Every torn and bit-flipped checked footer fails without any live mutation. + const size_t start=disk.size()-mesh::companion::traffic::FooterSize; + for(size_t offset=0;offset')) (work / 'test.cpp').write_text(HARNESS.replace('@METHODS@', methods)) for platform in ('ESP32_PLATFORM', 'RP2040_PLATFORM', 'STM32_PLATFORM', 'NRF52_PLATFORM', - 'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'): + 'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE', + 'NRF52_PLATFORM,RAK_4631'): with self.subTest(platform=platform): binary = work / 'test' sanitizer_flags = [] if os.name == 'nt' else [ @@ -341,7 +421,7 @@ inline char* utoa(unsigned int value,char* output,int base){ str(ROOT / 'src/helpers/TxtDataHelpers.cpp'), '-o', str(binary)], capture_output=True, text=True) self.assertEqual(build.returncode, 0, build.stdout + build.stderr) - for scenario in range(10): + for scenario in range(11): with self.subTest(scenario=scenario): run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) diff --git a/test/test_companion_prefs_image.py b/test/test_companion_prefs_image.py index 1c16833d0..0c8163cda 100644 --- a/test/test_companion_prefs_image.py +++ b/test/test_companion_prefs_image.py @@ -61,6 +61,8 @@ static void seed(CompanionNodePrefs& prefs, uint32_t& random) { } }; @SEED@ + prefs.tx_media = random % 5; + prefs.tx_ssh = (random >> 8) % 5; } static std::vector golden(const CompanionNodePrefs& prefs, @@ -78,6 +80,18 @@ static std::vector golden(const CompanionNodePrefs& prefs, #else assert(bytes.size() == 236); #endif + // Independent wire definition of the new checked traffic footer. Keep + // every historical byte above unchanged and encode the public policy tags. + bytes.insert(bytes.end(), {'T', 'X', 'R', 1, + uint8_t(prefs.tx_media ? 0xa0 | prefs.tx_media : 0), + uint8_t(prefs.tx_ssh ? 0xa0 | prefs.tx_ssh : 0)}); + uint16_t crc = 0xffff; + for(size_t i=bytes.size()-6;i>8));bytes.push_back(uint8_t(crc)); return bytes; } @@ -105,14 +119,18 @@ int main() { CompanionNodePrefs original; assert(original.lost_reply == 0); + assert(original.tx_media == mesh::RADIO_TX_AUTO && original.tx_ssh == mesh::RADIO_TX_AUTO); strcpy(original.node_name, "durable image"); original.freq = 915.25f; original.ble_pin = 123456; original.bluetooth_enabled = 1; original.usb_debug_enabled = 1; original.gps_sync_interval_hours = 336; original.lost_reply = 2; + original.tx_media = mesh::RADIO_TX_SECONDARY; + original.tx_ssh = mesh::RADIO_TX_OFF; DataStore initial; assert(initial.savePrefs(original, 47.125, -122.25)); const auto before = golden(original, 47.125, -122.25); + const size_t historical_size=before.size()-mesh::companion::traffic::FooterSize; assert(initial.fs.files.at("/new_prefs") == before); // Every complete historical wire boundary stays readable, and an old @@ -135,10 +153,12 @@ int main() { auto historical = before; historical.resize(length); reboot.fs.files["/new_prefs"] = historical; CompanionNodePrefs live; live.lost_reply = 2; + live.tx_media=mesh::RADIO_TX_BOTH;live.tx_ssh=mesh::RADIO_TX_PRIMARY; live.getCustom()->setByKey("local_tag", "unchanged"); double lat = 1, lon = 2; assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon)); assert(live.lost_reply == 0 && live.ble_pin == original.ble_pin); + assert(live.tx_media==mesh::RADIO_TX_AUTO&&live.tx_ssh==mesh::RADIO_TX_AUTO); assert(live.freq == original.freq && !strcmp(live.node_name, original.node_name)); assert(lat == 47.125 && lon == -122.25); assert(reboot.fs.files.at("/new_prefs") == historical); @@ -151,6 +171,7 @@ int main() { // a full extra byte range, including sizes that could alias legacy values // if the underlying file size were accidentally narrowed with the table. std::vector accepted_sizes = legacy_sizes; + accepted_sizes.push_back(historical_size); accepted_sizes.push_back(before.size()); for (size_t length = 0; length <= before.size() + 256; ++length) { DataStore reboot; @@ -167,7 +188,9 @@ int main() { assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon) == expected); if (expected) { assert(live.freq == original.freq); - assert(live.lost_reply == (length == before.size() ? 2 : 0)); + assert(live.lost_reply == (length >= historical_size ? 2 : 0)); + assert(live.tx_media == (length == before.size() ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO)); + assert(live.tx_ssh == (length == before.size() ? mesh::RADIO_TX_OFF : mesh::RADIO_TX_AUTO)); assert(lat == 47.125 && lon == -122.25); } else { assert(live.freq == 920.5f && live.lost_reply == 1); @@ -189,8 +212,9 @@ int main() { DataStore writer; assert(writer.savePrefs(saved, 47.125, -122.25)); const auto disk = writer.fs.files.at("/new_prefs"); - assert(disk.back() == value); - assert(std::equal(disk.begin(), disk.end() - 1, before.begin())); + assert(disk[historical_size-1] == value); + assert(std::equal(disk.begin(), disk.begin()+historical_size-1, before.begin())); + assert(std::equal(disk.begin()+historical_size, disk.end(), before.begin()+historical_size)); DataStore reboot; reboot.fs.files["/new_prefs"] = disk; CompanionNodePrefs live; live.lost_reply = 2; double lat = 1, lon = 2; @@ -272,6 +296,7 @@ int main() { assert(loaded.freq == changed.freq && loaded.ble_pin == changed.ble_pin); assert(loaded.gps_sync_interval_hours == 24 && loaded.usb_debug_enabled == 0); assert(loaded.lost_reply == 1); + assert(loaded.tx_media == original.tx_media && loaded.tx_ssh == original.tx_ssh); assert(lat == 42.25 && lon == -121.125); #if defined(STM32_PLATFORM) diff --git a/test/test_companion_prefs_tail.py b/test/test_companion_prefs_tail.py index ea5d537f7..2cd696e74 100644 --- a/test/test_companion_prefs_tail.py +++ b/test/test_companion_prefs_tail.py @@ -55,22 +55,23 @@ int main() { assert(compact.savePrefs(prefs,47.1,-122.2)); assert(legacy.savePrefsLegacy(prefs,47.1,-122.2)); const auto& image=compact.fs.files["/new_prefs"]; - assert(image==legacy.fs.files["/new_prefs"]); + const size_t historical_size=image.size()-mesh::companion::traffic::FooterSize; + assert(std::vector(image.begin(),image.begin()+historical_size)==legacy.fs.files["/new_prefs"]); #if defined(TBEAM_1W) - assert(image.size()==243); + assert(historical_size==243); #elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) - assert(image.size()==333); + assert(historical_size==333); #else - assert(image.size()==236); + assert(historical_size==236); #endif const uint8_t expected[]={prefs.flood_retry_attempts,prefs.flood_retry_max_path, prefs.flood_retry_group_max_path,prefs.flood_retry_advert_enabled, prefs.one_key_dm_enabled,prefs.bluetooth_enabled,0,0,prefs.usb_debug_enabled, prefs.lost_reply}; - assert(!memcmp(image.data()+image.size()-10,expected,6)); - assert(!memcmp(image.data()+image.size()-4,&prefs.gps_sync_interval_hours,2)); - assert(image[image.size()-2]==prefs.usb_debug_enabled); - assert(image.back()==prefs.lost_reply); + assert(!memcmp(image.data()+historical_size-10,expected,6)); + assert(!memcmp(image.data()+historical_size-4,&prefs.gps_sync_interval_hours,2)); + assert(image[historical_size-2]==prefs.usb_debug_enabled); + assert(image[historical_size-1]==prefs.lost_reply); } DataStore initial; assert(initial.savePrefs(original,47.1,-122.2)); const auto disk=initial.fs.files["/new_prefs"]; @@ -94,9 +95,9 @@ int main() { }; // A short return at each boundary of the single final write must abort the // transaction, including either byte of the two-byte GPS field. - for (unsigned boundary=0; boundary<10; ++boundary) { + for (unsigned boundary=0; boundary<10+mesh::companion::traffic::FooterSize; ++boundary) { DataStore store; store.fs.files["/new_prefs"]=disk; - store.fs.fail_write_after=int(disk.size()-10+boundary); + store.fs.fail_write_after=int(disk.size()-mesh::companion::traffic::FooterSize-10+boundary); assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store); store.fs.fail_write_after=-1; assert(store.savePrefs(changed,42.3,-121.2)); @@ -104,9 +105,9 @@ int main() { } // Fully written bytes still cannot replace the live image until readback // verifies every final-tail boundary. LittleFS's retries also must fail. - for (unsigned boundary=0; boundary<10; ++boundary) { + for (unsigned boundary=0; boundary<10+mesh::companion::traffic::FooterSize; ++boundary) { DataStore store; store.fs.files["/new_prefs"]=disk; - store.fs.fail_read_after=int(disk.size()-10+boundary); + store.fs.fail_read_after=int(disk.size()-mesh::companion::traffic::FooterSize-10+boundary); assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store); } { diff --git a/test/test_companion_prefs_values.py b/test/test_companion_prefs_values.py index e875fff8a..2a97961b9 100644 --- a/test/test_companion_prefs_values.py +++ b/test/test_companion_prefs_values.py @@ -36,9 +36,9 @@ static size_t adapterOffset(const CompanionNodePrefs& prefs) { // The value helper does not copy the adapters or their inter-field padding. // Use the complete object's byte representation for bounded observation. const uintptr_t first = reinterpret_cast(&prefs); - const uintptr_t last = reinterpret_cast(&prefs.lost_reply); + const uintptr_t last = reinterpret_cast(&prefs.tx_ssh); assert(last >= first && last - first < sizeof(prefs)); - return static_cast(last - first) + sizeof(prefs.lost_reply); + return static_cast(last - first) + sizeof(prefs.tx_ssh); } static std::vector adapterBytes(const CompanionNodePrefs& prefs) { @@ -92,7 +92,10 @@ int main() { saved.bluetooth_enabled = static_cast(trial); DataStore image_store; assert(image_store.savePrefs(saved, 47.125, -122.25)); - const auto image = image_store.fs.files["/new_prefs"]; + auto image = image_store.fs.files["/new_prefs"]; + // The frozen reference remains authoritative only for the historical + // prefix. Checked new-footer behavior has dedicated persistence tests. + image.resize(image.size()-mesh::companion::traffic::FooterSize); // Test every possible old/torn image length, including every officially // supported append boundary and unknown lengths beyond the current tail. for (size_t length = 0; length <= image.size() + 5; ++length) { @@ -124,7 +127,9 @@ int main() { "/new_prefs", actual, lat, lon); assert(got == expected); assert(READ_CALLS == expected_reads); - assert(AVAILABLE_CALLS == expected_available); + // The common footer reader adds one EOF probe after any recognized + // historical image; no original field read or ordering changes. + assert(AVAILABLE_CALLS == expected_available+(expected_reads.empty()?0:1)); if (length == 84 && quota == 84) { // The original mandatory prefix has 21 reads, ending at ble_pin. // This order is an independent wire contract, not the new table. @@ -141,8 +146,9 @@ int main() { #endif // Every absent optional field observes EOF, then the completed // image receives the existing final trailing-byte check. - assert(AVAILABLE_CALLS == optional_fields + 1); + assert(AVAILABLE_CALLS == optional_fields + 2); } + if(got)legacy.tx_media=legacy.tx_ssh=mesh::RADIO_TX_AUTO; equalValues(actual, legacy); assert(!memcmp(&lat, &legacy_lat, sizeof(lat))); assert(!memcmp(&lon, &legacy_lon, sizeof(lon))); @@ -185,7 +191,7 @@ class CompanionPrefsValuesTests(unittest.TestCase): r'^\s*(?:float|char|u?int(?:8|16|32)_t)\s+([A-Za-z_][A-Za-z_0-9]*)', prefix, re.M) self.assertEqual(declarations[0], 'airtime_factor') - self.assertEqual(declarations[-2:], ['lost_reply', 'peripherals_shared']) + self.assertEqual(declarations[-2:], ['tx_media', 'tx_ssh']) optional = { 'fan_mode': 'TBEAM_1W', 'fan_lo': 'TBEAM_1W', 'fan_hi': 'TBEAM_1W', 'wifi_ssid': 'defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)', diff --git a/test/test_companion_settings_persistence_contract.py b/test/test_companion_settings_persistence_contract.py index b142938b9..831ea7a50 100644 --- a/test/test_companion_settings_persistence_contract.py +++ b/test/test_companion_settings_persistence_contract.py @@ -122,15 +122,13 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase): "bool copyPersistedValuesFrom(const CompanionNodePrefs& source)", ) self.assertIn("reinterpret_cast(&airtime_factor)", copy) - # RAK4631 appends its peripheral-mode byte; every other target stops - # at lost-reply. Neither endpoint may include a runtime adapter. - self.assertIn("const auto& last_value = peripherals_shared;", copy) - self.assertIn("const auto& last_value = lost_reply;", copy) + # Both common traffic preferences follow the optional RAK policy. + # The final value may never include either runtime adapter. + self.assertIn("const auto& last_value = tx_ssh;", copy) self.assertIn("reinterpret_cast(&last_value)", copy) - self.assertIn("&values.peripherals_shared", copy) - self.assertIn("&values.lost_reply", copy) - self.assertIn("+ sizeof(lost_reply)", copy) - self.assertIn("end - begin == size - sizeof(values.lost_reply)", copy) + self.assertIn("&values.tx_ssh", copy) + self.assertIn("+ sizeof(tx_ssh)", copy) + self.assertIn("end - begin == size - sizeof(values.tx_ssh)", copy) self.assertIn("reinterpret_cast(&values.radio)", copy) self.assertIn("reinterpret_cast(&values.custom)", copy) self.assertIn("size <= radio_begin - begin", copy) diff --git a/test/test_companion_traffic_routing.py b/test/test_companion_traffic_routing.py new file mode 100644 index 000000000..75183c6eb --- /dev/null +++ b/test/test_companion_traffic_routing.py @@ -0,0 +1,419 @@ +"""Exercise production Companion media/SSH routing without radios or flash writes.""" +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + + +HEADER_HARNESS = r''' +#include +#include +#include +#include +#include +#include + +int main() { + using namespace mesh; + using namespace mesh::companion; + for (const auto kind : {TrafficKind::Media, TrafficKind::Ssh}) { + const char* name=kind==TrafficKind::Media ? "media" : "ssh"; + char text[96]; + snprintf(text,sizeof(text),"get tx.%s",name); + auto parsed=parseTrafficRouteCommand(text); + assert(parsed.matched && parsed.valid && !parsed.set && parsed.kind==kind); + for (uint8_t policy=RADIO_TX_AUTO; policy<=RADIO_TX_OFF; ++policy) { + snprintf(text,sizeof(text),"set tx.%s %s",name,radioTxPolicyName(policy)); + parsed=parseTrafficRouteCommand(text); + assert(parsed.matched && parsed.valid && parsed.set && parsed.kind==kind && parsed.policy==policy); + } + for (const char* value : {"", "radio3", "RADIO", "radio trailing", "-1", "auto both"}) { + snprintf(text,sizeof(text),"set tx.%s %s",name,value); + parsed=parseTrafficRouteCommand(text); + assert(parsed.matched && !parsed.valid); + } + snprintf(text,sizeof(text),"get tx.%s extra",name); + parsed=parseTrafficRouteCommand(text); + assert(parsed.matched && !parsed.valid); + } + for (const char* text : {"get tx.mediax", "set tx.sshx radio", "get tx.user Bob", "get tx.channel 0", "set tx.radio both", ""}) + assert(!parseTrafficRouteCommand(text).matched); + assert(!parseTrafficRouteCommand(nullptr).matched); + + assert(rawTrafficKind(nullptr,0)==TrafficKind::Other); + assert(rawTrafficKind(nullptr,20)==TrafficKind::Other); + const uint8_t normal[]={0x56,0,1,2,3,0,99}; + const uint8_t salted[]={0x57,0,1,2,3,0,99,42}; + assert(rawTrafficKind(normal,sizeof(normal))==TrafficKind::Media); + assert(rawTrafficKind(salted,sizeof(salted))==TrafficKind::Media); + for (size_t n=0;n reserved(fetch_all,fetch_all+sizeof(fetch_all)); + reserved[5]=0x80; + assert(rawTrafficKind(reserved.data(),reserved.size())==TrafficKind::Other); + + const uint8_t ssh[]={0x53,0x53,0x48,0x01,0x99}; + assert(rawTrafficKind(ssh,sizeof(ssh))==TrafficKind::Ssh); + for (size_t n=0;n +#include +#include +#include +#include +#include +#include +#define OUT_PATH_UNKNOWN 0xff +#define MAX_GROUP_DATA_LENGTH 163 +namespace mesh { +struct GroupChannel { uint8_t secret[32]={},hash[1]={},tx_radio=RADIO_TX_AUTO; }; +struct Packet { uint8_t tx_radio=RADIO_TX_AUTO; }; +} +struct Radio { + mesh::RadioProfiles config; + bool available=true; + mesh::RadioProfiles* profiles() { return available ? &config : nullptr; } +}; +struct Prefs { uint8_t tx_media=mesh::RADIO_TX_AUTO,tx_ssh=mesh::RADIO_TX_AUTO; }; +class MyMesh { +public: + Prefs _prefs,saved; + Radio radio; Radio* _radio=&radio; + unsigned writes=0,group_calls=0,raw_calls=0,direct_calls=0; + uint8_t last_policy=0,last_path_len=0; + uint16_t last_type=0; + std::vector last_data; + mesh::Packet raw_packet; + bool save_ok=true,allocate_ok=true,send_ok=true; + bool savePrefs() { ++writes; if (!save_ok) return false; saved=_prefs; return true; } + template bool savePreference(T& field,T value) { + const T before=field; field=value; if (savePrefs()) return true; field=before; return false; + } + mesh::Packet* createRawData(const uint8_t* data,size_t len) { + ++raw_calls; if (!allocate_ok) return nullptr; raw_packet.tx_radio=mesh::RADIO_TX_AUTO; + last_data.assign(data,data+len); return &raw_packet; + } + bool sendDirect(mesh::Packet* packet,uint8_t*,uint8_t n) { + ++direct_calls; last_policy=packet->tx_radio; last_path_len=n; + return send_ok && mesh::companion::trafficTransmitMask(last_policy,_radio->profiles()); + } + bool sendGroupData(mesh::GroupChannel& channel,uint8_t*,uint8_t n,uint16_t type,const uint8_t* data,int len) { + ++group_calls; last_policy=channel.tx_radio; last_type=type; last_path_len=n; + if (len<0 || len>MAX_GROUP_DATA_LENGTH) return false; + last_data.assign(data,data+len); + return send_ok && mesh::companion::trafficTransmitMask(channel.tx_radio,_radio->profiles()); + } + bool handleTrafficRoutingCommand(const char*,char*,size_t); + bool sendClientGroupData(mesh::GroupChannel&,uint8_t*,uint8_t,uint16_t,const uint8_t*,int); + bool sendClientRawData(const uint8_t*,size_t,uint8_t*,uint8_t); +}; +@METHODS@ + +int main() { + using namespace mesh; + MyMesh node; + node.radio.config.secondary.mode=RadioProfileMode::RxTx; + node.radio.config.cross=RadioCrossMode::Off; + char reply[160]; + auto cmd=[&](const char* text) { memset(reply,0,sizeof(reply)); assert(node.handleTrafficRoutingCommand(text,reply,sizeof(reply))); }; + assert(!node.handleTrafficRoutingCommand("get tx.user Bob",reply,sizeof(reply))); + for (const char* name : {"media","ssh"}) { + char text[64]; + snprintf(text,sizeof(text),"get tx.%s",name); cmd(text); + assert(strstr(reply,"auto") && strstr(reply,"active=channel/global")); + for (uint8_t policy=0;policy<=4;++policy) { + snprintf(text,sizeof(text),"set tx.%s %s",name,radioTxPolicyName(policy)); cmd(text); + assert(!strncmp(reply,"OK",2)); + assert((!strcmp(name,"media") ? node._prefs.tx_media : node._prefs.tx_ssh)==policy); + } + } + assert(node.saved.tx_media==RADIO_TX_OFF && node.saved.tx_ssh==RADIO_TX_OFF); + const Prefs durable=node.saved; + const auto previous_writes=node.writes; + for (const char* text : {"set tx.media", "set tx.ssh radio3", "get tx.ssh extra"}) { + cmd(text); assert(!strncmp(reply,"Error",5)); + assert(node._prefs.tx_media==durable.tx_media && node._prefs.tx_ssh==durable.tx_ssh); + } + assert(node.writes==previous_writes); + node.save_ok=false; + // Even setting the current value must attempt a durable save; otherwise a + // boot following a failed flash-time preference write can report false OK. + cmd("set tx.media off"); assert(!strncmp(reply,"Error",5) && node.writes==previous_writes+1); + cmd("set tx.media both"); assert(!strncmp(reply,"Error",5)); + assert(node._prefs.tx_media==RADIO_TX_OFF && node.saved.tx_media==durable.tx_media); + cmd("set tx.ssh radio2"); assert(!strncmp(reply,"Error",5)); + assert(node._prefs.tx_ssh==RADIO_TX_OFF && node.saved.tx_ssh==durable.tx_ssh); + node.save_ok=true; + cmd("set tx.media radio2"); + node.radio.config.secondary.mode=RadioProfileMode::Rx; + cmd("get tx.media"); assert(strstr(reply,"unavailable") && strstr(reply,"active=off")); + node.radio.config.secondary.mode=RadioProfileMode::RxTx; + node.radio.config.secondary.params.protocol=RadioProfileProtocol::Meshtext; + cmd("get tx.media"); assert(strstr(reply,"unavailable") && strstr(reply,"active=off")); + node.radio.config.secondary.params.protocol=RadioProfileProtocol::MeshCore; + cmd("set tx.media auto"); cmd("set tx.ssh auto"); + const uint8_t media[]={0x56,0,1,2,3,0,99}; + const uint8_t ssh[]={'S','S','H',1,42}; + const uint8_t unknown[]={1,2,3,4,5,6,7}; + uint8_t path[]={0x11,0x22}; + GroupChannel channel; + channel.tx_radio=RADIO_TX_SECONDARY; + channel.secret[0]=0x42; + const auto original=channel; + for (uint8_t policy=0;policy<=4;++policy) { + node._prefs.tx_media=policy; node._prefs.tx_ssh=RADIO_TX_BOTH; + bool result=node.sendClientGroupData(channel,path,2,0x5643,media,sizeof(media)); + const uint8_t expected=policy==RADIO_TX_AUTO ? original.tx_radio : policy; + assert(result==(expected!=RADIO_TX_OFF)); + if (result) { assert(node.last_policy==expected && node.last_type==0x5643 && node.last_path_len==2); } + assert(!memcmp(&channel,&original,sizeof(channel))); + result=node.sendClientRawData(media,sizeof(media),path,2); + assert(result==(policy!=RADIO_TX_OFF)); + if (result) assert(node.last_policy==policy && node.last_data==std::vector(media,media+sizeof(media))); + } + node._prefs.tx_media=RADIO_TX_OFF; + for (uint8_t policy=0;policy<=4;++policy) { + node._prefs.tx_ssh=policy; + bool result=node.sendClientGroupData(channel,path,2,0x5353,ssh,sizeof(ssh)); + const uint8_t expected=policy==RADIO_TX_AUTO ? original.tx_radio : policy; + assert(result==(expected!=RADIO_TX_OFF)); + if (result) assert(node.last_policy==expected); + result=node.sendClientRawData(ssh,sizeof(ssh),path,2); + assert(result==(policy!=RADIO_TX_OFF)); + if (result) assert(node.last_policy==policy && node.last_data==std::vector(ssh,ssh+sizeof(ssh))); + assert(!memcmp(&channel,&original,sizeof(channel))); + } + node._prefs.tx_media=node._prefs.tx_ssh=RADIO_TX_OFF; + assert(node.sendClientGroupData(channel,path,2,0x5654,unknown,sizeof(unknown))); + assert(node.last_policy==original.tx_radio && node.last_type==0x5654); + assert(node.sendClientRawData(unknown,sizeof(unknown),path,2)); + assert(node.last_policy==RADIO_TX_AUTO); + node._prefs.tx_media=RADIO_TX_SECONDARY; + node.radio.config.secondary.mode=RadioProfileMode::Off; + assert(!node.sendClientRawData(media,sizeof(media),path,2)); + assert(!node.sendClientGroupData(channel,path,2,0x5643,media,sizeof(media))); + node.radio.config.secondary.mode=RadioProfileMode::Rx; + assert(!node.sendClientRawData(media,sizeof(media),path,2)); + node.radio.config.secondary.mode=RadioProfileMode::RxTx; + node.radio.config.secondary.params.protocol=RadioProfileProtocol::Meshtext; + assert(!node.sendClientRawData(media,sizeof(media),path,2)); + node.radio.config.secondary.params.protocol=RadioProfileProtocol::MeshCore; + node.allocate_ok=false; + assert(!node.sendClientRawData(media,sizeof(media),path,2)); + node.allocate_ok=true; node.send_ok=false; + assert(!node.sendClientRawData(media,sizeof(media),path,2)); + assert(!node.sendClientGroupData(channel,path,2,0x5643,media,sizeof(media))); + assert(!node.sendClientRawData(nullptr,20,path,2)); + assert(!node.sendClientRawData(media,3,path,2)); + assert(!node.sendClientGroupData(channel,path,2,0x5643,media,-1)); + assert(!node.sendClientGroupData(channel,path,2,0x5643,media,MAX_GROUP_DATA_LENGTH+1)); + assert(!node.sendClientGroupData(channel,path,2,0x5643,nullptr,1)); + assert(!node.handleTrafficRoutingCommand("get tx.media",nullptr,1)); + assert(!node.handleTrafficRoutingCommand("get tx.media",reply,0)); + // Reply formatting never writes past the caller's advertised capacity. + node.send_ok=true; + for (size_t cap=1;cap<12;++cap) { + char bounded[16]; memset(bounded,0x6b,sizeof(bounded)); + assert(node.handleTrafficRoutingCommand("get tx.media",bounded,cap)); + assert(bounded[cap-1]==0); + for (size_t i=cap;i +#include +#include +#include +#include +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#include +#pragma GCC diagnostic pop +@CONSTANTS@ +namespace mesh { +@PACKET_METHODS@ +} +class MyMesh { +public: + unsigned ok=0,errors=0,sends=0; + uint8_t error=0; + bool send_ok=true; + const uint8_t* frame=nullptr; + size_t frame_length=0; + void writeOKFrame() { ++ok; } + void writeErrFrame(uint8_t value) { ++errors; error=value; } + bool sendClientRawData(const uint8_t* data,size_t n,uint8_t* path,uint8_t encoded) { + ++sends; + const size_t bytes=(encoded & 63)*((encoded >> 6)+1); + assert(frame_length>=2+bytes+4); + assert(data==frame+2+bytes && n==frame_length-2-bytes); + assert(!memcmp(path,frame+2,bytes)); + return send_ok; + } + void onFrame(uint8_t* cmd_frame,size_t len) { + @RAW_CLAUSE@ + else writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + } +}; + +int main() { + // Every buffer is allocated to its exact advertised frame length. Reading + // even one byte past the received frame is therefore an ASan failure. + for (size_t length=6;length<=80;++length) for (unsigned encoded=0;encoded<256;++encoded) { + auto frame=std::unique_ptr(new uint8_t[length]); + for (size_t i=0;i(i*29+encoded); + frame[0]=CMD_SEND_RAW_DATA; + frame[1]=static_cast(encoded); + const unsigned width=(encoded >> 6)+1; + const size_t bytes=(encoded & 63)*width; + const bool valid=width!=4 && bytes<=MAX_PATH_SIZE; + const bool fits=length>=2+bytes+4; + for (const bool result : {false,true}) { + MyMesh node; + node.send_ok=result; node.frame=frame.get(); node.frame_length=length; + node.onFrame(frame.get(),length); + if (!valid) { + assert(node.errors==1 && node.error==ERR_CODE_UNSUPPORTED_CMD && !node.sends && !node.ok); + } else if (!fits) { + assert(node.errors==1 && node.error==ERR_CODE_ILLEGAL_ARG && !node.sends && !node.ok); + } else { + assert(node.sends==1); + if (result) assert(node.ok==1 && !node.errors); + else assert(!node.ok && node.errors==1 && node.error==ERR_CODE_BAD_STATE); + } + } + } +} +''' + + +class CompanionTrafficRoutingTest(unittest.TestCase): + def compile_and_run(self, source): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + cpp = work / 'test.cpp' + cpp.write_text(source) + build = subprocess.run([ + 'g++', '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-include', 'cstdio', '-fsanitize=address,undefined', + '-fno-pie', '-no-pie', '-I', str(ROOT / 'src'), + str(cpp), '-o', str(work / 'test'), + ], capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stdout + build.stderr) + run = subprocess.run([str(work / 'test')], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + def test_classification_parser_and_profile_permissions(self): + self.compile_and_run(HEADER_HARNESS) + + def test_real_cli_and_client_sends(self): + source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() + methods = '\n'.join(method(source, signature) for signature in ( + 'bool MyMesh::handleTrafficRoutingCommand(', + 'bool MyMesh::sendClientGroupData(', + 'bool MyMesh::sendClientRawData(', + )) + self.compile_and_run(METHOD_HARNESS.replace('@METHODS@', methods)) + + def test_binary_and_local_control_paths_use_routing(self): + source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() + local = method(source, 'bool MyMesh::handleLocalControlCommand(') + self.assertIn('handleTrafficRoutingCommand(command, reply, reply_size)', local) + raw_start = source.index('cmd_frame[0] == CMD_SEND_RAW_DATA') + raw_end = source.index('} else if (cmd_frame[0] == CMD_SEND_LOGIN', raw_start) + self.assertIn('sendClientRawData(', source[raw_start:raw_end]) + self.assertNotIn('createRawData(', source[raw_start:raw_end]) + group_start = source.index('cmd_frame[0] == CMD_SEND_CHANNEL_DATA') + group_end = source.index('} else if (cmd_frame[0] == CMD_GET_CONTACTS', group_start) + self.assertIn('sendClientGroupData(', source[group_start:group_end]) + + def test_real_raw_frame_path_bounds_and_failed_send_response(self): + source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() + packet = (ROOT / 'src/Packet.cpp').read_text() + clause = method(source, '} else if (cmd_frame[0] == CMD_SEND_RAW_DATA') + # The extracted clause is still production code: strip only the + # previous command's closing brace and its `else` connective. + clause = clause.removeprefix('} else ') + names = ('CMD_SEND_RAW_DATA', 'ERR_CODE_UNSUPPORTED_CMD', + 'ERR_CODE_ILLEGAL_ARG', 'ERR_CODE_BAD_STATE') + constants = '\n'.join(re.search( + r'^#define\s+' + name + r'\s+\d+.*$', source, re.MULTILINE).group(0) + for name in names) + packet_methods = '\n'.join(method(packet, signature) for signature in ( + 'bool Packet::isValidPathLen(', 'size_t Packet::writePath(', + )) + self.compile_and_run(RAW_FRAME_HARNESS.replace('@CONSTANTS@', constants) + .replace('@PACKET_METHODS@', packet_methods) + .replace('@RAW_CLAUSE@', clause)) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_companion_usb_logging.py b/test/test_companion_usb_logging.py index 289404fb7..079d48c98 100644 --- a/test/test_companion_usb_logging.py +++ b/test/test_companion_usb_logging.py @@ -55,17 +55,17 @@ int main(){ assert(node.savePrefs()); const auto original=node.store.fs.files["/new_prefs"]; #if defined(TBEAM_1W) - assert(original.size()==243); + assert(original.size()==251); #elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) - assert(original.size()==333); + assert(original.size()==341); #else - assert(original.size()==236); + assert(original.size()==244); #endif - const size_t debug_offset=original.size()-2; + const size_t debug_offset=original.size()-mesh::companion::traffic::FooterSize-2; assert(original[debug_offset]==0&&original[debug_offset-3]==0); assert(original[debug_offset-2]==1&&original[debug_offset-1]==1); assert(original[debug_offset-4]==1&&original[158]==1); - assert(original.back()==0); + assert(original[debug_offset+1]==0); char reply[160]={}; for(const char* malformed : {"set usb.debug","set usb.debug ", "set usb.debug 1","set usb.debug ON","set usb.debug on ", diff --git a/test/test_ethernet_kiss.py b/test/test_ethernet_kiss.py new file mode 100644 index 000000000..c7e53b42c --- /dev/null +++ b/test/test_ethernet_kiss.py @@ -0,0 +1,80 @@ +"""Run the actual bounded Ethernet KISS codec and TCP service with fake sockets.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test" / "fixtures" / "ethernet_kiss" + + +class EthernetKissTests(unittest.TestCase): + def test_kiss_port_does_not_collide_with_companion_services(self): + companion = (ROOT / "examples/companion_radio/main.cpp").read_text(encoding="utf-8") + server = (ROOT / "src/helpers/esp32/EthernetKissServer.h").read_text(encoding="utf-8") + terminal = re.search(r"#define\s+OTA_CONSOLE_TCP_PORT\s+(\d+)", companion) + kiss = re.search(r"static constexpr uint16_t Port\s*=\s*(\d+)", server) + self.assertIsNotNone(terminal) + self.assertIsNotNone(kiss) + self.assertEqual(int(terminal.group(1)), 5002) + self.assertEqual(int(kiss.group(1)), 5003) + self.assertNotIn(int(kiss.group(1)), {5000, 5001, int(terminal.group(1))}) + + @classmethod + def setUpClass(cls): + cls.directory = tempfile.TemporaryDirectory() + cls.executable = Path(cls.directory.name) / "ethernet-kiss" + command = [ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-g", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie", + "-I", str(FIXTURE), "-I", str(ROOT / "src"), + str(FIXTURE / "test_ethernet_kiss.cpp"), "-o", str(cls.executable), + ] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode: + cls.directory.cleanup() + raise AssertionError(result.stdout + result.stderr) + + @classmethod + def tearDownClass(cls): + cls.directory.cleanup() + + def run_case(self, name): + environment = dict(os.environ, ASAN_OPTIONS="detect_leaks=1:halt_on_error=1") + result = subprocess.run([str(self.executable), name], env=environment, + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_every_ethernet_length_and_tcp_split(self): + self.run_case("codec-roundtrip") + + def test_malformed_escapes_ports_short_and_oversized_frames(self): + self.run_case("codec-malformed") + + def test_queue_backpressure_and_partial_output(self): + self.run_case("codec-backpressure") + + def test_reset_and_bounded_random_input(self): + self.run_case("codec-reset-fuzz") + + def test_station_only_binding_single_client_and_disable(self): + self.run_case("server-station") + + def test_per_poll_budgets_and_tcp_backpressure(self): + self.run_case("server-budget-backpressure") + + def test_nonblocking_writes_resume_without_duplicate_bytes(self): + self.run_case("server-partial-writes") + + def test_disconnect_reconnect_address_change_discards_stale_work(self): + self.run_case("server-session-reset") + + def test_listener_retry_eof_and_socket_errors(self): + self.run_case("server-failures") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_radio.py b/test/test_ethernet_radio.py new file mode 100644 index 000000000..48c41a716 --- /dev/null +++ b/test/test_ethernet_radio.py @@ -0,0 +1,334 @@ +"""Exercise the actual foreign-radio isolation and Ethernet ownership layer. + +These host tests prove routing/state contracts, not RF delivery or TCP speed. +""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_meshtext_transport import HARNESS as TRANSPORT_HARNESS +from test_radio_profile_scan import HARNESS as SCAN_HARNESS +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +class EthernetRadioTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("a host C++17 compiler is required") + + def compile_run(self, code, flags=(), sources=(), includes=(), arduino_stub=False): + with tempfile.TemporaryDirectory(prefix="meshcore-ethernet-radio-") as folder: + cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test" + cpp.write_text(code, encoding="utf-8") + command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", *flags] + if arduino_stub: + (Path(folder) / "Arduino.h").write_text("#pragma once\n#include \n#include \n") + command += ["-I", folder] + if os.name != "nt": + command += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + for include in includes: + command += ["-I", str(ROOT / include)] + command += ["-I", str(ROOT), "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src")] + command += [str(ROOT / source) for source in sources] + [str(cpp), "-o", str(binary)] + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, (result.stdout + result.stderr)[-16000:]) + env = os.environ.copy() + env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20, env=env) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_feature_matrix_and_late_chip_discovery(self): + code = r''' +#include +#if !defined(USE_SX1262) +#define USE_SX1262 1 +#endif +static_assert(MESH_ENABLE_ETHERNET_TUNNEL == EXPECT_ETH, "Ethernet feature scope"); +static_assert(bool(MESH_ENABLE_FOREIGN_RADIO) == bool(EXPECT_ETH || MESH_ENABLE_MESHTEXT_CHAT), "shared queue"); +int main() {} +''' + cases = [ + ([], 0), + (["ESP32_PLATFORM", "COMPANION_RADIO_FULL", "WIFI_SSID=\"\""], 1), + (["ESP32_PLATFORM", "COMPANION_RADIO_FULL"], 0), + (["ESP32_PLATFORM", "WIFI_SSID=\"\""], 0), + (["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "WIFI_SSID=\"\""], 0), + (["ESP32_PLATFORM", "COMPANION_RADIO_FULL", "WIFI_SSID=\"\"", "MESH_PRIMARY_ESPNOW=1"], 0), + (["ESP32_PLATFORM", "COMPANION_RADIO_FULL", "WIFI_SSID=\"\"", "MESH_PRIMARY_ESPNOW=0"], 1), + (["ESP32_PLATFORM", "COMPANION_RADIO_FULL", "WIFI_SSID=\"\"", "MESH_ENABLE_ETHERNET_TUNNEL=0"], 0), + ] + for defines, expected in cases: + with self.subTest(defines=defines): + self.compile_run(code, [*("-D" + item for item in defines), "-DEXPECT_ETH=" + str(expected)]) + + def test_dispatcher_ethernet_only_and_shared_queue(self): + code = TRANSPORT_HARNESS.split("int main() {", 1)[0] + code = code.replace("bool incoming=false;", "bool incoming=false, ethernet_supported=true;") + code = code.replace("uint8_t active=0;", "uint8_t active=0; uint32_t rx_generation=0;") + code = code.replace("uint8_t receiveProfile() const override { return active; }", + "uint8_t receiveProfile() const override { return active; }\n" + " uint32_t receiveProfileGeneration() const override { return rx_generation ? rx_generation : p.generation[active]; }") + code = code.replace("bool supportsMeshtext() const override { return supported; }", + "bool supportsMeshtext() const override { return supported; }\n" + " bool supportsEthernetTunnel() const override { return ethernet_supported; }") + code = code.replace("std::vector done;", + "std::vector done; std::vector done_protocols;") + code = code.replace("done.push_back(ok);", + "done.push_back(ok); done_protocols.push_back(queuedForeignRadioProtocol());") + code = code.replace("using Dispatcher::hasForeignRadioFrame;", + "using Dispatcher::hasForeignRadioFrame;\n using Dispatcher::discardQueuedForeignRadioFrame;") + code += r''' +static void ethernet(Fixture& f) { + f.radio.p.secondary.params.protocol=RadioProfileProtocol::Ethernet; + f.radio.p.secondary.params.preamble=32; +} +static bool enqueue(Fixture& f) { + return f.d.queueForeignRadioFrame(bytes,sizeof(bytes),1,f.radio.p.generation[1],RadioProfileProtocol::Ethernet); +} +int main() { + using P=RadioProfileProtocol; + { Fixture f;ethernet(f);assert(enqueue(f));f.tick(); + assert(f.radio.sends==1 && f.radio.active==1 && f.radio.sent==std::vector(bytes,bytes+3)); + f.radio.complete=true;f.tick();assert(f.d.done.size()==1 && f.d.done[0]); + assert(f.d.done_protocols[0]==P::Ethernet && f.radio.p.tx_packets[1]==1); + assert(!f.d.getNumSentFlood() && !f.d.getNumSentDirect()); + } + { Fixture f;ethernet(f);assert(enqueue(f)); + f.radio.p.secondary.params.protocol=P::Meshtext; // even a bad external owner not bumping generation + f.tick();assert(!f.radio.sends && f.d.done.size()==1 && !f.d.done[0]); + assert(f.d.done_protocols[0]==P::Ethernet); + } + { Fixture f;ethernet(f);assert(enqueue(f));++f.radio.p.generation[1];f.tick(); + assert(!f.radio.sends && !f.d.done[0]); + } + { Fixture f;ethernet(f);f.radio.active=1;f.radio.incoming=true;f.tick(); + assert(f.d.foreign_rx==1 && !f.d.mc_rx && !f.d.raw_logs && !f.d.getLastMeshCoreRecvMillis()); + f.radio.ethernet_supported=false;f.radio.incoming=true;f.tick(); + assert(f.d.foreign_rx==1 && !f.d.mc_rx && !f.d.raw_logs); + f.radio.p.secondary.params.protocol=P(99);f.radio.incoming=true;f.tick(); + assert(f.d.foreign_rx==1 && !f.d.mc_rx && !f.d.raw_logs && f.radio.processed==3); + } + { Fixture f;ethernet(f);f.radio.active=1; + f.radio.rx_generation=f.radio.p.generation[1]; + f.radio.p.secondary.params.protocol=P::MeshCore;++f.radio.p.generation[1]; + f.radio.incoming=true;f.tick(); + assert(!f.d.foreign_rx && !f.d.mc_rx && !f.d.raw_logs && f.radio.processed==1); + } + { Fixture f;ethernet(f);f.radio.ethernet_supported=false;assert(!enqueue(f)); + f.radio.ethernet_supported=true;f.radio.p.secondary.mode=RadioProfileMode::Rx;assert(!enqueue(f)); + f.radio.p.secondary.mode=RadioProfileMode::RxTx; + assert(!f.d.queueForeignRadioFrame(bytes,3,0,f.radio.p.generation[0],P::Ethernet)); + assert(!f.d.queueForeignRadioFrame(bytes,3,1,f.radio.p.generation[1],P::MeshCore)); + assert(!f.d.queueForeignRadioFrame(bytes,3,1,f.radio.p.generation[1],P(99))); + } + { Fixture f;ethernet(f);assert(enqueue(f)); + assert(!f.d.discardQueuedForeignRadioFrame(P::Meshtext)); + assert(f.d.discardQueuedForeignRadioFrame(P::Ethernet)); + assert(!f.d.hasForeignRadioFrame() && f.d.done.size()==1 && !f.d.done[0]); + f.tick();assert(!f.radio.sends); + assert(enqueue(f));f.tick();assert(!f.d.discardQueuedForeignRadioFrame(P::Ethernet)); + f.radio.complete=true;f.tick();assert(f.d.done.size()==2 && f.d.done[1]); + } + { Fixture f;ethernet(f);assert(enqueue(f));f.tick();f.tick(1151); + assert(f.radio.sends==1 && f.radio.recovery==1 && !f.d.done[0]); + f.tick(5000);assert(f.radio.sends==1); + } + { Fixture f;ethernet(f);auto* pkt=newPacket(f.d);f.radio.p.cross=RadioCrossMode::On; + assert(!f.radio.p.canCross() && f.d.getTransmitProfileMask(pkt)==1); + assert(f.d.sendPacket(pkt,0));f.tick();assert(f.radio.active==0); + auto* denied=newPacket(f.d,RADIO_TX_SECONDARY);assert(!f.d.sendPacket(denied,0)); + } + { Fixture f;ethernet(f);assert(enqueue(f));assert(!f.d.queue(bytes)); + f.d.setRadioAvailable(false);assert(!f.d.hasForeignRadioFrame() && !f.d.done[0]); + assert(!enqueue(f)); + } + { Fixture f;f.radio.active=1;f.radio.incoming=true;f.tick(); +#if MESH_ENABLE_MESHTEXT_CHAT + assert(f.d.foreign_rx==1);assert(f.d.queue(bytes)); +#else + assert(!f.d.foreign_rx);assert(!f.d.queue(bytes)); +#endif + assert(!f.d.mc_rx && !f.d.raw_logs); + } +} +''' + for chat in (0, 1): + with self.subTest(chat=chat): + self.compile_run(code, ["-DMESH_ENABLE_ETHERNET_TUNNEL=1", f"-DMESH_ENABLE_MESHTEXT_CHAT={chat}"], + ["src/Dispatcher.cpp", "src/Packet.cpp", "src/helpers/StaticPoolPacketManager.cpp"]) + + def test_ethernet_auto_and_manual_ownership_without_chat(self): + code = r''' +#define main existing_cli_main +#include "test/fixtures/radio_profiles/cli_test.cpp" +#undef main +int main() { + using P=mesh::RadioProfileProtocol; + Fixture f; + mesh::RadioProfileConfig config; + config.params=f.radio.p.primary;config.params.freq=910.5f;config.params.preamble=32; + config.params.protocol=P::Ethernet;config.mode=mesh::RadioProfileMode::RxTx; + assert(f.cli.canAutoSecondary() && f.cli.requestAutoSecondary(config)); + assert(f.cli.autoSecondaryActive() && f.radio.p.secondary.params.protocol==P::Ethernet); + assert(f.radio.profilePreamble(1)==32 && f.fs.files.empty()); + const auto generation=f.radio.p.generation[1]; + assert(f.cli.requestAutoSecondary(config));assert(f.radio.p.generation[1]==generation); + auto conflict=config;conflict.params.protocol=P::Meshtext; + assert(!f.cli.requestAutoSecondary(conflict)); + f.cli.clearAutoSecondary();assert(!f.radio.p.enabled()); + f.cmd("set radio2 910.5,500,8,5,rxtx,80");f.advance(2000); + const auto saved=f.fs.files["/radio_profiles"]; + assert(f.cli.useSecondaryProtocol(P::Ethernet)); + assert(f.radio.p.secondary.params.protocol==P::Ethernet && f.radio.profilePreamble(1)==80); + assert(f.fs.files["/radio_profiles"]==saved); + assert(!f.cli.useSecondaryProtocol(P::Meshtext)); + assert(f.radio.p.secondary.params.protocol==P::Ethernet); + f.cli.clearSecondaryProtocol();assert(f.radio.p.secondary.params.protocol==P::MeshCore); + assert(f.radio.profilePreamble(1)==80 && f.fs.files["/radio_profiles"]==saved); + assert(f.cli.useSecondaryProtocol(P::Ethernet)); + f.cmd("set tempradio2 911.5,500,8,5,rxtx,1,80");f.advance(2000); + f.cli.clearSecondaryProtocol(); + assert(f.radio.p.secondary.params.protocol==P::MeshCore && f.radio.p.secondary.params.freq==911.5f); + assert(!f.cli.useSecondaryProtocol(P::Ethernet)); + Fixture veto;veto.cmd("set radio2 off");veto.advance(2000); + assert(!veto.cli.canAutoSecondary() && !veto.cli.requestAutoSecondary(config)); +} +''' + for chat in (0, 1): + with self.subTest(chat=chat): + self.compile_run(code, ["-DMESH_ENABLE_ETHERNET_TUNNEL=1", f"-DMESH_ENABLE_MESHTEXT_CHAT={chat}"], + ["src/helpers/RadioProfileCLI.cpp"], ["test/fixtures/radio_profiles/mocks"]) + + def test_actual_sx1262_and_sx1276_syncword_selection(self): + for chip in ("SX1262", "SX1276"): + source = (ROOT / f"src/helpers/radiolib/Custom{chip}Wrapper.h").read_text() + protocol = method(source, "bool applyProtocol(").replace(" override", "") + code = r''' +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +struct Custom@CHIP@ { + unsigned writes=0;uint8_t sync=0; + int result=0; + int setProfileProtocol(uint8_t value) { ++writes;sync=value;return result; } +}; +struct Driver { + Custom@CHIP@ chip;Custom@CHIP@* _radio=&chip; + bool supportsMeshtext() const { return MESH_ENABLE_MESHTEXT_WIRE; } + bool supportsEthernetTunnel() const { return MESH_ENABLE_ETHERNET_TUNNEL; } + @METHOD@ +}; +int main() { + using P=mesh::RadioProfileProtocol;Driver d; + assert(d.applyProtocol(P::MeshCore) && d.chip.sync==0x12); +#if MESH_ENABLE_ETHERNET_TUNNEL + assert(d.applyProtocol(P::Ethernet) && d.chip.sync==0x16); + assert(d.applyProtocol(P::MeshCore) && d.chip.sync==0x12); +#else + const auto before_eth=d.chip.writes;assert(!d.applyProtocol(P::Ethernet));assert(d.chip.writes==before_eth); +#endif +#if MESH_ENABLE_MESHTEXT_WIRE + assert(d.applyProtocol(P::Meshtext) && d.chip.sync==0x2b); +#else + const auto before_mt=d.chip.writes;assert(!d.applyProtocol(P::Meshtext));assert(d.chip.writes==before_mt); +#endif + const auto before=d.chip.writes;assert(!d.applyProtocol(P(99)));assert(d.chip.writes==before); + d.chip.result=-1;assert(!d.applyProtocol(P::MeshCore)); +} +'''.replace("@CHIP@", chip).replace("@METHOD@", protocol) + for wire, ethernet in ((0, 1), (1, 1), (1, 0)): + with self.subTest(chip=chip, wire=wire, ethernet=ethernet): + self.compile_run(code, [f"-DMESH_ENABLE_MESHTEXT_WIRE={wire}", + f"-DMESH_ENABLE_ETHERNET_TUNNEL={ethernet}"]) + + def test_driver_validator_rejects_disabled_or_unsupported_ethernet(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + validator = method(source, "bool RadioLibWrapper::validateProfile(") + code = r''' +#include +#include +#include +struct RadioLibWrapper { + bool ethernet=true; + bool supportsMeshtext() const { return true; } + bool supportsEthernetTunnel() const { return ethernet; } + bool validateProfile(const mesh::RadioProfileParams&) const; +}; +@VALIDATOR@ +int main() { + RadioLibWrapper driver;mesh::RadioProfileParams p; + p.freq=909.5;p.bw=500;p.sf=8;p.cr=5;p.preamble=32; + p.protocol=mesh::RadioProfileProtocol::Ethernet; + assert(driver.validateProfile(p)==bool(MESH_ENABLE_ETHERNET_TUNNEL)); + driver.ethernet=false;assert(!driver.validateProfile(p)); + p.protocol=mesh::RadioProfileProtocol::MeshCore;assert(driver.validateProfile(p)); + p.protocol=mesh::RadioProfileProtocol(99);assert(!driver.validateProfile(p)); +} +'''.replace("@VALIDATOR@", validator) + for enabled in (0, 1): + self.compile_run(code, [f"-DMESH_ENABLE_ETHERNET_TUNNEL={enabled}", "-DMESH_RADIO_PROFILE_CHIP=1262"]) + + def test_ethernet_only_physical_retune_and_failed_protocol_rollback(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + names = [("uint8_t", "beginReconfigure"), ("void", "endReconfigure"), + ("void", "serviceProfileScan")] + [("mesh::RadioParamApplyResult", name) for name in + ("tuneProfile", "prepareTransmitProfile", "tryRestoreCodingRate", "trySetParams", "trySetPrimaryParams")] + methods = "\n".join(method(source, f"{kind} RadioLibWrapper::{name}(") for kind, name in names) + methods += "\n" + method(source, "mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool") + wrapper = (ROOT / "src/helpers/radiolib/CustomSX1262Wrapper.h").read_text() + methods += "\n" + method(wrapper, "void setProfileStandbyWarm(").replace( + "void setProfileStandbyWarm(bool enabled) override", + "void RadioLibWrapper::setProfileStandbyWarm(bool enabled)") + harness = "#include \n" + SCAN_HARNESS.split("@METHODS@")[0] + harness = harness.replace("uint16_t _physical_preamble=32;", r''' + uint16_t _physical_preamble=32; + mesh::RadioProfileProtocol _physical_protocol=mesh::RadioProfileProtocol::MeshCore; + mesh::RadioProfileProtocol applied_protocol=mesh::RadioProfileProtocol::MeshCore; + bool fail_protocol=false;unsigned protocol_writes=0; + bool applyProtocol(mesh::RadioProfileProtocol protocol) { + ++protocol_writes; + if (fail_protocol && protocol==mesh::RadioProfileProtocol::Ethernet) return false; + applied_protocol=protocol;return true; + } +''') + harness += methods + r''' +int main() { + using P=mesh::RadioProfileProtocol;using R=mesh::RadioParamApplyResult; + { RadioLibWrapper driver;mesh::RadioProfileConfig config; + config.params=driver._profiles.primary;config.params.protocol=P::Ethernet; + config.params.preamble=80;config.mode=mesh::RadioProfileMode::RxTx; + driver._profiles.setSecondary(config,false); + assert(driver.tuneProfile(1)==R::APPLIED); + assert(driver._physical_protocol==P::Ethernet && driver.applied_protocol==P::Ethernet); + assert(driver._physical_preamble==80 && driver.protocol_writes==1); + assert(driver.tuneProfile(0)==R::APPLIED); + assert(driver._physical_protocol==P::MeshCore && driver.applied_protocol==P::MeshCore); + assert(driver.protocol_writes==2); + } + { RadioLibWrapper driver;mesh::RadioProfileConfig config; + config.params=driver._profiles.primary;config.params.protocol=P::Ethernet; + config.params.preamble=32;config.mode=mesh::RadioProfileMode::RxTx; + driver._profiles.setSecondary(config,false);driver.fail_protocol=true; + assert(driver.tuneProfile(1)==R::FAILED); + assert(driver._physical_protocol==P::MeshCore && driver.applied_protocol==P::MeshCore); + assert(driver._active_profile==0 && driver._profile_generation==1); + assert(driver.protocol_writes==2); + } +} +''' + for flags in (("-DMESH_ENABLE_ETHERNET_TUNNEL=1",), + ("-DMESH_ENABLE_ETHERNET_TUNNEL=0", "-DMESH_ENABLE_ETHERNET_WIRE=1")): + with self.subTest(flags=flags): + self.compile_run(harness, [*flags, "-DMESH_ENABLE_MESHTEXT_WIRE=0"], arduino_stub=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_hil_tx_frequency_guard.py b/test/test_hil_tx_frequency_guard.py new file mode 100644 index 000000000..957500b03 --- /dev/null +++ b/test/test_hil_tx_frequency_guard.py @@ -0,0 +1,193 @@ +"""Execute actual TX entry points with the opt-in lab RF boundary enabled.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +import test_beacon_radio +from test_beacon_radio import method +from test_carrier_wave import production_harness + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp" + + +class HilTxFrequencyGuardTest(unittest.TestCase): + def compile_run(self, code, defines=(), guard=True): + with tempfile.TemporaryDirectory(prefix="meshcore-hil-tx-") as directory: + cpp, binary = Path(directory) / "test.cpp", Path(directory) / "test" + cpp.write_text(code) + command = [os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + *(["-DMESH_HIL_TX_FREQ_MHZ=909.5"] if guard else []), + *defines, str(cpp), "-o", str(binary)] + built = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_packet_and_beacon_actual_entry_points(self): + code = test_beacon_radio.BeaconRadioTest().source().split("int main() {", 1)[0] + code = code.replace("#include ", "#include \n#include \n" + "#include ") + code = code.replace("int startTransmit(uint8_t*, int) { return tx_result; }", + "unsigned starts=0; int startTransmit(uint8_t*, int) { ++starts; return tx_result; }") + code = code.replace("bool _beacon_owned = false, _beacon_restoring = false;", + "bool _beacon_owned = false, _beacon_restoring = false;\n" + "float _hil_beacon_tx_frequency_mhz=0; bool hilPacketTransmitAllowed() const;") + code += method(SOURCE.read_text(), "bool RadioLibWrapper::hilPacketTransmitAllowed() const") + code += r''' +void configure(RadioLibWrapper& w, float f=909.5f) { + w._profiles.primary.freq=w._cur_freq=f; + w._profiles.primary.bw=w._cur_bw=500; + w._profiles.primary.sf=w._cur_sf=5; + w._profiles.primary.cr=w._cur_cr=5; + w._physical_preamble=w.profilePreamble(0); +} +void blocked(RadioLibWrapper& w) { + uint8_t bytes[14]={}; + assert(!w.startSendRaw(bytes,sizeof(bytes))); + assert(w.board.before==0 && w.modem.starts==0); +} +int main() { + uint8_t bytes[14]={}; + { RadioLibWrapper w; configure(w,910.525f); blocked(w); } + { RadioLibWrapper w; configure(w); assert(w.startSendRaw(bytes,14)); + assert(w.board.before==1 && w.modem.starts==1); } + { RadioLibWrapper w; configure(w); w._params_valid=false; blocked(w); } + { RadioLibWrapper w; configure(w); w._profile_refresh_required=true; blocked(w); } + { RadioLibWrapper w; configure(w); ++w._profiles.generation[0]; blocked(w); } + { RadioLibWrapper w; configure(w); w._active_profile=2; blocked(w); } + { RadioLibWrapper w; configure(w); w._active_profile=1; blocked(w); } + { RadioLibWrapper w; configure(w); w._cur_freq=910.525f; blocked(w); } + { RadioLibWrapper w; configure(w); w._profiles.primary.freq=910.525f; blocked(w); } + { RadioLibWrapper w; configure(w); w._cur_bw=250; blocked(w); } + { RadioLibWrapper w; configure(w); w._cur_sf=6; blocked(w); } + { RadioLibWrapper w; configure(w); w._cur_cr=6; blocked(w); } + { RadioLibWrapper w; configure(w); ++w._physical_preamble; blocked(w); } + { RadioLibWrapper w; configure(w); w._physical_protocol=mesh::RadioProfileProtocol::Ethernet; blocked(w); } + { RadioLibWrapper w; configure(w); w._cur_freq=std::numeric_limits::quiet_NaN(); blocked(w); } + { RadioLibWrapper w; configure(w); auto p=beacon(); p.freq=910.525f; + assert(w.prepareBeaconTransmit(p,10)==Result::FAILED); + assert(w.applications==0 && !w._beacon_owned); blocked(w); } + { RadioLibWrapper w; configure(w,910.525f); auto p=beacon(); p.freq=909.5f; + assert(w.prepareBeaconTransmit(p,10)==Result::APPLIED); + assert(w._cur_freq==910.525f && w.modem.freq==909.5f); + assert(w.startSendRaw(bytes,14)); assert(w.board.before==1 && w.modem.starts==1); + w.onSendFinished(); w.modem.param_fail=true; w.deep_ok=false; + assert(w.restoreBeaconReceive()==Result::BUSY); + assert(w._hil_beacon_tx_frequency_mhz==0 && !w.hilPacketTransmitAllowed()); } + { RadioLibWrapper w; configure(w); auto p=beacon(); p.freq=909.5f; + w.modem.param_fail=true; + assert(w.prepareBeaconTransmit(p,10)==Result::FAILED); + assert(w._hil_beacon_tx_frequency_mhz==0); blocked(w); } +} +''' + # After rejecting an unsafe beacon, the normal allowed profile can + # still transmit. That case only checks the beacon's absence of RF. + code = code.replace("assert(w.applications==0 && !w._beacon_owned); blocked(w);", + "assert(w.applications==0 && !w._beacon_owned && w.board.before==0 && w.modem.starts==0);") + self.compile_run(code) + + def test_transmit_preparation_rejects_unsafe_tuple_before_retune(self): + code = r''' +#include +#include +#include +namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } +struct RadioLibWrapper { + mesh::RadioProfiles _profiles; + unsigned tunes=0; + bool isChipBusy() const { return false; } + bool isPacketPendingOrReceiving() const { return false; } + mesh::RadioParamApplyResult tuneProfile(uint8_t) { ++tunes; return mesh::RadioParamApplyResult::APPLIED; } + mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t, bool); +}; +''' + code += method(SOURCE.read_text(), "mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool") + code += r''' +int main() { + using Result=mesh::RadioParamApplyResult; + RadioLibWrapper w; w._profiles.primary.freq=910.525f; + w._profiles.secondary.params.freq=909.5f; + w._profiles.secondary.mode=mesh::RadioProfileMode::RxTx; + assert(w.prepareTransmitProfile(0,false)==Result::FAILED && w.tunes==0); + assert(w.prepareTransmitProfile(255,false)==Result::FAILED && w.tunes==0); + assert(w.prepareTransmitProfile(1,false)==Result::APPLIED && w.tunes==1); +} +''' + self.compile_run(code) + + def test_carrier_and_morse_actual_entry_points(self): + code = production_harness().split("int main() {", 1)[0] + code = code.replace("#include ", "#include \n" + "#include ") + code = code.replace("float _cur_freq=909.5;", "float _cur_freq=909.5, _cur_bw=500;\n" + "uint8_t _cur_sf=5, _cur_cr=5; uint16_t _physical_preamble=128;\n" + "uint32_t _profile_generation=1; bool hilPacketTransmitAllowed() const;\n" + "uint16_t profilePreamble(uint8_t p) const { return _profiles.preamble(p,32); }") + code = code.replace("_profiles.primary.bw=500;", "_profiles.primary.bw=500;\n" + "_profiles.primary.sf=_profiles.secondary.params.sf=5;\n" + "_profiles.primary.cr=_profiles.secondary.params.cr=5;") + code = code.replace("chip.frequency=frequency;", "chip.frequency=frequency;\n" + "_cur_bw=_profiles.params(target).bw; _cur_sf=_profiles.params(target).sf;\n" + "_cur_cr=_profiles.params(target).cr; _profile_generation=_profiles.generation[target];\n" + "_physical_preamble=profilePreamble(target);") + code += method(SOURCE.read_text(), "bool RadioLibWrapper::hilPacketTransmitAllowed() const") + code += r''' +int main() { + using Result=mesh::RadioParamApplyResult; + { RadioLibWrapper w; w._profiles.primary.freq=910.525f; + assert(w.setCarrierWave(0,100)==Result::FAILED); + assert(w.board.before==0 && w.chip.starts==0 && w.tunes==0); } + { RadioLibWrapper w; assert(w.setCarrierWave(0,100)==Result::APPLIED); + assert(w.board.before==1 && w.chip.starts==1); + ++w._profiles.generation[0]; assert(!w.setTxPower(10)); + assert(w.chip.starts==1); assert(w.serviceCarrierWave()); assert(!w.chip.carrier); } + { RadioLibWrapper w; w._profile_refresh_required=true; + assert(w.setCarrierWave(0,100)==Result::FAILED); + assert(w.board.before==0 && w.chip.starts==0); } + { RadioLibWrapper w; + assert(w.startMorseTx(910.525f,25,"EE")==mesh::MorseTxResult::InvalidFrequency); + assert(w.board.before==0 && w.chip.starts==0 && w.board.frequency_prepares==0); } + { RadioLibWrapper w; w._profiles.primary.freq=910.525f; + assert(w.startMorseTx(909.5f,25,"EE")==mesh::MorseTxResult::Started); + assert(w.board.before==1 && w.chip.starts==1 && w.chip.frequency==909.5f); + w._morse_frequency=910.525f; assert(w.serviceMorseTx()); + assert(w.chip.starts==1 && !w.chip.carrier); } +} +''' + self.compile_run(code, ("-DMESH_ENABLE_MORSE_RX=0",)) + + def test_read_only_runtime_preflight_and_production_default(self): + code = r''' +#include +#include +#include +#include +int main() { + char reply[160]="unchanged"; +#if defined(MESH_HIL_TX_FREQ_MHZ) + assert(mesh::handleHilTxFrequencyCommand("get lab.tx",reply,sizeof(reply))); + assert(strcmp(reply,"> HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency")==0); + assert(mesh::hilTransmitFrequencyAllowed(909.5f)); + assert(!mesh::hilTransmitFrequencyAllowed(910.525f)); + assert(!mesh::hilTransmitFrequencyAllowed(std::numeric_limits::quiet_NaN())); + assert(!mesh::handleHilTxFrequencyCommand("set lab.tx 910.525",reply,sizeof(reply))); +#else + assert(!mesh::handleHilTxFrequencyCommand("get lab.tx",reply,sizeof(reply))); + assert(strcmp(reply,"unchanged")==0); + assert(mesh::hilTransmitFrequencyAllowed(910.525f)); +#endif +} +''' + self.compile_run(code) + self.compile_run(code, guard=False) + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + self.assertIn("#if defined(MESH_HIL_TX_FREQ_MHZ)\n" + " if (mesh::handleHilTxFrequencyCommand(command, reply, reply_size)) return true;\n#endif", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_kiss_boot_trace.py b/test/test_kiss_boot_trace.py new file mode 100644 index 000000000..cb9ca99b0 --- /dev/null +++ b/test/test_kiss_boot_trace.py @@ -0,0 +1,86 @@ +"""Lab startup trace is opt-in and cannot enter native/dedicated KISS UARTs.""" +from pathlib import Path +import os +import shutil +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = (ROOT / "examples/kiss_modem/main.cpp").read_text(encoding="utf-8") +TRACE = "#if" + SOURCE.split("// Lab-only boot diagnostics", 1)[1].split( + "#if", 1 +)[1].split("#if MESH_ESP32_HWCDC_SESSION_GUARD", 1)[0] + + +class KissBootTraceTests(unittest.TestCase): + def test_actual_trace_preprocessor_is_uart_only_and_opt_in(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++ preprocessor is required") + cases = [ + (["ESP32=1", "MESH_HIL_KISS_BOOT_TRACE=1"], True), + (["ESP32=1", "MESH_HIL_KISS_BOOT_TRACE=1", "ARDUINO_USB_CDC_ON_BOOT=0"], True), + (["ESP32=1"], False), + (["ESP32=1", "MESH_HIL_KISS_BOOT_TRACE=0"], False), + (["NRF52_PLATFORM=1", "MESH_HIL_KISS_BOOT_TRACE=1"], False), + (["ESP32=1", "MESH_HIL_KISS_BOOT_TRACE=1", "ARDUINO_USB_MODE=0", + "ARDUINO_USB_CDC_ON_BOOT=1"], False), + (["ESP32=1", "MESH_HIL_KISS_BOOT_TRACE=1", "ARDUINO_USB_MODE=1", + "ARDUINO_USB_CDC_ON_BOOT=1"], False), + (["ESP32=1", "MESH_HIL_KISS_BOOT_TRACE=1", "KISS_UART_RX=5", + "KISS_UART_TX=6"], False), + ] + for defines, enabled in cases: + with self.subTest(defines=defines): + result = subprocess.run( + [compiler, "-E", "-P", "-x", "c++", *[f"-D{x}" for x in defines], "-"], + input=TRACE + '\nKISS_UART_BOOT_TRACE\nKISS_BOOT_STAGE("probe");\n', + capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip().splitlines()[0], "1" if enabled else "0") + self.assertEqual("Serial.println" in result.stdout, enabled) + + def test_fixed_stages_bracket_startup_before_binary_loop(self): + setup = SOURCE.split("void setup() {", 1)[1].split("void loop() {", 1)[0] + self.assertLess(setup.index("Serial.begin(115200)"), setup.index("board.begin()")) + for operation, before, after in [ + ("board.begin()", "board.before", "board.after"), + ("radio_driver.begin()", "driver.before", "driver.after"), + ("loadOrCreateIdentity()", "identity.before", "identity.after"), + ("sensors.begin()", "sensors.before", "sensors.after"), + ("modem->begin()", "modem.before", "modem.after"), + ]: + self.assertLess(setup.index(f'KISS_BOOT_STAGE("{before}")'), setup.index(operation)) + self.assertLess(setup.index(operation), setup.index(f'KISS_BOOT_STAGE("{after}")')) + self.assertNotIn("KISS_BOOT_STAGE", SOURCE.split("void loop() {", 1)[1]) + + def test_esp32_mount_precedes_radio_and_failed_mount_cannot_generate_key(self): + identity = SOURCE.split("void loadOrCreateIdentity() {", 1)[1].split( + "void onSetRadio(", 1 + )[0] + setup = SOURCE.split("void setup() {", 1)[1].split("void loop() {", 1)[0] + self.assertNotIn("SPIFFS.begin", identity) + self.assertEqual(SOURCE.count("SPIFFS.begin(true)"), 1) + self.assertLess(setup.index("board.begin()"), setup.index("SPIFFS.begin(true)")) + self.assertLess(setup.index("SPIFFS.begin(true)"), setup.index("while (!radio_init())")) + mount = setup.split("#if defined(ESP32)", 1)[1].split("#endif", 1)[0] + self.assertIn("const bool filesystem_ready = SPIFFS.begin(true);", mount) + failure = mount.split("if (!filesystem_ready) {", 1)[1] + self.assertIn("board.reboot();", failure) + self.assertIn("halt();", failure) + self.assertNotIn("loadOrCreateIdentity", failure) + + def test_identity_stages_never_export_identity_bytes(self): + for line in SOURCE.splitlines(): + if "KISS_BOOT_STAGE(" in line and not line.lstrip().startswith("#define"): + self.assertRegex(line.strip(), r'^KISS_BOOT_STAGE\("[a-z_.]+"\);$') + before = SOURCE.index('KISS_BOOT_STAGE("filesystem.before")') + after = SOURCE.index('KISS_BOOT_STAGE("filesystem.after")') + self.assertLess(before, SOURCE.index("SPIFFS.begin(true)")) + self.assertLess(SOURCE.index("SPIFFS.begin(true)"), after) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_kiss_ethernet_wire.py b/test/test_kiss_ethernet_wire.py new file mode 100644 index 000000000..0601f4226 --- /dev/null +++ b/test/test_kiss_ethernet_wire.py @@ -0,0 +1,112 @@ +"""Raw KISS wire-only support: no Companion tunnel, dispatcher slot or RF.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +class KissEthernetWireTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("a host C++17 compiler is required") + + def compile_run(self, code, *flags): + with tempfile.TemporaryDirectory(prefix="meshcore-kiss-wire-") as folder: + source, binary = Path(folder) / "test.cpp", Path(folder) / "test" + source.write_text(code, encoding="utf-8") + command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", *flags, + "-I", str(ROOT / "src"), str(source), "-o", str(binary)] + result = subprocess.run(command, capture_output=True, text=True, timeout=45) + self.assertEqual(result.returncode, 0, result.stderr[-12000:]) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_wire_only_does_not_enable_companion_tunnel_or_foreign_queue(self): + self.compile_run(r''' +#include +static_assert(MESH_ENABLE_ETHERNET_WIRE == 1, "raw wire must be enabled"); +static_assert(MESH_ENABLE_ETHERNET_TUNNEL == 0, "no Companion tunnel"); +static_assert(MESH_ENABLE_FOREIGN_RADIO == 0, "no foreign dispatcher slot"); +int main() {} +''', "-DMESH_ENABLE_ETHERNET_WIRE=1", "-DMESH_ENABLE_ETHERNET_TUNNEL=0", + "-DMESH_ENABLE_MESHTEXT_CHAT=0", "-DNRF52_PLATFORM=1") + + def test_actual_sx1262_and_sx1276_protocol_switching_with_wire_only(self): + for chip in ("SX1262", "SX1276"): + source = (ROOT / f"src/helpers/radiolib/Custom{chip}Wrapper.h").read_text() + protocol = method(source, "bool applyProtocol(").replace(" override", "") + code = r''' +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +struct Custom@CHIP@ { + unsigned writes=0;uint8_t sync=0;int result=0; + int setProfileProtocol(uint8_t value) { ++writes;sync=value;return result; } +}; +struct Driver { + Custom@CHIP@ chip;Custom@CHIP@* _radio=&chip;bool supported=true; + bool supportsMeshtext() const { return false; } + bool supportsEthernetTunnel() const { return supported; } + @METHOD@ +}; +int main() { + using P=mesh::RadioProfileProtocol;Driver d; + assert(d.applyProtocol(P::Ethernet) && d.chip.sync==0x16); + assert(d.applyProtocol(P::MeshCore) && d.chip.sync==0x12); + const auto before=d.chip.writes;d.supported=false; + assert(!d.applyProtocol(P::Ethernet) && d.chip.writes==before); + assert(!d.applyProtocol(P::Meshtext) && d.chip.writes==before); + assert(!d.applyProtocol(P(99)) && d.chip.writes==before); + d.chip.result=-1;assert(!d.applyProtocol(P::MeshCore)); +} +'''.replace("@CHIP@", chip).replace("@METHOD@", protocol) + with self.subTest(chip=chip): + self.compile_run(code, "-DMESH_ENABLE_ETHERNET_WIRE=1", + "-DMESH_ENABLE_ETHERNET_TUNNEL=0", "-DMESH_ENABLE_MESHTEXT_WIRE=0") + + def test_actual_validator_gates_wire_and_chip_capability(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + validator = method(source, "bool RadioLibWrapper::validateProfile(") + code = r''' +#include +#include +#include +struct RadioLibWrapper { + bool supported=true; + bool supportsMeshtext() const { return false; } + bool supportsEthernetTunnel() const { return supported; } + bool validateProfile(const mesh::RadioProfileParams&) const; +}; +@VALIDATOR@ +int main() { + using P=mesh::RadioProfileProtocol; + RadioLibWrapper d;mesh::RadioProfileParams p; + p.freq=909.5;p.bw=500;p.sf=5;p.cr=5;p.preamble=512;p.protocol=P::Ethernet; +#if MESH_RADIO_PROFILE_CHIP == 1262 + assert(d.validateProfile(p)==bool(MESH_ENABLE_ETHERNET_WIRE)); +#else + assert(!d.validateProfile(p)); // SX1276 cannot use SF5. + p.sf=7;assert(d.validateProfile(p)==bool(MESH_ENABLE_ETHERNET_WIRE)); +#endif + d.supported=false;assert(!d.validateProfile(p)); + p.protocol=P::MeshCore;assert(d.validateProfile(p)); +} +'''.replace("@VALIDATOR@", validator) + for wire in (0, 1): + for chip in (1262, 1276): + with self.subTest(wire=wire, chip=chip): + self.compile_run(code, f"-DMESH_ENABLE_ETHERNET_WIRE={wire}", + "-DMESH_ENABLE_ETHERNET_TUNNEL=0", f"-DMESH_RADIO_PROFILE_CHIP={chip}") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_kiss_modem/test_tx_backpressure.cpp b/test/test_kiss_modem/test_tx_backpressure.cpp index 00489133e..9097b08af 100644 --- a/test/test_kiss_modem/test_tx_backpressure.cpp +++ b/test/test_kiss_modem/test_tx_backpressure.cpp @@ -130,8 +130,10 @@ public: mesh::RadioProfiles* profiles() override { return &_profiles; } const mesh::RadioProfiles* profiles() const override { return &_profiles; } + bool supportsEthernetTunnel() const override { return _ethernet_supported; } bool validateProfile(const mesh::RadioProfileParams& params) const override { - return mesh::RadioProfiles::valid(params); + return mesh::RadioProfiles::valid(params) && _profile_supported + && (params.protocol != mesh::RadioProfileProtocol::Ethernet || _ethernet_supported); } uint8_t receiveProfile() const override { return _active_profile; } mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override { @@ -171,6 +173,8 @@ public: void setSendComplete(bool complete) { _send_complete = complete; } void setPrepareResult(mesh::RadioParamApplyResult result) { _prepare_result = result; } void setReceiveProfile(uint8_t profile) { _active_profile = profile; } + void setEthernetSupported(bool enabled) { _ethernet_supported = enabled; } + void setProfileSupported(bool enabled) { _profile_supported = enabled; } int startSendCount() const { return _start_send_count; } int sendFinishedCount() const { return _send_finished_count; } uint8_t lastSentProfile() const { return _sent_profiles.empty() ? 0xff : _sent_profiles.back(); } @@ -185,6 +189,8 @@ private: uint8_t _active_profile = 0; bool _start_send_result = true; bool _send_complete = true; + bool _ethernet_supported = true; + bool _profile_supported = true; int _start_send_count = 0; int _send_finished_count = 0; mesh::RadioParamApplyResult _prepare_result = mesh::RadioParamApplyResult::APPLIED; @@ -555,6 +561,151 @@ TEST_F(KissModemFixture, TempRadio2RestoresSavedProfileAndLeavesSavedUpdatesDefe 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, KISS_FEND})); } +TEST_F(KissModemFixture, ExtendedRadio2RoundTripKeepsLegacyCommandsExactlyThirteenBytes) { + auto payload = radio2Payload(909500000, 500000, 5, 5, + (uint8_t)mesh::RadioProfileMode::RxTx, 512); + const auto legacy = payload; + payload.push_back((uint8_t)mesh::RadioProfileProtocol::Ethernet); + sendHardware(HW_CMD_SET_RADIO2_EXT, payload); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_OK)); + EXPECT_EQ(radio.profileConfig().secondary.params.protocol, mesh::RadioProfileProtocol::Ethernet); + EXPECT_EQ(radio.profileConfig().secondary.params.preamble, 512); + + serial.clearWrites(); + sendHardware(HW_CMD_GET_RADIO2_EXT); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP(HW_CMD_GET_RADIO2_EXT), payload)); + serial.clearWrites(); + sendHardware(HW_CMD_GET_RADIO2); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP(HW_CMD_GET_RADIO2), legacy)); + + serial.clearWrites(); + sendHardware(HW_CMD_SET_RADIO2, legacy); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_OK)); + EXPECT_EQ(radio.profileConfig().secondary.params.protocol, mesh::RadioProfileProtocol::MeshCore); + serial.clearWrites(); + sendHardware(HW_CMD_GET_RADIO2_EXT); + payload.back() = (uint8_t)mesh::RadioProfileProtocol::MeshCore; + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP(HW_CMD_GET_RADIO2_EXT), payload)); +} + +TEST_F(KissModemFixture, LabTransmitGuardIsReadOnlyAndExistsOnlyInExplicitHilBuilds) { + sendHardware(HW_CMD_GET_LAB_TX_GUARD); +#if defined(MESH_HIL_TX_FREQ_MHZ) + static_assert(MESH_HIL_TX_FREQ_MHZ == 909.5, "This test fixture requires the standard lab lock"); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP(HW_CMD_GET_LAB_TX_GUARD), + {0x60, 0xDE, 0x35, 0x36})); // 909500000 Hz, little endian. + serial.clearWrites(); + sendHardware(HW_CMD_GET_LAB_TX_GUARD, {0}); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_ERROR, {HW_ERR_INVALID_LENGTH})); +#else + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_ERROR, {HW_ERR_UNKNOWN_CMD})); +#endif + EXPECT_EQ(radio.startSendCount(), 0); +} + +TEST_F(KissModemFixture, ExtendedRadio2RejectsInvalidLengthsProtocolsAndUnsupportedChipWithoutMutation) { + auto payload = radio2Payload(909500000, 500000, 5, 5, + (uint8_t)mesh::RadioProfileMode::RxTx, 512); + payload.push_back((uint8_t)mesh::RadioProfileProtocol::Ethernet); + sendHardware(HW_CMD_SET_RADIO2_EXT, payload); + const auto generation = radio.profileConfig().generation[1]; + const auto original = radio.profileConfig().secondary.params; + for (size_t length : {size_t(0), size_t(13), size_t(15)}) { + auto changed = payload; changed.resize(length); + serial.clearWrites(); + sendHardware(HW_CMD_SET_RADIO2_EXT, changed); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_ERROR, {HW_ERR_INVALID_LENGTH})); + EXPECT_EQ(radio.profileConfig().generation[1], generation); + } + for (uint8_t protocol : {uint8_t(1), uint8_t(3), uint8_t(255)}) { + auto changed = payload; changed.back() = protocol; + serial.clearWrites(); + sendHardware(HW_CMD_SET_RADIO2_EXT, changed); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_ERROR, {HW_ERR_INVALID_PARAM})); + EXPECT_EQ(radio.profileConfig().generation[1], generation); + } + radio.setEthernetSupported(false); + for (uint8_t mode : {uint8_t(0), uint8_t(2)}) { + auto changed = payload; changed[10] = mode; + serial.clearWrites(); + sendHardware(HW_CMD_SET_RADIO2_EXT, changed); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_ERROR, {HW_ERR_INVALID_PARAM})); + EXPECT_EQ(radio.profileConfig().generation[1], generation); + } + radio.setEthernetSupported(true); + radio.setProfileSupported(false); // e.g. modem cannot use the requested SF/BW. + serial.clearWrites(); + sendHardware(HW_CMD_SET_RADIO2_EXT, payload); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_ERROR, {HW_ERR_INVALID_PARAM})); + EXPECT_EQ(radio.profileConfig().secondary.params, original); + EXPECT_EQ(radio.profileConfig().generation[1], generation); + + serial.clearWrites(); + sendHardware(HW_CMD_GET_RADIO2_EXT, {0}); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_ERROR, {HW_ERR_INVALID_LENGTH})); +} + +TEST_F(KissModemFixture, ExtendedSavedProtocolDoesNotInterruptTemporaryMeshCoreLease) { + auto temporary = radio2Payload(868000000, 125000, 7, 5, + (uint8_t)mesh::RadioProfileMode::RxTx, 32); + appendU16(temporary, 1); + sendHardware(HW_CMD_SET_TEMPRADIO2, temporary); + ASSERT_TRUE(radio.profileConfig().secondary_temporary); + auto saved = radio2Payload(909500000, 500000, 5, 5, + (uint8_t)mesh::RadioProfileMode::RxTx, 512); + saved.push_back((uint8_t)mesh::RadioProfileProtocol::Ethernet); + const auto temporary_generation = radio.profileConfig().generation[1]; + serial.clearWrites(); + sendHardware(HW_CMD_SET_RADIO2_EXT, saved); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_OK)); + EXPECT_EQ(radio.profileConfig().secondary.params.protocol, mesh::RadioProfileProtocol::MeshCore); + EXPECT_EQ(radio.profileConfig().generation[1], temporary_generation); + serial.clearWrites(); + sendHardware(HW_CMD_GET_RADIO2_EXT); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP(HW_CMD_GET_RADIO2_EXT), saved)); + delay(60000); + modem.loop(); + EXPECT_FALSE(radio.profileConfig().secondary_temporary); + EXPECT_EQ(radio.profileConfig().secondary.params.protocol, mesh::RadioProfileProtocol::Ethernet); + EXPECT_FLOAT_EQ(radio.profileConfig().secondary.params.freq, 909.5f); +} + +TEST_F(KissModemFixture, TemporaryCancelRestoresSavedEthernetProtocol) { + auto saved = radio2Payload(909500000, 500000, 5, 5, + (uint8_t)mesh::RadioProfileMode::RxTx, 512); + saved.push_back((uint8_t)mesh::RadioProfileProtocol::Ethernet); + sendHardware(HW_CMD_SET_RADIO2_EXT, saved); + auto temporary = radio2Payload(868000000, 125000, 7, 5, + (uint8_t)mesh::RadioProfileMode::RxTx, 32); + appendU16(temporary, 1); + sendHardware(HW_CMD_SET_TEMPRADIO2, temporary); + EXPECT_EQ(radio.profileConfig().secondary.params.protocol, mesh::RadioProfileProtocol::MeshCore); + serial.clearWrites(); + sendHardware(HW_CMD_SET_TEMPRADIO2, std::vector(KISS_TEMPRADIO2_PARAMS_SIZE, 0)); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_OK)); + EXPECT_FALSE(radio.profileConfig().secondary_temporary); + EXPECT_EQ(radio.profileConfig().secondary.params.protocol, mesh::RadioProfileProtocol::Ethernet); +} + +TEST_F(KissModemFixture, ProtocolOnlyChangeInvalidatesQueuedSecondaryTransmitWithoutPrimaryFallback) { + auto payload = radio2Payload(909500000, 500000, 5, 5, + (uint8_t)mesh::RadioProfileMode::RxTx, 512); + sendHardware(HW_CMD_SET_RADIO2, payload); + serial.clearWrites(); + serial.pushRx(dataFrame({0x42}, 1)); + modem.loop(); // Bind the pending port-one packet before the protocol changes. + const auto generation = radio.profileConfig().generation[1]; + payload.push_back((uint8_t)mesh::RadioProfileProtocol::Ethernet); + sendHardware(HW_CMD_SET_RADIO2_EXT, payload); + EXPECT_NE(radio.profileConfig().generation[1], generation); + serial.clearWrites(); + delay(KISS_DEFAULT_TXDELAY * 10); + modem.loop(); + modem.loop(); + EXPECT_EQ(radio.startSendCount(), 0); + EXPECT_EQ(serial.writesSnapshot(), hardwareFrame(HW_RESP_TX_DONE, {0})); +} + TEST_F(KissModemFixture, SecondaryReceiveUsesPortOneWithoutChangingPacketOrRxMeta) { const std::vector radio2 = radio2Payload(910500000, 62500, 7, 5, (uint8_t)mesh::RadioProfileMode::RxTx, 0); diff --git a/test/test_mota_kiss_direct.py b/test/test_mota_kiss_direct.py new file mode 100644 index 000000000..3bbea8628 --- /dev/null +++ b/test/test_mota_kiss_direct.py @@ -0,0 +1,396 @@ +"""Host-only direct KISS OTA source tests; no serial device is opened.""" +from collections import deque +from pathlib import Path +import struct +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch +import zlib + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools/lora_ota")) +import mota_kiss_direct as ota + + +def container(payload, block_size=2048): + ml = ota.motalib + leaves = ml.leaf_hashes(payload, block_size) + manifest = ml.Manifest(flags=ml.FLAG_FULL, target_id=123, fw_version=0x01170000, + image_size=len(payload), payload_size=len(payload), block_size_log2=block_size.bit_length() - 1, + merkle_root=ml.merkle_root(leaves), image_hash=ml.mh32(payload), leaves=leaves) + return ml.build_container(manifest, payload) + + +def request(source, *items, wide=True): + message = bytes((ota.REQ,)) + source.mid + b"".join(struct.pack("> 11, number) + self.assertEqual((packed & 2047) + 1, 2048) + self.assertEqual(message[9:13], ota.motalib.mh4(source.block(0))) + self.assertLessEqual(len(message[13:]), size) + reconstructed.extend(message[13:]) + self.assertEqual(reconstructed, source.block(0)) + self.assertEqual(max(map(len, replies)), 255 if wide else 186) + + def test_1k_tail_descriptor_and_legacy160_fallback(self): + source = self.source(bytes(range(256)) * 5, block_size=1024) + self.assertEqual(len(source.replies(request(source, (0, 0x9FFF)))), 6) # 5DATA+PROOF + legacy = source.replies(request(source, (0, 0xFFFF), wide=False)) + data = [ota.decode_radio(r).message for r in legacy if ota.decode_radio(r).message[0] == ota.DATA] + self.assertEqual(len(data), 7) + self.assertEqual([struct.unpack_from("= 2 + 32 + 16" - ) - next_command_at = handler.index( - "cmd_frame[0] == CMD_SIGN_START", channel_at - ) - channel_update = handler[channel_at:next_command_at] + # Both wire encodings must snapshot the raw persisted representation. + # getChannel() resolves aliases into effective wire keys/TX routing; + # using it for rollback would corrupt a prefixed channel after failure. + # Exact-byte rollback is also executed by the native channel-alias and + # Meshtext command fixtures, not merely checked as source ordering. + for key_bytes in (16, 32): + with self.subTest(key_bytes=key_bytes): + channel_update = function_body(mesh, + f"if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + {key_bytes})") + snapshot_at = channel_update.index( + "getChannelForSave(channel_idx, previous)" + ) + self.assertNotIn("getChannel(channel_idx, previous)", channel_update) + mutate_at = channel_update.index( + "setChannel(channel_idx, channel)" + ) + save_at = channel_update.index("saveChannels()") + rollback_at = channel_update.index( + "setChannel(channel_idx, previous)", save_at + ) + error_at = channel_update.index( + "writeErrFrame(ERR_CODE_FILE_IO_ERROR)", rollback_at + ) + ok_at = channel_update.index("writeOKFrame()") - snapshot_at = channel_update.index( - "getChannel(channel_idx, previous)" - ) - mutate_at = channel_update.index( - "setChannel(channel_idx, channel)" - ) - save_at = channel_update.index("saveChannels()") - rollback_at = channel_update.index( - "setChannel(channel_idx, previous)", save_at - ) - error_at = channel_update.index( - "writeErrFrame(ERR_CODE_FILE_IO_ERROR)", rollback_at - ) - ok_at = channel_update.index("writeOKFrame()") - - self.assertLess(snapshot_at, mutate_at) - self.assertLess(mutate_at, save_at) - self.assertLess(save_at, ok_at) - self.assertLess(save_at, rollback_at) - self.assertLess(rollback_at, error_at) + self.assertLess(snapshot_at, mutate_at) + self.assertLess(mutate_at, save_at) + self.assertLess(save_at, ok_at) + self.assertLess(save_at, rollback_at) + self.assertLess(rollback_at, error_at) + self.assertEqual(channel_update.count("writeOKFrame()"), 1) def test_absent_advert_delete_avoids_bucket_rewrite_without_legacy_cache(self): store = STORE.read_text(encoding="utf-8") diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 47dd259ee..390a4b657 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -5768,6 +5768,8 @@ TEST(OtaSpeed, MissingProofFallbackStaysArmedAtMillisRollover) { EXPECT_EQ(ota_msg_type(sent.items.back().data(), sent.items.back().size()), OTA_REQ_PROOF); } +#include "test_ota_direct_manager.inc" + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_ota/test_ota_direct_manager.inc b/test/test_ota/test_ota_direct_manager.inc new file mode 100644 index 000000000..3c48e80e4 --- /dev/null +++ b/test/test_ota/test_ota_direct_manager.inc @@ -0,0 +1,467 @@ +// Included after the shared OTA vectors/codecs in test_ota_core.cpp. +namespace { +struct DirectManagerBus { + struct Frame { OtaManager* destination; std::vector bytes; OtaReplyRoute route; }; + struct End { DirectManagerBus* bus; OtaManager* sender; OtaManager* destination; }; + OtaManager server, client; + OtaStoreRam<16384> store; + End from_server{this, &server, &client}, from_client{this, &client, &server}; + std::vector queue; + std::vector> direct_requests; + std::vector requests; + std::vector request_times; + std::vector data; + bool downgrade_probe = false; + bool drop_one = false; + bool dropped = false; + bool lose_direct_after_first_block = false; + unsigned drop_direct_requests_after_first_block = 0; + unsigned reject_direct_requests_after_first_block = 0; + unsigned dropped_direct_requests = 0; + unsigned rejected_direct_requests = 0; + unsigned legacy_requests = 0; + unsigned direct_proofs = 0; + uint32_t now = 0; + + static bool send(void* context, const uint8_t* bytes, uint16_t length, bool) { + End& end = *static_cast(context); + DirectManagerBus& bus = *end.bus; + OtaReplyRoute route = end.sender->replyRoute(); + const bool direct = route.direct; + EXPECT_LE(length, direct ? OTA_DIRECT_MESSAGE_MAX : MAX_PACKET_PAYLOAD); + route.profile = 1; + route.generation = 19; + if (bytes[0] == OTA_REQ) { + if (direct && bus.client.blocksHave() != 0 && + bus.reject_direct_requests_after_first_block != 0) { + --bus.reject_direct_requests_after_first_block; + ++bus.rejected_direct_requests; + return false; + } + if (direct) bus.direct_requests.emplace_back(bytes, bytes + length); + else ++bus.legacy_requests; + bus.requests.push_back({end.destination, {bytes, bytes + length}, route}); + bus.request_times.push_back(bus.now); + if (direct && bus.client.blocksHave() != 0 && + (bus.lose_direct_after_first_block || bus.drop_direct_requests_after_first_block)) { + if (bus.drop_direct_requests_after_first_block) --bus.drop_direct_requests_after_first_block; + ++bus.dropped_direct_requests; + return true; + } + if (direct && bus.downgrade_probe) route.direct = false; + } + if (direct && bus.lose_direct_after_first_block && bus.client.blocksHave() != 0) { + return true; + } + Frame frame{end.destination, {bytes, bytes + length}, route}; + if (bytes[0] == OTA_DATA) { + bus.data.push_back(frame); + if (bus.drop_one && direct && !bus.dropped) { + bus.dropped = true; + return true; + } + } + if (bytes[0] == OTA_PROOF && direct) ++bus.direct_proofs; + bus.queue.push_back(std::move(frame)); + return true; + } + + void begin(const uint8_t* mota, uint32_t length, bool direct_client = true, + bool direct_server = true, uint8_t hops = 0) { + server.begin(0, send, &from_server); + client.begin(SIM_TARGET_ID, send, &from_client); + server.set_direct_transport_enabled(direct_server); + client.set_direct_transport_enabled(direct_client); + client.set_max_hops(hops); + client.set_fetch_store(&store); + server.set_clock(now); client.set_clock(now); + ASSERT_TRUE(server.serve(mota, length)); + } + + void pull(const MotaManifest& manifest) { + ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED); + } + + void deliver() { + unsigned bounded = 1000; + while (!queue.empty() && bounded--) { + Frame frame = std::move(queue.front()); + queue.erase(queue.begin()); + frame.destination->note_rx_path_hops(0); + frame.destination->on_message(frame.bytes.data(), frame.bytes.size(), frame.route); + } + ASSERT_GT(bounded, 0u); + } + + void tick() { + server.set_clock(now); client.set_clock(now); + server.serviceEgress(); client.serviceEgress(); + deliver(); + server.loop(); client.loop(); + deliver(); + now += 10; + } + + void finish(const uint8_t* expected, uint32_t length) { + for (unsigned i = 0; i < 30000 && client.fetchState() != OtaManager::COMPLETE && + client.fetchState() != OtaManager::FAILED; ++i) tick(); + ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE); + ASSERT_EQ(store.staged_size(), length); + EXPECT_EQ(0, memcmp(store.data(), expected, length)); + EXPECT_EQ(client.blocksHave(), client.blocksTotal()); + } +}; +} + +TEST(OtaDirectManager, RoutePaddingAndLegacyPayloadCapacityAreUnchanged) { + EXPECT_EQ(sizeof(OtaReplyRoute), 8u); + EXPECT_EQ(MAX_PACKET_PAYLOAD, 184); + EXPECT_EQ(OTA_MAX_BLOCK, 2048); + EXPECT_EQ(OTA_DIRECT_FRAG_DATA, 240); + OtaReplyRoute normal; + OtaReplyRoute direct = normal; direct.direct = true; + EXPECT_FALSE(normal == direct); +} + +TEST(OtaDirectManager, RawOneAndTwoKiBBlocksUseFullDirectMtu) { + for (const auto& vector : {std::make_pair(SIM_MOTA_1K, SIM_MOTA_1K_LEN), + std::make_pair(SIM_MOTA_2K, SIM_MOTA_2K_LEN)}) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(vector.first, vector.second, manifest)); + bus.begin(vector.first, vector.second); + bus.pull(manifest); + bus.finish(vector.first, vector.second); + EXPECT_TRUE(bus.client.directFetchConfirmed()); + EXPECT_FALSE(bus.direct_requests.empty()); + EXPECT_EQ(bus.direct_proofs, manifest.block_count); + bool full = false; + for (const auto& frame : bus.data) { + EXPECT_TRUE(frame.route.direct); + if (frame.bytes.size() == OTA_DIRECT_MESSAGE_MAX) full = true; + } + EXPECT_TRUE(full); + } +} + +TEST(OtaDirectManager, CompressionAndRawTailRetainMerkleIntegrity) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + bus.begin(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + VectorDeflateCodec codec{&manifest}; + bus.server.set_transport_deflate_encoder(vector_2k_wire_encode, &codec); + bus.client.set_transport_deflate_decoder(ota_transport_inflate); + bus.pull(manifest); + bus.finish(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + EXPECT_GT(codec.calls, 0u); + EXPECT_TRUE(bus.client.directFetchConfirmed()); +} + +TEST(OtaDirectManager, CompressedOneKiBRetainsDirectIdentityAndProofs) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + bus.begin(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + TestWireCodec codec{&manifest}; + bus.server.set_transport_deflate_encoder(test_wire_encode, &codec); + bus.client.set_transport_deflate_decoder(test_wire_decode, &codec); + bus.pull(manifest); + bus.finish(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + EXPECT_TRUE(bus.client.directFetchConfirmed()); + EXPECT_GT(codec.calls, 0u); +} + +TEST(OtaDirectManager, OldSourceDropsProbeThenUses171ByteFallback) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + bus.begin(SIM_MOTA_2K, SIM_MOTA_2K_LEN, true, false); + bus.pull(manifest); + bus.finish(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + EXPECT_EQ(bus.direct_requests.size(), 1u); + EXPECT_GT(bus.legacy_requests, 0u); + EXPECT_FALSE(bus.client.directFetchActive()); + EXPECT_EQ(bus.client.directFetchRoute().profile, 1); + EXPECT_EQ(bus.client.directFetchRoute().generation, 19u); + for (const auto& frame : bus.data) { + EXPECT_FALSE(frame.route.direct); + EXPECT_LE(frame.bytes.size(), 184u); + } +} + +TEST(OtaDirectManager, SourceVanishingAfterCommittedBlockFallsBackWithoutRewritingIt) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + bus.begin(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + bus.lose_direct_after_first_block = true; + bus.pull(manifest); + for (unsigned i = 0; i < 1000 && bus.client.blocksHave() == 0; ++i) bus.tick(); + ASSERT_EQ(bus.client.blocksHave(), 1u); + const uint32_t epoch = bus.client.directTransportEpoch(); + bus.finish(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + EXPECT_FALSE(bus.client.directFetchActive()); + EXPECT_NE(bus.client.directTransportEpoch(), epoch); + ASSERT_EQ(bus.direct_requests.size(), 3u); // first block, next flight, one same-mode sparse retry + EXPECT_EQ(bus.dropped_direct_requests, 2u); + ASSERT_GE(bus.requests.size(), 4u); + EXPECT_GE(uint32_t(bus.request_times[2] - bus.request_times[1]), OTA_FETCH_RETRY_FALLBACK_MS); + EXPECT_GE(uint32_t(bus.request_times[3] - bus.request_times[2]), OTA_FETCH_RETRY_FALLBACK_MS); + for (size_t i = 1; i < bus.requests.size(); ++i) { + ReqWindowMsg request{}; + ASSERT_TRUE(decode_req_window(bus.requests[i].bytes.data(), bus.requests[i].bytes.size(), request)); + for (uint8_t item = 0; item < request.n_items; ++item) EXPECT_NE(request.items[item].block_idx, 0u); + } + bool saw_normal = false; + for (const auto& frame : bus.data) { + if (!frame.route.direct) { + saw_normal = true; + DataMsg message{}; + ASSERT_TRUE(decode_data(frame.bytes.data(), frame.bytes.size(), message)); + EXPECT_NE(message.block_idx, 0u); + } + } + EXPECT_TRUE(saw_normal); +} + +TEST(OtaDirectManager, ConfirmedEmptyFlightsRetryWideAndResetGraceAfterProgress) { + for (uint32_t started : {0u, UINT32_MAX - 2000u}) { + DirectManagerBus bus; + bus.now = started; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + ASSERT_EQ(manifest.block_count, 3u); + bus.begin(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + bus.drop_direct_requests_after_first_block = 1; + bus.pull(manifest); + bus.finish(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + EXPECT_TRUE(bus.client.directFetchConfirmed()); + EXPECT_EQ(bus.legacy_requests, 0u); + EXPECT_EQ(bus.dropped_direct_requests, 1u); + ASSERT_EQ(bus.direct_requests.size(), 4u); + // The lost two-block flight is repaired one slot per deadline. Progress on + // block 1 must renew the grace for still-empty block 2, not downgrade it. + const uint8_t expected_items[] = {1, 2, 1, 1}; + const uint16_t expected_first[] = {0, 1, 1, 2}; + for (size_t i = 0; i < bus.direct_requests.size(); ++i) { + ReqWindowMsg request{}; + const auto& bytes = bus.direct_requests[i]; + ASSERT_TRUE(decode_req_window(bytes.data(), bytes.size(), request)); + EXPECT_EQ(request.n_items, expected_items[i]); + EXPECT_EQ(request.items[0].block_idx, expected_first[i]); + } + for (const auto& frame : bus.data) EXPECT_TRUE(frame.route.direct); + EXPECT_EQ(bus.client.directFetchRoute().profile, 1); + EXPECT_EQ(bus.client.directFetchRoute().generation, 19u); + } +} + +TEST(OtaDirectManager, ConfirmedEmptyRetryAdmissionDoesNotConsumeGrace) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + bus.begin(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + bus.lose_direct_after_first_block = true; + bus.pull(manifest); + for (unsigned i = 0; i < 1000 && bus.client.blocksHave() == 0; ++i) bus.tick(); + ASSERT_EQ(bus.client.blocksHave(), 1u); + ASSERT_TRUE(bus.client.directFetchConfirmed()); + ASSERT_EQ(bus.direct_requests.size(), 2u); + const uint32_t epoch = bus.client.directTransportEpoch(); + bus.reject_direct_requests_after_first_block = 3; + bus.now += bus.client.fetchRetryTimeoutMs(); + for (unsigned i = 1; i <= 3; ++i) { + bus.tick(); + EXPECT_EQ(bus.rejected_direct_requests, i); + EXPECT_EQ(bus.direct_requests.size(), 2u); + EXPECT_EQ(bus.legacy_requests, 0u); + EXPECT_TRUE(bus.client.directFetchConfirmed()); + EXPECT_EQ(bus.client.directTransportEpoch(), epoch); + } + bus.tick(); // one accepted wide retry; its RF loss must use a fresh deadline + ASSERT_EQ(bus.direct_requests.size(), 3u); + bus.tick(); + EXPECT_EQ(bus.direct_requests.size(), 3u); + EXPECT_EQ(bus.legacy_requests, 0u); + EXPECT_TRUE(bus.client.directFetchConfirmed()); + bus.finish(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + EXPECT_FALSE(bus.client.directFetchActive()); + EXPECT_EQ(bus.direct_requests.size(), 3u); + EXPECT_NE(bus.client.directTransportEpoch(), epoch); +} + +TEST(OtaDirectManager, ConfirmedEmptyRetryCannotOutliveItsRadioSession) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + bus.begin(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + bus.lose_direct_after_first_block = true; + bus.pull(manifest); + for (unsigned i = 0; i < 1000 && bus.client.blocksHave() == 0; ++i) bus.tick(); + ASSERT_EQ(bus.client.blocksHave(), 1u); + bus.now += bus.client.fetchRetryTimeoutMs(); + bus.tick(); + ASSERT_EQ(bus.direct_requests.size(), 3u); + ASSERT_TRUE(bus.client.directFetchConfirmed()); + const uint32_t epoch = bus.client.directTransportEpoch(); + bus.client.serviceEgress([](void*, const OtaReplyRoute&) { return false; }, nullptr); + EXPECT_EQ(bus.client.fetchState(), OtaManager::FAILED); + EXPECT_EQ(bus.client.fetchError(), OtaManager::FETCH_ERROR_RADIO_SESSION); + EXPECT_NE(bus.client.directTransportEpoch(), epoch); + bus.now += OTA_FETCH_RETRY_FALLBACK_MS; + bus.tick(); + EXPECT_EQ(bus.direct_requests.size(), 3u); + EXPECT_EQ(bus.legacy_requests, 0u); +} + +TEST(OtaDirectManager, CanonicalNormalAnswerBeforeConfirmationDowngradesWholeFlight) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + bus.begin(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + bus.downgrade_probe = true; + bus.pull(manifest); + bus.finish(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + EXPECT_EQ(bus.direct_requests.size(), 1u); + EXPECT_FALSE(bus.client.directFetchActive()); +} + +TEST(OtaDirectManager, FeatureOffAndRoutedSettingsNeverProbe) { + for (unsigned mode = 0; mode < 2; ++mode) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + bus.begin(SIM_MOTA_1K, SIM_MOTA_1K_LEN, mode != 0, true, mode ? 1 : 0); + bus.pull(manifest); + bus.finish(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + EXPECT_TRUE(bus.direct_requests.empty()); + } +} + +TEST(OtaDirectManager, LostDirectFragmentRecoversOnlyHolesAndWorksAcrossRollover) { + DirectManagerBus bus; + bus.now = UINT32_MAX - 200; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + bus.begin(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + bus.drop_one = true; + bus.pull(manifest); + bus.finish(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + ASSERT_GT(bus.direct_requests.size(), 1u); + bool recovered_one = false; + for (const auto& bytes : bus.direct_requests) { + ReqWindowMsg request{}; + ASSERT_TRUE(decode_req_window(bytes.data(), bytes.size(), request)); + if (request.n_items == 1 && ota_req_v2_fragments(request.items[0].want_mask) == 1u) + recovered_one = true; + } + EXPECT_TRUE(recovered_one); + EXPECT_TRUE(bus.client.directFetchConfirmed()); +} + +TEST(OtaDirectManager, ConfirmedDirectRejectsNormalMetadataAndExpiredSession) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + bus.begin(SIM_MOTA_2K, SIM_MOTA_2K_LEN); + bus.pull(manifest); + for (unsigned i = 0; i < 100 && bus.data.empty(); ++i) bus.tick(); + ASSERT_FALSE(bus.data.empty()); + ASSERT_TRUE(bus.client.directFetchConfirmed()); + const auto& frame = bus.data.front(); + OtaReplyRoute normal = frame.route; normal.direct = false; + EXPECT_FALSE(bus.client.on_message(frame.bytes.data(), frame.bytes.size(), normal)); + OtaReplyRoute stale = frame.route; ++stale.generation; + EXPECT_FALSE(bus.client.on_message(frame.bytes.data(), frame.bytes.size(), stale)); + EXPECT_FALSE(bus.client.on_message(frame.bytes.data(), frame.bytes.size() - 1, frame.route)); + auto oversized = frame.bytes; oversized.push_back(0); + EXPECT_FALSE(bus.client.on_message(oversized.data(), oversized.size(), frame.route)); + const uint32_t epoch = bus.client.directTransportEpoch(); + bus.client.serviceEgress([](void*, const OtaReplyRoute&) { return false; }, nullptr); + EXPECT_EQ(bus.client.fetchState(), OtaManager::FAILED); + EXPECT_EQ(bus.client.fetchError(), OtaManager::FETCH_ERROR_RADIO_SESSION); + EXPECT_NE(bus.client.directTransportEpoch(), epoch); +} + +TEST(OtaDirectManager, EpochRetiresClearResetRecreatedContextsAndNeverUsesZero) { + uint32_t previous = 0; + { + OtaManager manager; + previous = manager.directTransportEpoch(); + EXPECT_NE(previous, 0u); + manager.clearPendingEgress(); + EXPECT_NE(manager.directTransportEpoch(), previous); + previous = manager.directTransportEpoch(); + manager.reset_session(); + EXPECT_NE(manager.directTransportEpoch(), previous); + previous = manager.directTransportEpoch(); + } + OtaManager replacement; + EXPECT_NE(replacement.directTransportEpoch(), previous); + EXPECT_EQ(ota_advance_direct_epoch(UINT32_MAX), 1u); + EXPECT_EQ(ota_advance_direct_epoch(0), 1u); +} + +TEST(OtaDirectManager, CompletionAndDirectPauseRetireAdmittedRawFrames) { + for (bool pause : {false, true}) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + bus.begin(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + bus.pull(manifest); + for (unsigned i = 0; i < 100 && !bus.client.directFetchConfirmed(); ++i) bus.tick(); + ASSERT_TRUE(bus.client.directFetchConfirmed()); + const uint32_t epoch = bus.client.directTransportEpoch(); + if (pause) { + ASSERT_TRUE(bus.client.pauseFetchForDisconnect()); + EXPECT_EQ(bus.client.fetchState(), OtaManager::PAUSED); + } else { + bus.finish(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + } + EXPECT_NE(bus.client.directTransportEpoch(), epoch); + } +} + +namespace { +class PrecompressedSource : public RamMotaSource { +public: + explicit PrecompressedSource(const MotaManifest& manifest) : m(manifest) { + add(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + setSourceCaps(MOTA_SOURCE_CAP_DEFLATE_BLOCK); + } + MotaManifest m; + unsigned raw_reads = 0, encoded_reads = 0; + int bad = 0; + bool read(uint8_t idx, uint32_t off, uint8_t* out, uint32_t len) override { + if (off >= uint32_t(m.payload - SIM_MOTA_1K) && len) ++raw_reads; + return RamMotaSource::read(idx, off, out, len); + } + bool read_deflated_block(uint8_t, uint16_t block, uint8_t* out, + uint16_t cap, uint16_t* length) override { + ++encoded_reads; + if (bad) { *length = bad == 1 ? 0 : (bad == 3 ? cap + 1 : cap); return bad != 4; } + out[0] = 0xD1; out[1] = uint8_t(block); *length = 2; + return true; + } +}; +} + +TEST(OtaDirectManager, HostPrecompressedFirstAvoidsRedundantRawHostReads) { + for (int bad : {0, 1, 2, 3, 4}) { + DirectManagerBus bus; + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + bus.begin(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + bus.server.clear_primary(); + PrecompressedSource source(manifest); source.bad = bad; + ASSERT_TRUE(bus.server.add_source(&source)); + TestWireCodec codec{&manifest}; + bus.client.set_transport_deflate_decoder(test_wire_decode, &codec); + bus.pull(manifest); + bus.finish(SIM_MOTA_1K, SIM_MOTA_1K_LEN); + EXPECT_GT(source.encoded_reads, 0u); + if (bad) EXPECT_GT(source.raw_reads, 0u); + else EXPECT_EQ(source.raw_reads, 0u); + } +} diff --git a/test/test_ota_cli_listing.py b/test/test_ota_cli_listing.py index 1165dc200..0f78d4bdc 100644 --- a/test/test_ota_cli_listing.py +++ b/test/test_ota_cli_listing.py @@ -1,4 +1,4 @@ -"""Exercise the actual outlined OTA dispatch/listing with the real name decoder.""" +"""Exercise real OTA listing, direct-transfer status, and the name decoder.""" import os import re import shutil diff --git a/test/test_ota_direct_manager.py b/test/test_ota_direct_manager.py new file mode 100644 index 000000000..f4e0f83c2 --- /dev/null +++ b/test/test_ota_direct_manager.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Run the actual complete OTA core suite without invoking PlatformIO.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class OtaDirectManagerTests(unittest.TestCase): + def test_actual_ota_core_with_direct_interop_and_sanitizers(self): + googletest = ROOT / ".pio/libdeps/native/googletest/googletest" + self.assertTrue(googletest.is_dir()) + with tempfile.TemporaryDirectory(prefix="ota-direct-core-") as directory: + directory = Path(directory) + objects = [] + flags = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-g", "-O1"] + for source in ("OtaTinf.c", "detools/detools.c"): + obj = directory / (Path(source).name + ".o") + built = subprocess.run([ + "gcc", "-DOTA_TRANSPORT_DEFLATE_TEST=1", *flags, + "-c", str(ROOT / "src/helpers/ota" / source), "-o", str(obj)], + capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + objects.append(str(obj)) + sources = ("OtaManager.cpp", "OtaProtocol.cpp", "MotaContainer.cpp", + "MerkleTree.cpp", "FirmwareInfo.cpp", "OtaDeflate.cpp", + "OtaApply.cpp", "MotaSourceSerial.cpp", "FolderMotaStore.cpp") + binary = directory / "ota-core" + built = subprocess.run([ + "g++", "-std=c++17", "-pthread", *flags, + "-DOTA_TRANSPORT_DEFLATE_TEST=1", "-DOTA_FETCH_PIPELINE=4", + "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), + "-I", str(googletest / "include"), "-I", str(googletest), + str(ROOT / "test/test_ota/test_ota_core.cpp"), + *[str(ROOT / "src/helpers/ota" / source) for source in sources], + str(ROOT / "src/Utils.cpp"), str(googletest / "src/gtest-all.cc"), + *objects, "-o", str(binary)], capture_output=True, text=True, timeout=180) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary), "--gtest_brief=1"], + capture_output=True, text=True, timeout=90) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + print(checked.stdout.strip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_kiss_direct.py b/test/test_ota_kiss_direct.py new file mode 100644 index 000000000..2433b7f4d --- /dev/null +++ b/test/test_ota_kiss_direct.py @@ -0,0 +1,472 @@ +"""Exercise direct OTA wire framing and the real bounded Dispatcher owner. + +These native host tests do not transmit RF, invoke PlatformIO, or claim that a +hosted modem delivered a frame. OtaManager negotiation/geometry has separate +coverage; this file tests the scheduler and its admission-policy boundary. +""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_meshtext_transport import HARNESS as TRANSPORT_HARNESS +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + + +def dispatcher_harness(): + code = TRANSPORT_HARNESS.split("int main() {", 1)[0] + code = code.replace( + "#include ", + "#include \n#include \n" + "#include \n#include ", + ) + code = code.replace( + "bool incoming=false;", + "bool incoming=false; uint32_t rx_generation=0; std::vector rx;", + ) + code = code.replace( + "uint8_t receiveProfile() const override { return active; }", + "uint8_t receiveProfile() const override { return active; }\n" + " uint32_t receiveProfileGeneration() const override {\n" + " return rx_generation ? rx_generation : p.generation[active];\n }", + ) + start = code.index(" int recvRaw(") + end = code.index(" void onReceiveProcessed()", start) + code = code[:start] + r''' + int recvRaw(uint8_t* bytes,int capacity) override { + if (!incoming) return 0; + incoming=false; + assert(rx.size()<=size_t(capacity)); + if (!rx.empty()) memcpy(bytes,rx.data(),rx.size()); + return int(rx.size()); + } +''' + code[end:] + code = code.replace( + "bool allow_foreign=true;", + "bool allow_foreign=true, allow_direct=false;\n" + " unsigned direct_calls=0, direct_rx=0; float speed=1; uint32_t epoch=1;\n" + " std::vector direct_message;", + ) + code = code.replace( + "using Dispatcher::queueForeignRadioFrame;", + "using Dispatcher::queueForeignRadioFrame;\n" + " using Dispatcher::discardQueuedForeignRadioFrame;\n" + "#if MESH_ENABLE_OTA_KISS_DIRECT\n" + " using Dispatcher::queueDirectOtaRadioFrame;\n" + " uint32_t directOtaEpoch() const override { return epoch; }\n" + " bool allowDirectOtaTransmit(uint8_t,uint32_t) const override { return allow_direct; }\n" + " bool onDirectOtaRadioFrame(const uint8_t* b,size_t n,uint8_t,uint32_t) override {\n" + " ++direct_calls; const uint8_t* msg=nullptr;uint16_t size=0;\n" + " if (!allow_direct || !ota::ota_direct_wire_decode(b,n,msg,size)) return false;\n" + " ++direct_rx;direct_message.assign(msg,msg+size);return true;\n" + " }\n#endif\n" + " float getOtaPacketSpeedFactor() const override { return speed; }", + ) + code += r''' +static void directProfile(Fixture& f,uint8_t profile=1) { + f.radio.p.secondary.params.protocol=RadioProfileProtocol::MeshCore; + f.radio.p.secondary.params.preamble=32; + f.radio.p.primary_temporary=profile==0; + f.radio.p.secondary_temporary=profile==1; + f.d.allow_direct=true; +} +#if MESH_ENABLE_OTA_KISS_DIRECT +static bool enqueue(Fixture& f,uint8_t profile=1) { + const uint8_t msg[]={ota::OTA_REQ,0xc0,0xdb}; + return f.d.queueDirectOtaRadioFrame(msg,sizeof(msg),profile,f.radio.p.generation[profile]); +} +#endif +static void receive(Fixture& f,const std::vector& frame,uint8_t profile=1) { + f.radio.active=profile;f.radio.rx=frame;f.radio.incoming=true;f.tick(); +} +''' + return code + + +class OtaKissDirectTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("a host C++17 compiler is required") + + def compile_run(self, code, flags=(), dispatcher=False, expect_compile=True): + with tempfile.TemporaryDirectory(prefix="meshcore-ota-kiss-direct-") as folder: + cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test.exe" + cpp.write_text(code, encoding="utf-8") + command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", *flags] + if os.name != "nt": + command += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + command += ["-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src")] + if dispatcher: + command += [str(ROOT / source) for source in ( + "src/Dispatcher.cpp", "src/Packet.cpp", "src/helpers/StaticPoolPacketManager.cpp")] + command += [str(cpp), "-o", str(binary)] + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + if not expect_compile: + self.assertNotEqual(result.returncode, 0, "invalid configuration unexpectedly compiled") + return result.stdout + result.stderr + self.assertEqual(result.returncode, 0, (result.stdout + result.stderr)[-16000:]) + env = os.environ.copy() + env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20, env=env) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + def test_wire_full_frame_binary_escaping_and_rejection(self): + code = r''' +#include +#include +#include +#include +using namespace mesh; +int main() { + static_assert(ota::OTA_DIRECT_FRAG_DATA==240,"full data budget"); + uint8_t message[253];message[0]=ota::OTA_DATA; + for (unsigned i=1;i258); + for (size_t i=0;i +#include +static_assert(MESH_ENABLE_OTA_KISS_DIRECT==EXPECT_DIRECT,"feature scope"); +static_assert(bool(MESH_ENABLE_FOREIGN_RADIO)==bool(EXPECT_DIRECT),"shared slot scope"); +static_assert(MAX_PACKET_PAYLOAD==184,"routed payload remains unchanged"); +int main() { std::printf("%zu\n",sizeof(mesh::Packet)); } +''' + sizes = set() + for flags, enabled in ( + ([], 0), (["-DENABLE_OTA"], 1), + (["-DENABLE_OTA", "-DMESH_PRIMARY_ESPNOW=1"], 0), + (["-DENABLE_OTA", "-DMESH_PRIMARY_ESPNOW=0"], 1), + (["-DENABLE_OTA", "-DMESH_ENABLE_OTA_KISS_DIRECT=0"], 0), + (["-DENABLE_OTA", "-DMESH_ENABLE_FOREIGN_RADIO=0"], 0), + ): + with self.subTest(flags=flags): + sizes.add(self.compile_run(code, [*flags, f"-DEXPECT_DIRECT={enabled}", + "-DMESH_ENABLE_MESHTEXT_CHAT=0", "-DMESH_ENABLE_ETHERNET_TUNNEL=0"]).strip()) + self.assertEqual(len(sizes), 1, "direct support must not enlarge each Packet") + for flags in (["-DMESH_ENABLE_OTA_KISS_DIRECT=1"], + ["-DENABLE_OTA", "-DMESH_ENABLE_OTA_KISS_DIRECT=2"], + ["-DENABLE_OTA", "-DMESH_ENABLE_OTA_KISS_DIRECT=1", "-DMESH_ENABLE_FOREIGN_RADIO=0"]): + diagnostic = self.compile_run("#include \nint main() {}", + flags, expect_compile=False) + self.assertIn("direct", diagnostic.lower()) + + def test_real_dispatcher_profile_affinity_admission_and_accounting(self): + code = dispatcher_harness() + r''' +int main() { + using P=RadioProfileProtocol; + for (uint8_t profile: {0,1}) { + Fixture f;directProfile(f,profile);const auto free=f.manager.getFreeCount(); + uint8_t message[253];message[0]=ota::OTA_DATA; + for (unsigned i=1;i<253;++i) message[i]=uint8_t(i); + assert(f.d.queueDirectOtaRadioFrame(message,253,profile,f.radio.p.generation[profile])); + assert(f.manager.getFreeCount()==free && f.manager.getOutboundTotal()==0); + assert(!enqueue(f,profile));f.tick(); + assert(f.radio.sends==1 && f.radio.active==profile && f.radio.sent.size()==255); + assert(f.radio.sent[0]==0xf2 && !f.radio.sent[1] && !memcmp(f.radio.sent.data()+2,message,253)); + f.tick(20);f.radio.complete=true;f.tick(); + assert(!f.d.hasOutbound() && f.d.done.empty() && !f.d.mc_sent); + assert(f.radio.p.tx_packets[profile]==1 && f.d.getNumSentDirect()==1 && !f.d.getNumSentFlood()); + assert(f.d.getTotalAirTime()==21 && f.manager.getFreeCount()==free); + } + { Fixture f;directProfile(f);f.d.allow_direct=false;assert(!enqueue(f)); + f.d.allow_direct=true;f.radio.carrier=true;assert(!enqueue(f));f.radio.carrier=false; + f.radio.p.secondary.mode=RadioProfileMode::Rx;assert(!enqueue(f)); + f.radio.p.secondary.mode=RadioProfileMode::Off;assert(!enqueue(f)); + f.radio.p.secondary.mode=RadioProfileMode::RxTx; + assert(!f.d.queueDirectOtaRadioFrame(bytes,3,2,1)); + assert(!f.d.queueDirectOtaRadioFrame(bytes,3,1,f.radio.p.generation[1]+1)); + f.radio.p.secondary.params.protocol=P::Ethernet;assert(!enqueue(f)); + f.radio.p.secondary.params.protocol=P::MeshCore; + assert(!f.d.queueDirectOtaRadioFrame(nullptr,3,1,f.radio.p.generation[1])); + const uint8_t discover[]={ota::OTA_ADV}; + assert(!f.d.queueDirectOtaRadioFrame(discover,1,1,f.radio.p.generation[1])); + assert(!f.d.queueForeignRadioFrame(bytes,3,1,f.radio.p.generation[1],P::MeshCore)); + } + for (unsigned changed=0;changed<4;++changed) { + Fixture f;directProfile(f);assert(enqueue(f)); + if (changed==0) ++f.radio.p.generation[1]; + if (changed==1) f.radio.p.secondary.params.protocol=P::Meshtext; + if (changed==2) f.radio.p.secondary.mode=RadioProfileMode::Off; + if (changed==3) f.d.allow_direct=false; + f.tick();assert(!f.radio.sends && !f.d.hasOutbound() && f.d.done.empty()); + assert(!f.d.getNumSentDirect() && !f.radio.p.tx_packets[1]); + } + { Fixture f;directProfile(f);assert(enqueue(f));f.tick(); + ++f.radio.p.generation[1];f.radio.complete=true;f.tick(10); + assert(!f.d.hasOutbound() && f.d.done.empty() && !f.d.getNumSentDirect()); + assert(f.d.getTotalAirTime()==10 && !f.radio.p.tx_packets[1]); + } + { Fixture f;directProfile(f);assert(enqueue(f)); + assert(!f.d.discardQueuedForeignRadioFrame(P::Ethernet)); + assert(f.d.discardQueuedForeignRadioFrame(P::MeshCore)); + f.tick();assert(!f.radio.sends && !f.d.hasOutbound() && f.d.done.empty()); + assert(enqueue(f));f.tick();assert(!f.d.discardQueuedForeignRadioFrame(P::MeshCore)); + f.radio.complete=true;f.tick();assert(f.d.getNumSentDirect()==1); + } +} +''' + self.compile_run(code, ["-DENABLE_OTA", "-DMESH_ENABLE_MESHTEXT_CHAT=0", + "-DMESH_ENABLE_ETHERNET_TUNNEL=0"], dispatcher=True) + + def test_real_dispatcher_epoch_retires_abort_fallback_and_old_context(self): + code = dispatcher_harness() + r''' +int main() { + { Fixture f;directProfile(f);f.d.epoch=0;assert(!enqueue(f));assert(!f.d.hasOutbound()); } + // The manager changes the opaque token on abort, fallback and context + // reconstruction. Neither a stable radio generation nor a live temp window + // can authorize an already-admitted frame belonging to the former token. + for (uint32_t retired: {0u,2u,0xffffffffu}) { + Fixture f;directProfile(f);assert(enqueue(f));f.d.epoch=retired;f.tick(); + assert(!f.radio.sends && !f.d.hasOutbound() && f.d.done.empty()); + assert(!f.d.getNumSentDirect() && !f.radio.p.tx_packets[1]); + f.d.epoch=3;assert(enqueue(f));f.tick();assert(f.radio.sends==1); + f.radio.complete=true;f.tick();assert(f.d.getNumSentDirect()==1 && f.d.done.empty()); + } + { Fixture f;directProfile(f);f.radio.prepare=RadioParamApplyResult::BUSY; + assert(enqueue(f));f.tick();f.d.epoch=2;f.tick(20); + assert(!f.radio.sends && !f.d.hasOutbound() && !f.radio.recovery && f.d.done.empty()); + } + { Fixture f;directProfile(f);assert(enqueue(f));f.tick();f.d.epoch=2; + f.radio.complete=true;f.tick(10); + assert(f.radio.sends==1 && f.radio.finishes==1 && !f.d.hasOutbound()); + assert(f.d.getTotalAirTime()==10 && !f.d.getNumSentDirect() && !f.radio.p.tx_packets[1]); + assert(f.d.done.empty());f.tick(5000);assert(f.radio.sends==1); + assert(enqueue(f));f.tick();assert(f.radio.sends==2); // new token remains usable + } + { Fixture f;directProfile(f);assert(enqueue(f));f.tick();f.d.epoch=0;f.tick(1151); + assert(f.radio.sends==1 && f.radio.finishes==1 && f.radio.recovery==1 && !f.d.hasOutbound()); + assert(f.d.getTotalAirTime()==1151 && !f.d.getNumSentDirect() && f.d.done.empty()); + } +} +''' + self.compile_run(code, ["-DENABLE_OTA", "-DMESH_ENABLE_MESHTEXT_CHAT=0", + "-DMESH_ENABLE_ETHERNET_TUNNEL=0"], dispatcher=True) + + def test_real_dispatcher_failures_cad_duty_quiet_and_packet_fairness(self): + code = dispatcher_harness() + r''' +int main() { + { Fixture f;directProfile(f);f.radio.start_ok=false;assert(enqueue(f));f.tick();f.tick(10000); + assert(f.radio.sends==1 && !f.d.hasOutbound() && f.d.done.empty() && !f.d.getNumSentDirect()); + } + { Fixture f;directProfile(f);assert(enqueue(f));f.tick();f.tick(1151); + assert(f.radio.sends==1 && f.radio.finishes==1 && f.radio.recovery==1 && !f.d.hasOutbound()); + f.tick(10000);assert(f.radio.sends==1 && f.d.done.empty()); + } + { Fixture f;directProfile(f);f.radio.prepare=RadioParamApplyResult::FAILED;assert(enqueue(f));f.tick(); + assert(!f.radio.sends && !f.d.hasOutbound() && f.d.done.empty()); + } + { Fixture f;directProfile(f);f.radio.prepare=RadioParamApplyResult::BUSY;assert(enqueue(f));f.tick();f.tick(8151); + assert(!f.radio.sends && !f.d.hasOutbound() && f.radio.recovery==1 && f.d.done.empty()); + } + { Fixture f;directProfile(f);f.radio.receiving=true;assert(enqueue(f));f.tick();f.tick(201); + assert(!f.radio.sends && f.d.hasOutbound());f.radio.receiving=false;f.tick(201);assert(f.radio.sends==1); + } + { Fixture f;directProfile(f);assert(enqueue(f));f.tick();f.d.setRadioAvailable(false);f.tick(10000); + assert(f.radio.sends==1 && f.radio.finishes==1 && !f.d.hasOutbound() && !enqueue(f) && f.d.done.empty()); + } + { Fixture f;directProfile(f);assert(enqueue(f));f.tick();f.d.begin(); + assert(f.radio.finishes==1 && !f.d.hasOutbound() && !f.d.getNumSentDirect() && f.d.done.empty()); + } + { Fixture f;directProfile(f);f.d.duty_window=200;f.d.begin();assert(enqueue(f));f.tick(); + f.clock.now+=100;f.radio.complete=true;f.d.loop();assert(f.d.getRemainingTxBudget()==0); + assert(enqueue(f));f.tick(100);assert(f.radio.sends==1);f.tick(101);assert(f.radio.sends==2); + } + { Fixture f;directProfile(f);f.d.speed=0.5;assert(enqueue(f));f.tick();f.tick(99); + f.radio.complete=true;f.tick();assert(f.d.getTotalAirTime()==100); + assert(enqueue(f));f.tick(99);assert(f.radio.sends==1);f.tick(2);assert(f.radio.sends==2); + } + { Fixture f;directProfile(f);assert(enqueue(f));f.tick();f.radio.complete=true;f.tick(); + assert(enqueue(f));auto* packet=newPacket(f.d,RADIO_TX_PRIMARY);assert(f.d.sendPacket(packet,0));f.tick(); + assert(f.radio.sends==2 && f.radio.active==0);f.radio.complete=true;f.tick();f.tick(); + assert(f.radio.sends==3 && f.radio.active==1 && f.d.mc_sent==1 && f.d.done.empty()); + } +} +''' + self.compile_run(code, ["-DENABLE_OTA", "-DMESH_ENABLE_MESHTEXT_CHAT=0", + "-DMESH_ENABLE_ETHERNET_TUNNEL=0"], dispatcher=True) + + def test_real_dispatcher_receive_isolation_without_packet_credit(self): + code = dispatcher_harness() + r''' +int main() { + const std::vector valid={0xf2,0,ota::OTA_REQ,0xc0,0xdb}; + { Fixture f;directProfile(f);std::vector reserved; + while (auto* p=f.d.obtainNewPacket()) reserved.push_back(p); + assert(!f.manager.getFreeCount());receive(f,valid); + assert(f.d.direct_rx==1 && f.d.direct_message==std::vector(valid.begin()+2,valid.end())); + assert(!f.d.mc_rx && !f.d.foreign_rx && !f.d.raw_logs && !f.d.getLastMeshCoreRecvMillis()); + assert(f.d.getNumRecvDirect()==1 && !f.d.getNumRecvFlood() && f.radio.p.rx_packets[1]==1); + assert(f.radio.processed==1 && f.d.getReceiveAirTime()==100 && !f.manager.getFreeCount()); + for (auto* p:reserved) f.d.releasePacket(p); + } + { Fixture f;directProfile(f);f.d.allow_direct=false;receive(f,valid); + assert(f.d.direct_calls==1 && !f.d.direct_rx && !f.d.raw_logs && !f.d.mc_rx && !f.d.foreign_rx); + assert(!f.d.getNumRecvDirect() && !f.radio.p.rx_packets[1] && !f.d.getReceiveAirTime()); + } + for (const auto& bad: std::vector>{ + {0xf2},{0xf2,0},{0xf2,1,ota::OTA_REQ},{0xf2,0,ota::OTA_ADV},{0xf2,0,0xff}}) { + Fixture f;directProfile(f);receive(f,bad); + assert(f.d.direct_calls==1 && !f.d.direct_rx && !f.d.raw_logs && !f.d.mc_rx && !f.d.foreign_rx); + assert(!f.d.getNumRecvDirect() && !f.radio.p.rx_packets[1] && f.radio.processed==1); + } + { Fixture f;directProfile(f);f.radio.rx_generation=f.radio.p.generation[1];++f.radio.p.generation[1];receive(f,valid); + assert(!f.d.direct_calls && !f.d.raw_logs && !f.d.mc_rx && f.radio.processed==1); + } + { Fixture f;directProfile(f,0);receive(f,valid,0);assert(f.d.direct_rx==1 && f.radio.p.rx_packets[0]==1); } + { Fixture f;directProfile(f);auto flood=valid;flood[0]=0xf1;receive(f,flood); + assert(!f.d.direct_calls && !f.d.mc_rx && !f.d.getNumRecvFlood() && !f.manager.getOutboundTotal()); + } +} +''' + self.compile_run(code, ["-DENABLE_OTA", "-DMESH_ENABLE_MESHTEXT_CHAT=0", + "-DMESH_ENABLE_ETHERNET_TUNNEL=0"], dispatcher=True) + + def test_real_dispatcher_disabled_and_shared_transport_ownership(self): + code = dispatcher_harness() + r''' +int main() { +#if MESH_ENABLE_OTA_KISS_DIRECT + { Fixture f;directProfile(f);assert(enqueue(f));assert(!f.d.queue(bytes)); + f.d.discardQueuedForeignRadioFrame(RadioProfileProtocol::MeshCore); + f.radio.p.secondary.params.protocol=RadioProfileProtocol::Meshtext; + assert(f.d.queue(bytes));assert(!enqueue(f,0)); + } +#else + { Fixture f;directProfile(f);receive(f,{0xf2,0,ota::OTA_REQ}); + assert(!f.d.direct_calls && !f.d.mc_rx && !f.manager.getOutboundTotal()); + } +#endif + // Existing generic Meshtext ownership and its completion callback are unchanged. + { Fixture f;assert(f.d.queue(bytes));f.tick();f.radio.complete=true;f.tick(); + assert(f.d.done.size()==1 && f.d.done[0] && !f.d.getNumSentDirect()); + } +} +''' + for enabled in (0, 1): + with self.subTest(direct=enabled): + self.compile_run(code, ["-DENABLE_OTA", f"-DMESH_ENABLE_OTA_KISS_DIRECT={enabled}", + "-DMESH_ENABLE_MESHTEXT_CHAT=1"], dispatcher=True) + + def test_mesh_temp_policy_and_no_unsolicited_context_allocation(self): + source = (ROOT / "src/Mesh.cpp").read_text() + epoch = method(source, "uint32_t Mesh::directOtaEpoch(") + policy = method(source, "bool Mesh::allowDirectOtaTransmit(") + receive = method(source, "bool Mesh::onDirectOtaRadioFrame(") + code = r''' +#include +#include +#include +namespace mesh { namespace ota { +struct OtaReplyRoute { uint8_t profile=255;uint32_t generation=0;bool direct=false; }; +struct Manager { + unsigned calls=0;bool enabled=false;uint8_t hops=255;OtaReplyRoute route; + uint32_t directTransportEpoch() const { return 42; } + void set_direct_transport_enabled(bool value) { enabled=value; } + void note_rx_path_hops(uint8_t value) { hops=value; } + bool on_message(const uint8_t*,uint16_t,OtaReplyRoute value) { ++calls;route=value;return true; } + uint8_t fetchState() const { return 0; } +}; +struct Context { Manager manager;void track_session(uint8_t,unsigned long) {} } context; +bool exists=false;unsigned lookups=0; +Context* ota_context_if_active() { ++lookups;return exists ? &context:nullptr; } +} } +namespace mesh { +struct TestRadio { RadioProfiles p;const RadioProfiles* profiles() const { return &p; } }; +struct Clock { unsigned long getMillis() const { return 1000; } }; +class Mesh { +public: + TestRadio radio;TestRadio* _radio=&radio;Clock clock;Clock* _ms=&clock; + bool temp=false,primary=false,transmit=true; + Mesh() { + radio.p.primary.freq=909.5;radio.p.primary.bw=500;radio.p.primary.sf=5;radio.p.primary.cr=5; + radio.p.secondary.params=radio.p.primary;radio.p.secondary.mode=RadioProfileMode::RxTx; + } + bool isAnyTempRadioActive() const { return temp; } + bool isTempRadioActive() const { return primary; } + bool allowPacketTransmit(const Packet*) const { return transmit; } + void syncOtaTiming(ota::Manager&) {} + bool allowDirectOtaTransmit(uint8_t,uint32_t) const; + uint32_t directOtaEpoch() const; + bool onDirectOtaRadioFrame(const uint8_t*,size_t,uint8_t,uint32_t); +}; +@POLICY@ +@EPOCH@ +@RECEIVE@ +} +int main() { + using namespace mesh;Mesh m;const auto gen=m.radio.p.generation[1]; + const uint8_t frame[]={0xf2,0,ota::OTA_REQ}; + assert(!m.directOtaEpoch() && !ota::exists && ota::lookups==1); + assert(!m.allowDirectOtaTransmit(1,gen));m.temp=true; + assert(!m.allowDirectOtaTransmit(1,gen));m.radio.p.secondary_temporary=true; + assert(m.allowDirectOtaTransmit(1,gen)); + assert(!m.onDirectOtaRadioFrame(frame,3,1,gen) && ota::lookups==2 && !ota::context.manager.calls); + ota::exists=true;assert(m.onDirectOtaRadioFrame(frame,3,1,gen)); + assert(m.directOtaEpoch()==42); + assert(ota::context.manager.calls==1 && ota::context.manager.enabled && ota::context.manager.hops==0); + assert(ota::context.manager.route.direct && ota::context.manager.route.profile==1 && ota::context.manager.route.generation==gen); + m.transmit=false;assert(!m.onDirectOtaRadioFrame(frame,3,1,gen));m.transmit=true; + assert(!m.allowDirectOtaTransmit(1,gen+1) && !m.allowDirectOtaTransmit(2,gen)); + m.radio.p.secondary.mode=RadioProfileMode::Rx;assert(!m.allowDirectOtaTransmit(1,gen)); + m.radio.p.secondary.mode=RadioProfileMode::RxTx; + m.radio.p.secondary.params.protocol=RadioProfileProtocol::Ethernet;assert(!m.allowDirectOtaTransmit(1,gen)); + m.radio.p.secondary.params.protocol=RadioProfileProtocol::MeshCore; + m.radio.p.secondary_temporary=false;m.primary=true; + assert(!m.allowDirectOtaTransmit(1,gen));assert(m.allowDirectOtaTransmit(0,m.radio.p.generation[0])); + m.primary=false;m.radio.p.primary_temporary=true; + assert(m.allowDirectOtaTransmit(0,m.radio.p.generation[0])); + m.radio.p.primary_temporary=false;m.radio.p.cross=RadioCrossMode::On; + assert(m.allowDirectOtaTransmit(1,gen));m.temp=false;assert(!m.allowDirectOtaTransmit(1,gen)); +} +'''.replace("@POLICY@", policy).replace("@RECEIVE@", receive).replace("@EPOCH@", epoch) + self.compile_run(code, ["-DENABLE_OTA", "-DMESH_ENABLE_MESHTEXT_CHAT=0"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_nrf52_display_cache.py b/test/test_ota_nrf52_display_cache.py index b2fae8eeb..1202250ba 100644 --- a/test/test_ota_nrf52_display_cache.py +++ b/test/test_ota_nrf52_display_cache.py @@ -77,7 +77,7 @@ struct OtaManager { enum FetchError { FETCH_ERROR_NONE, FETCH_ERROR_MANIFEST, FETCH_ERROR_HASH_ALGO, FETCH_ERROR_VERSION, FETCH_ERROR_CODEC, FETCH_ERROR_GEOMETRY, FETCH_ERROR_TOO_LARGE, FETCH_ERROR_STORAGE, FETCH_ERROR_INTEGRITY, FETCH_ERROR_MANIFEST_TIMEOUT, - FETCH_ERROR_LEAVES_TIMEOUT }; + FETCH_ERROR_LEAVES_TIMEOUT, FETCH_ERROR_RADIO_SESSION }; enum { AUTOFETCH_ANY = 2, AUTOFETCH_SIGNED = 1 }; struct ServeEntry { bool is_self; uint8_t mid[4]; uint32_t fw_version, have_count; }; FetchState state = IDLE; @@ -85,6 +85,8 @@ struct OtaManager { unsigned have = 0, total = 26; uint8_t mid[4] = {1, 2, 3, 4}; FetchState fetchState() const { return state; } + bool directFetchActive() const { return false; } + bool directFetchConfirmed() const { return false; } FetchError fetchError() const { return error; } unsigned blocksHave() const { return have; } unsigned blocksTotal() const { return total; } diff --git a/test/test_peripheral_mode_prefs.py b/test/test_peripheral_mode_prefs.py index e238732cd..a290c359f 100644 --- a/test/test_peripheral_mode_prefs.py +++ b/test/test_peripheral_mode_prefs.py @@ -245,7 +245,7 @@ int main(){ strcpy(prefs.node_name,"retained name");prefs.ble_pin=654321;prefs.gps_interval=42; assert(prefs.getCustom()->setByKey("snapshot_tag","original")); assert(store.savePrefs(prefs,47.2,-122.1));const auto disk=store.fs.files["/new_prefs"]; - assert(disk.size()==244);double lat=0,lon=0; + assert(disk.size()==252);double lat=0,lon=0; CompanionNodePrefs loaded;assert(loaded.getCustom()->setByKey("snapshot_tag","loaded")); assert(store.loadPrefsInt("/new_prefs",loaded,lat,lon)); assert(loaded.peripherals_shared==1&&loaded.ble_pin==654321&&loaded.gps_interval==42); @@ -259,6 +259,11 @@ int main(){ assert(ok==(keep==236));if(ok)assert(candidate.peripherals_shared==0); else assert(candidate.peripherals_shared==1&&candidate.ble_pin==123456); } + // The previous complete RAK PMO footer remains an accepted legacy image. + DataStore old;old.fs.files["/new_prefs"]={disk.begin(),disk.begin()+244}; + CompanionNodePrefs old_prefs;old_prefs.tx_media=mesh::RADIO_TX_OFF; + assert(old.loadPrefsInt("/new_prefs",old_prefs,lat,lon)); + assert(old_prefs.peripherals_shared==1&&old_prefs.tx_media==mesh::RADIO_TX_AUTO); for(size_t byte=236;byte<244;++byte){ DataStore damaged;damaged.fs.files["/new_prefs"]=disk;damaged.fs.files["/new_prefs"][byte]^=1; CompanionNodePrefs candidate;candidate.peripherals_shared=1;candidate.ble_pin=123456; diff --git a/test/test_radio_profile_auto.py b/test/test_radio_profile_auto.py index c60b10230..1506b2d64 100644 --- a/test/test_radio_profile_auto.py +++ b/test/test_radio_profile_auto.py @@ -329,6 +329,88 @@ int main(int argc, char** argv) { class RadioProfileAutoTest(unittest.TestCase): + def test_ota_raw_slot_does_not_enable_foreign_ownership_or_v2_settings(self): + code = r''' +#define main existing_cli_main +#include "test/fixtures/radio_profiles/cli_test.cpp" +#undef main +int main() { + Fixture f(true); + const bool foreign = MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL; +#if defined(ENABLE_OTA) + static_assert(MESH_ENABLE_OTA_KISS_DIRECT, "exercise the actual direct-OTA capability"); + static_assert(MESH_ENABLE_FOREIGN_RADIO, "exercise the shared raw scheduler"); +#endif + assert(f.cli.canAutoSecondary() == foreign); + assert(!f.cli.autoSecondaryActive()); + mesh::RadioProfileConfig config; + config.mode = mesh::RadioProfileMode::RxTx; + config.params.freq = 909.5f; config.params.bw = 250; + config.params.sf = 11; config.params.cr = 5; config.params.preamble = 16; +#if MESH_ENABLE_MESHTEXT_CHAT + config.params.protocol = mesh::RadioProfileProtocol::Meshtext; +#else + config.params.protocol = mesh::RadioProfileProtocol::Ethernet; +#endif + assert(f.cli.requestAutoSecondary(config) == foreign); + f.cli.clearAutoSecondary(); + assert(!f.radio.p.enabled()); + assert(f.cli.useSecondaryProtocol(mesh::RadioProfileProtocol::Meshtext) == false); + char reply[160] = "unchanged"; + assert(f.cli.handle("set radio2 auto", reply, sizeof reply)); + assert((!strncmp(reply, "OK", 2)) == foreign); + if (foreign) f.advance(2000); + assert(f.cli.savePrimaryPreamble(120)); + const auto image = f.fs.files["/radio_profiles"]; + assert(image.size() == 24 && image[2] == (foreign ? 2 : 1)); + f.cmd("set radio2 off"); + assert(!f.cli.autoSecondaryActive()); + assert(f.fs.files["/radio_profiles"][2] == (foreign ? 2 : 1)); + Radio reboot; mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock, true); + assert(!reboot.p.enabled() && !restored.canAutoSecondary()); +#if defined(MESH_HIL_TX_FREQ_MHZ) + assert(restored.handle("get lab.tx", reply, sizeof reply)); + assert(!strcmp(reply, "> HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency")); +#else + assert(!restored.handle("get lab.tx", reply, sizeof reply)); +#endif + assert(!restored.handle("set lab.tx 909.5", reply, sizeof reply)); + printf("profile_size=%zu\n", sizeof(mesh::RadioProfileCLI)); +} +''' + sizes = {} + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + with tempfile.TemporaryDirectory(prefix="radio-profile-capability-") as work: + source, binary = Path(work) / "capability.cpp", Path(work) / "capability" + source.write_text(code) + for name, ota, meshtext, ethernet, guard in ( + ("baseline", 0, 0, 0, 0), ("ota", 1, 0, 0, 0), + ("meshtext", 1, 1, 0, 0), ("ethernet", 1, 0, 1, 0), + ("ota_guard", 1, 0, 0, 1), + ): + flags = [f"-DMESH_ENABLE_MESHTEXT_CHAT={meshtext}", + f"-DMESH_ENABLE_ETHERNET_TUNNEL={ethernet}", + "-DMESH_ENABLE_FLEET_CONTROL=0"] + if ota: + flags.append("-DENABLE_OTA=1") + if guard: + flags.append("-DMESH_HIL_TX_FREQ_MHZ=909.5") + result = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", *flags, + "-I", str(ROOT), "-I", str(ROOT / "test/fixtures/radio_profiles/mocks"), + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(ROOT / "src/helpers/RadioProfileCLI.cpp"), str(source), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(run.returncode, 0, name + ": " + run.stdout + run.stderr) + sizes[name] = int(run.stdout.split("profile_size=", 1)[1].splitlines()[0]) + self.assertEqual(sizes["baseline"], sizes["ota"]) + self.assertEqual(sizes["baseline"], sizes["ota_guard"]) + self.assertGreater(sizes["meshtext"], sizes["ota"]) + self.assertEqual(sizes["meshtext"], sizes["ethernet"]) + @classmethod def setUpClass(cls): compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index 817ac5440..ab554761e 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -119,16 +119,23 @@ int main() { assert(w._profiles.switchTestReady()); assert(w._profiles.switch_test_samples[0]==4 && w._profiles.switch_test_samples[1]==4); assert(w._profiles.switchBudgetUs()==1320); // measured 1200 us plus 10%, no nominal floor - assert(w._profiles.preamble(0,32)>=32 && w._profiles.preamble(0,32)<=128); - assert(w._profiles.preamble(1,32)>=32 && w._profiles.preamble(1,32)<=128); + assert(w._profiles.preamble(0,32)>=32 && w._profiles.preamble(0,32)<=512); + assert(w._profiles.preamble(1,32)>=32 && w._profiles.preamble(1,32)<=512); assert(w._profiles.preamble(0,32)%8==0 && w._profiles.preamble(1,32)%8==0); } { RadioLibWrapper w; w._profiles.primary.bw=w._cur_bw=500; w.switch_apply_us=8428; w.enable(); assert(w._profiles.switchTestReady() && w._profiles.switchBudgetUs()==9271); - assert(w._profiles.preamble(0,32)==128 && w._profiles.preamble(1,32)<=128); - assert(w._profiles.chirpTiming().preamble[0]>128); + assert(w._profiles.preamble(0,32)==168 && w._profiles.preamble(1,32)<=512); + assert(w._profiles.chirpTiming().preamble[0]==168); + } + { + RadioLibWrapper w; w._profiles.primary.bw=w._cur_bw=500; w.switch_apply_us=40000; + w.enable(); + assert(w._profiles.switchTestReady() && w._profiles.switchBudgetUs()==44000); + assert(w._profiles.preamble(0,32)==512 && w._profiles.preamble(1,32)<=512); + assert(w._profiles.chirpTiming().preamble[0]>512); } { RadioLibWrapper w; w.packet=true; w.enable(); diff --git a/tools/lora_ota/README.md b/tools/lora_ota/README.md new file mode 100644 index 000000000..10993fdec --- /dev/null +++ b/tools/lora_ota/README.md @@ -0,0 +1,207 @@ +# LoRa OTA tools + +`lora_ota.py` is the existing Companion-based update runner. The separate +`mota_kiss_direct.py` tool serves one verified `.mota` file through a **full KISS +modem** on logical radio port `0` or `1`. It does not use the Companion raw-packet +command or the Ethernet-only KISS endpoint. See the [mOTA tooling](../mota/README.md) +for container creation and the [OTA protocol](../../docs/ota_protocol.md) for the +wire format. + +## KISS direct source + +The default invocation is **read-only probing; no RF transmission**. Serving +requires explicit `--serve`, an exact USB serial, a logical port, and an expected +frequency. An optional `--expect-public-key` adds the modem's exact 64-hex-character +public identity check. Use exclusive access to the modem; stop other serial users +first. + +The tool never tunes either radio, changes TX power, or clears temporary profiles. +Configure both ends beforehand. Radio port `1` must already be RxTx with the +MeshCore protocol, not Ethernet or Meshtext. Active temporary radio2 leases are +rejected, including when their frequency matches the saved setting. Identity and +frequency checks precede optional pacing changes; the selected profile is checked +again before each RF frame. Unknown required getters fail closed. Production +readback is not an atomic RF lock: prevent other controllers from changing the +modem while serving. + +From the repository root, install `pyserial`, `cryptography`, and the pinned build +compressor dependencies (`python -m pip install -r requirements-build.txt`). For +example, **after configuring the modem for the lab profile**: + +```text +python tools/lora_ota/mota_kiss_direct.py --port COM6 --expect-serial 0001 --logical-port 1 --expected-frequency 909.5 --require-lab-lock +``` + +To explicitly enable request-driven serving of one file for five minutes: + +```text +python tools/lora_ota/mota_kiss_direct.py --port COM6 --expect-serial 0001 --logical-port 1 --expected-frequency 909.5 --require-lab-lock --serve --mota firmware.mota --zero-pacing --duration 300 +``` + +Replace the port, exact USB serial and file with the intended device and package. +`--zero-pacing` explicitly sets KISS TXDELAY=0, PERSIST=255 and SLOTTIME=1 **only +after preflight succeeds**. Omit it to retain existing modem pacing. Add +`--advertise` only if one initial discovery advert is wanted; otherwise the source +only replies to receiver requests. The source requires full KISS protocol 2+ and +successful `F8 TX_DONE` notifications. + +On the receiving node, open a matching temporary MeshCore radio window and set +`ota config hops 0` for direct negotiation. The source modem uses its configured +KISS profile; it does not need a temporary lease. With automatic fetching off, +run `ota ls` on the receiver **while the source is serving** to request its +catalogue, then `ota pull flash`. Re-run `ota status` to confirm +the verified download is ready before deliberately requesting installation. +Keep `ota config autoinstall off` during tests. A seeder-only Companion cannot +stage firmware to its own flash; use an OTA receiver build or its supported +folder destination instead. + +### Using `tempradio2` on the receiver + +The KISS source may use its saved radio2 configuration on logical port `1`, while +the receiving repeater uses an actual `tempradio2` lease. These are independent +requirements: the source's temporary-lease rejection does not prohibit the +receiver's temporary window. + +For the V3-to-V4 lab setup, leave radio1 unchanged and configure the infrastructure +receiver as follows before starting discovery: + +```text +ota config autofetch off +ota config autoinstall off +ota config hops 0 +set tx.reply auto +set radio2.cross auto +set tempradio2 909.5,500,5,5,rxtx,30,256 +get tempradio2 +get radio2.status +``` + +Wait for the temporary profile and timing self-test to become active, then use +`ota ls` and `ota pull` while the matching source is serving. `tx.reply auto` +selects the temporary profile for ordinary discovery replies; infrastructure's +explicit `both` policy can otherwise request a copy on radio1 too. Direct transfer +requests remain bound to the negotiated physical profile. + +The explicit 256-symbol preamble above is a tested lab value, **not a universal +minimum**. When radio1 remains on slow USA settings, the receiver must spend time +listening there; fast retuning alone does not make a 32-symbol radio2 preamble +sufficient. Check both ends' timing/recommended-preamble readbacks. Lab images +must retain the immutable 909.5-MHz TX guard so the primary USA profile stays +receive-only. Do not change or end the temporary profile during a transfer; +expiry/replacement fails the bound session closed rather than moving it to +radio1. + +### Direct capacity and fallback + +Negotiated, zero-hop direct OTA uses `F2 00` followed by the OTA message: **255 RF +bytes maximum, with up to 240 bytes of DATA** after OTA framing and stream identity. +KISS escaping exists only on the host cable. No Ethernet header, forwarding path +or enlarged normal MeshCore packet is added. A raw 2 KiB block needs nine DATA +frames instead of twelve normal v2 frames; a 1 KiB block needs five instead of six. + +Discovery remains ordinary MeshCore OTA. A capable receiver negotiates direct +framing; older or routed receivers keep normal framing (171-byte v2 or 160-byte +legacy DATA). This source answers both; it never forwards the new direct frames. +The receiver owns negotiation, timeout fallback and missing-fragment requests. +An unanswered initial direct probe falls back immediately at its adaptive +deadline. Once direct DATA has confirmed support, one entirely missed later +flight gets a same-mode sparse retry before falling back. A locally rejected +request does not consume that retry; valid direct DATA resets it. + +Before opening the modem, the source verifies container structure, Merkle data, +the full-image hash where applicable, and any embedded signature, then caches per-block raw DEFLATE using +**Google Zopfli, 1,000 iterations**. Compression is used only when smaller than the +raw block. Encoded stream identifiers and Merkle proofs retain block integrity; +the receiver still enforces its normal target, approval and installation checks. +A delta's reconstructed final-image hash must be checked by the receiver against +its installed base; this tool does not apply the delta on the host. +Container verification is not a replacement for the receiver's trusted-signer +policy. + +### Completion and adaptive pacing + +Only one DATA transmission is outstanding. The next RF frame waits for actual +`F8 TX_DONE`, **not a queue-acceptance ACK**. TX_DONE confirms the local radio +completed its transmission, not that the receiver accepted, verified or installed +it. A timeout or failed TX ends this source attempt without hidden RF retries; +receiver request masks provide resumable missing-fragment recovery. + +Pace starts at 1.0. Sparse valid request masks reduce it to 0.5 and then, on loss +in a distinct block, 0.25; duplicate requests do not compound the reduction. +Sixteen forward clean blocks recover 0.1. `--minimum-pace` selects a floor of +`0.25`, `0.5` or `1`. Only a pace below 1 adds airtime-scaled quiet gaps. Legacy +proof replies also leave a request-turnaround gap; negotiated direct trains may +send their proof immediately after the last completed DATA transmission. The final +JSON reports actual RF bytes, completed versus attempted TX frames, retries, +effective pace, elapsed time and explicitly estimated airtime. + +### Lab safety + +All lab transmissions use **909.5 MHz, 500 kHz, SF5, CR5**. Always pass +`--require-lab-lock` during hardware tests: it requires the compiled 909.5 MHz TX +lock and verifies that complete PHY tuple. Keep any primary USA profile RX-only; +do not transmit tests on it. This flag requires the lab-only read-only guard +getter; omit it only for deliberate production use with the explicitly selected +legal frequency and matching radio configuration. + +Host-only regression tests, without opening USB or transmitting: + +```text +python test/test_mota_kiss_direct.py -q +``` + +### Measured qualification (2026-10-15) + +#### Primary temporary radio (KISS port 0) + +A Heltec V3 full KISS source sent a 64 KiB transport-only container to a Heltec V4 +repeater on the MercerWoodMesh Pi, using 909.5 MHz / 500 kHz / SF5 / CR5 at 0 dBm. +These runs used the receiving node's primary `tempradio`; radio1 was not also +listening on USA. All 32 2-KiB blocks reached Merkle-verified completion in each run: + +| Transfer | DATA frames | Elapsed | Verified payload rate | +|---|---:|---:|---:| +| Normal v2, after direct-probe fallback | 384 | 47.1 s | 11.1 kbps | +| Direct KISS, raw | 288 | 34.3 s | 15.3 kbps | +| Direct KISS, Zopfli-1000 | 248 | 30.6 s | 17.1 kbps | + +The raw direct run transmitted 256 full 255-byte RF frames and needed no DATA +retransmissions. A separate compressed run deliberately omitted one DATA frame; +the receiver requested the missing fragment, verified all blocks, and the sender +reduced its pace to 0.5 before recovering to 0.6. The source also avoids a former +50-ms serial read-fill wait on every tiny modem response. + +These are application goodput figures including host control/status polling, +not PHY rates; the normal-mode run includes the initial fallback timeout. +Compression gains depend on the image. The fixture contained the first 64 KiB +of a real application, **not a complete installable firmware**: it was staged and +verified, then cancelled, never installed. Final V4 Full repeater, RAK3401 reduced +OTA and RAK4631 Full Companion builds passed their build/RAM guards; nRF52 was +compiled and host-tested, not physically transferred in this qualification. + +#### Secondary temporary radio (KISS port 1) + +The same V3 source and V4 receiver also passed actual `tempradio2` testing with +both primary radios left on USA 910.525 MHz / 62.5 kHz / SF7 / CR5, receive-only +under the immutable lab TX guard. Radio2 used 909.5 MHz / 500 kHz / SF5 / CR5, +0 dBm and preamble 256; the receiver used `tx.reply auto` and `radio2.cross auto`. +Each run staged and Merkle-verified the same 64 KiB fixture, without installation: + +| Transfer | Elapsed | Verified payload rate | Radio1 RX / TX during run | +|---|---:|---:|---:| +| Direct raw, one entire later response flight deliberately omitted | 78.3 s | 6.7 kbps | 21 / 0 | +| Direct Zopfli-1000, no injected loss | 72.6 s | 7.2 kbps | 33 / 0 | +| Normal v2, initial direct probe deliberately ignored | 114.2 s | 4.6 kbps | 40 / 0 | + +The missed-flight test dropped exactly 18 DATA and two PROOF frames after five +committed blocks. Before the recovery fix, the identical omission caused a +240-to-171 downgrade; after the fix, all 297 transmitted DATA frames stayed in +direct geometry and all blocks verified. Ambient packet loss and pacing varied, +so individual timings are observations, not a repeatable throughput guarantee. +All tests recorded zero primary-profile TX increase. These figures include +primary-channel listening, recovery, adaptive quiet gaps and status polling; +they must not be substituted for the port-0-only figures above. The V4 Full +repeater and RAK3401 reduced-sensor OTA rebuilds passed, as did all 163 native +OTA core tests, including sanitizers, confirmed-empty retry admission, clock +rollover and session expiry. This radio2 hardware qualification was ESP32-only; +the nRF52 target was compiled, not physically transferred in this run. diff --git a/tools/lora_ota/mota_kiss_direct.py b/tools/lora_ota/mota_kiss_direct.py new file mode 100644 index 000000000..13dacb026 --- /dev/null +++ b/tools/lora_ota/mota_kiss_direct.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +"""Opt-in single-file mOTA source over full KISS, not Companion CMD65. + +Probe-only by default. --serve requires an explicit USB identity, logical radio +port and expected frequency. --require-lab-lock additionally proves the compiled +909.5 MHz TX lock and the 909.5/500/SF5/CR5 bench tuple. No radio/frequency setters +exist here. --zero-pacing explicitly selects TXDELAY=0/PERSIST=255/SLOTTIME=1. +KISS escaping is cable-only; radio bytes are F2 00 + OTA for negotiated direct +transfers, or ordinary 31/32 path-zero framing for discovery/legacy fallback. +Every emitted radio frame waits for F8 TX_DONE; no host ACK means RF delivery. +""" +from __future__ import annotations + +import argparse +from collections import deque +from dataclasses import dataclass +import hashlib +import json +import math +from pathlib import Path +import struct +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +for folder in (ROOT / "tools/hil", ROOT / "tools/mota", ROOT / "scripts"): + sys.path.insert(0, str(folder)) +from ethernet_kiss_modem_probe import (Decoder, ProtocolError, PRIMARY_STRUCT, + SECONDARY_STRUCT, GUARD_HZ, encode_frame, open_verified_port) +import motalib + +ADV, QUERY, HAVE, GET_MANIFEST, MANIFEST, REQ, DATA, REQ_PROOF, PROOF = range(1, 10) +GET_LEAVES, LEAVES = 10, 11 +TRANSFER = frozenset(range(GET_MANIFEST, LEAVES + 1)) +WIDE_PREFIX = b"\xf2\0" +WIDE_MESSAGE_MAX, WIDE_DATA, V2_DATA, LEGACY_DATA = 253, 240, 171, 160 +MAX_BATCH, MAX_PENDING_RX = 64, 64 + + +class AdaptivePace: + """Loss-aware packet spacing; duplicate requests never compound slowdown.""" + def __init__(self, minimum=.25): + if minimum not in (.25, .5, 1): + raise ValueError("Minimum pace must be0.25,0.5 or1.0") + self.minimum, self.effective = minimum, 1.0 + self.loss_blocks = set() + self.last_forward = -1 + self.clean = self.sparse_requests = self.duplicate_sparse = self.recovery_steps = 0 + + def observe(self, block, wanted, valid): + wanted &= valid + if not wanted: + return + if wanted != valid: + self.sparse_requests += 1 + if block in self.loss_blocks: + self.duplicate_sparse += 1 + return + self.loss_blocks.add(block) + self.effective = max(self.minimum, .5 if self.effective > .5 else .25) + self.last_forward = max(self.last_forward, block) + self.clean = 0 + elif block > self.last_forward: + self.last_forward = block + if self.effective < 1: + self.clean += 1 + if self.clean >= 16: + self.effective = min(1.0, round(self.effective + .1, 2)) + self.recovery_steps += 1 + self.clean = 0 + + def quiet(self, airtime): + return max(0.0, airtime) * (1 / self.effective - 1) + + def report(self): + return {"effective": self.effective, "minimum": self.minimum, + "unique_loss_blocks": len(self.loss_blocks), "sparse_requests": self.sparse_requests, + "duplicate_sparse_requests": self.duplicate_sparse, "recovery_steps": self.recovery_steps} + + +def kiss_data(port: int, radio: bytes) -> bytes: + if port not in (0, 1) or not 1 <= len(radio) <= 255: + raise ValueError("KISS DATA requires explicit port 0/1 and 1..255 RF bytes") + body = bytes((port << 4,)) + radio + return b"\xc0" + body.replace(b"\xdb", b"\xdb\xdd").replace(b"\xc0", b"\xdb\xdc") + b"\xc0" + + +@dataclass(frozen=True) +class RadioMessage: + message: bytes + wide: bool + observed_path_bytes: int = 0 + + +def decode_radio(raw: bytes) -> RadioMessage | None: + if not 3 <= len(raw) <= 255: + return None + if raw[0] == 0xF2: + return RadioMessage(raw[2:], True) if raw[1] == 0 and raw[2] in TRANSFER else None + if raw[0] not in (0x31, 0x32): + return None # no transport codes, other payload types or future versions + mode, count = raw[1] >> 6, raw[1] & 63 + if mode == 3: + return None + path_bytes = count * (mode + 1) + at = 2 + path_bytes + if path_bytes > 64 or at >= len(raw) or len(raw) - at > 184: + return None + if not ADV <= raw[at] <= LEAVES: + return None + # This tool is a direct source, never a repeater or reverse-route builder. + # Background QUERY may be overheard through relays; DATA is zero-hop only. + return RadioMessage(raw[at:], False, path_bytes) + + +def wrap_radio(message: bytes, *, wide=False, flood=False) -> bytes: + if not message or not ADV <= message[0] <= LEAVES: + raise ValueError("Unknown OTA message") + if wide: + if message[0] not in TRANSFER or flood or len(message) > WIDE_MESSAGE_MAX: + raise ValueError("Wide OTA is transfer-only, path-zero and at most253 bytes") + return WIDE_PREFIX + message + if len(message) > 184: + raise ValueError("Legacy OTA message exceeds Packet payload") + return bytes((0x31 if flood else 0x32, 0)) + message + + +def zopfli1000(data: bytes) -> bytes: + from zopfli_compress import MAXIMUM_ITERATIONS, raw_deflate_compress + return raw_deflate_compress(data, numiterations=MAXIMUM_ITERATIONS) + + +class MotaSource: + def __init__(self, blob: bytes, seeder_id: bytes, *, compressor=zopfli1000, minimum_pace=.25): + if len(blob) > 32 * 1024 * 1024 or len(seeder_id) != 4: + raise ValueError("Invalid source size or public seeder id") + self.parsed = motalib.parse_container(blob) + problems = motalib.verify(self.parsed) + if problems: + raise ValueError("Invalid mOTA: " + "; ".join(problems)) + self.manifest = self.parsed.manifest + if (self.manifest.hash_algo != motalib.HASH_ALGO_SHA256 or + self.manifest.codec_id not in (motalib.CODEC_FULL, motalib.CODEC_DETOOLS_SEQUENTIAL, + motalib.CODEC_DETOOLS_INPLACE) or + self.manifest.is_full != (self.manifest.codec_id == motalib.CODEC_FULL)): + raise ValueError("Unsupported manifest hash/codec contract") + if self.manifest.block_size not in (1024, 2048) or not 1 <= self.manifest.block_count <= 65535: + raise ValueError("Only1/2KiB OTA blocks and unsigned16-bit indexes are supported") + self.mid = self.manifest.merkle_root + self.seeder_id, self.digest = seeder_id, motalib.mh4(self.mid) + self.manifest_bytes = self.manifest.serialize()[:197] + self.compressor = compressor + self.pace = AdaptivePace(minimum_pace) + self.compressed: dict[int, bytes] = {} + self.seen: set[tuple] = set() + self.stats = {"requests": 0, "invalid_requests": 0, "data_frames": 0, + "repeated_data_frames": 0, "ota_payload_bytes": 0} + + def block(self, index): + size = self.manifest.block_size + return self.parsed.payload[index * size:(index + 1) * size] + + def prepare(self): + """Do expensive host compression before opening the live radio session.""" + for index in range(self.manifest.block_count): + if index in self.compressed: + continue + raw = self.block(index) + encoded = self.compressor(raw) + self.compressed[index] = encoded if 0 < len(encoded) < len(raw) else raw + + def advert(self): + return wrap_radio(bytes((ADV,)) + self.seeder_id + b"\1" + self.digest, flood=True) + + def proof(self, index): + siblings = motalib.proof_siblings(self.manifest.leaves, index) + return bytes((PROOF,)) + self.mid + struct.pack("= self.manifest.block_count: + raise ValueError("Out-of-range block") + v2 = bool(mask & 0x8000) and (not mask & 0x2000 or not mask & 0x1000) + extended = v2 and bool(mask & 0x2000) + if wide and not v2: + raise ValueError("Wide request must explicitly negotiate v2") + if v2 and extended != (self.manifest.block_size > 1024): + raise ValueError("Wrong v2 block descriptor geometry") + raw = self.block(index) + encoded = self.compressed.get(index, raw) if v2 and mask & 0x4000 else raw + fragment_size = WIDE_DATA if wide else V2_DATA if v2 else LEGACY_DATA + wanted = mask & (0x0FFF if extended else 0x1FFF) if v2 else mask + raw_mask = (1 << math.ceil(len(raw) / fragment_size)) - 1 + wanted &= raw_mask + stream_id = motalib.mh4(encoded) + result = [] + for fragment, at in enumerate(range(0, len(encoded), fragment_size)): + if not wanted & (1 << fragment): + continue + offset = (0x8000 | (fragment << (11 if extended else 10)) | (len(encoded) - 1) + | (0x4000 if not extended and encoded != raw else 0)) if v2 else at + data = (stream_id if v2 else b"") + encoded[at:at + fragment_size] + result.append(bytes((DATA,)) + self.mid + struct.pack(" list[bytes]: + incoming = decode_radio(radio) + if incoming is None: + return [] + message, wide = incoming.message, incoming.wide + opcode = message[0] + result = [] + try: + if opcode == QUERY: + if len(message) not in (13, 17): + raise ValueError("Truncated QUERY") + if message[1:5] != self.seeder_id or message[5:9] != self.digest: + return [] + target = struct.unpack_from("= self.manifest.block_count: + raise ValueError("Out-of-range proof") + result = [self.proof(index)] + else: + if len(message) < 9 or (len(message) - 5) % 4 or (len(message) - 5) // 4 > 4: + raise ValueError("Bad request window") + observations = [] + for at in range(5, len(message), 4): + index, mask = struct.unpack_from(" MAX_BATCH: + raise ValueError("Response batch exceeds bounded window") + except (ValueError, struct.error): + self.stats["invalid_requests"] += 1 + return [] # reject whole malformed batch, never a partial surprise + self.stats["requests"] += 1 + # Existing OTA adapters flood every normal transfer response from a new + # path-zero origin. Preserve that legacy routed fallback exactly; only + # explicit F2/00 requests establish the non-forwardable wide transport. + return [wrap_radio(message, wide=wide, flood=not wide) for message in result] + + def note_sent(self, radio): + incoming = decode_radio(radio) + if incoming is None: + raise ValueError("Invalid emitted OTA frame") + message = incoming.message + self.stats["ota_payload_bytes"] += len(message) + if message[0] == DATA: + self.stats["data_frames"] += 1 + identity = (incoming.wide, message[1:9], hashlib.sha256(message[9:]).digest()) + if identity in self.seen: + self.stats["repeated_data_frames"] += 1 + self.seen.add(identity) + + +class KissLink: + """One exclusive serial owner, one TX in flight, bounded received requests.""" + def __init__(self, port, logical_port, *, timeout=10, clock=time.monotonic): + if logical_port not in (0, 1) or not 0 < timeout <= 60: + raise ValueError("Invalid logical port or bounded deadline") + self.port, self.logical_port, self.timeout, self.clock = port, logical_port, timeout, clock + self.decoder, self.frames, self.received = Decoder(), deque(), deque() + self.authorized = False + self.radio = None + self.rf_bytes = self.kiss_bytes = self.tx_frames = self.tx_failed = 0 + self.tx_started_frames = self.tx_started_bytes = 0 + self.last_airtime_seconds = 0.0 + self.tx_done_wait_seconds = self.estimated_airtime_seconds = 0.0 + + def _write(self, frame): + if self.port.write(frame) != len(frame): + raise ProtocolError("Short KISS write") + self.kiss_bytes += len(frame) + self.port.flush() + + def _next(self, deadline): + byte_count = 0 + while self.clock() < deadline: + if self.frames: + return self.frames.popleft() + # A fixed read(1024) waits out the serial timeout for every tiny + # hardware/TX_DONE reply. Block for one byte only when empty, then + # drain the bytes already available without waiting to fill a chunk. + data = self.port.read(min(getattr(self.port, "in_waiting", 0) or 1, 1024)) + byte_count += len(data) + if byte_count > 65536: + raise ProtocolError("KISS receive byte budget exceeded") + self.frames.extend(self.decoder.feed(data)) + if len(self.frames) > 256: + raise ProtocolError("KISS receive frame budget exceeded") + return None + + def _data_or_meta(self, frame): + if frame[0] in (0, 0x10): + if frame[0] >> 4 == self.logical_port: + if len(self.received) >= MAX_PENDING_RX: + raise ProtocolError("Received request backlog exceeded bound") + self.received.append(frame[1:]) + return True + if frame[:2] == b"\6\xf9" and len(frame) == 4: + return True + return False + + def request(self, command, payload=b"", *, size): + self._write(encode_frame(6, bytes((command,)) + payload)) + deadline = self.clock() + self.timeout + for _ in range(256): + frame = self._next(deadline) + if frame is None: + break + if self._data_or_meta(frame): + continue + if frame[:2] == bytes((6, command | 0x80)) and len(frame) == size + 2: + return frame[2:] + raise ProtocolError("Unexpected KISS hardware response") + raise ProtocolError("KISS hardware response deadline exceeded") + + def selected_radio(self): + if self.logical_port == 0: + # KISS has no primary temporary-lease setter/saved shadow: this + # getter synchronizes from the effective RadioProfiles.primary. + return PRIMARY_STRUCT.unpack(self.request(0x0B, size=10)) + values = SECONDARY_STRUCT.unpack(self.request(0x20, size=14)) + # GET_RADIO2_EXT is the SAVED profile, not an active tempradio2 lease. + # Reject every nonzero temporary tuple (even expiry at zero minutes) + # rather than authorizing RF from a misleading saved-frequency echo. + if any(self.request(0x1E, size=15)): + raise ProtocolError("Active temporary radio2 lease; refusing RF/pacing changes") + if values[4] != 2 or values[6] != 0: + raise ProtocolError("Radio2 must already be RX/TX MeshCore, not Ethernet/Meshtext") + return values + + def preflight(self, *, expected_frequency=None, expected_public_key=None, + require_lab_lock=False, serve=False, zero_pacing=False): + if zero_pacing and not serve: + raise ValueError("Pacing setters require explicit --serve") + guard = None + if require_lab_lock: + guard = struct.unpack(" 50: + raise ProtocolError("Selected frequency does not match explicit expectation") + if serve and expected_frequency is None: + raise ValueError("Serving requires an explicit expected frequency") + if require_lab_lock and (abs(radio[0] - GUARD_HZ) > 50 or radio[1:4] != (500000, 5, 5)): + raise ProtocolError("Lab requires909.5MHz/BW500/SF5/CR5") + self.radio = radio + self.authorized = serve + if zero_pacing: + for command, value in ((1, 0), (2, 255), (3, 1)): + self._write(encode_frame(command, bytes((value,)))) + return {"protocol": list(version), "identity_sha256": hashlib.sha256(public_key).hexdigest(), + "logical_port": self.logical_port, "radio": list(radio), "lab_lock_hz": guard, + "serve": serve, "zero_pacing": zero_pacing}, public_key + + def poll(self, timeout=.1): + if self.received: + return self.received.popleft() + deadline = self.clock() + timeout + for _ in range(256): + frame = self._next(deadline) + if frame is None: + return None + if not self._data_or_meta(frame): + raise ProtocolError("Unsolicited KISS control/TX_DONE without pending TX") + if self.received: + return self.received.popleft() + raise ProtocolError("KISS poll frame budget exceeded") + + def send_radio(self, radio): + if not self.authorized or self.radio is None: + raise ProtocolError("Probe-only session cannot transmit") + if self.selected_radio() != self.radio: + raise ProtocolError("Radio changed after preflight; refusing RF") + started = self.clock() + self._write(kiss_data(self.logical_port, radio)) + self.tx_started_frames += 1 + self.tx_started_bytes += len(radio) + deadline = self.clock() + self.timeout + for _ in range(256): + frame = self._next(deadline) + if frame is None: + break + if self._data_or_meta(frame): + continue + if frame[:2] == b"\6\xf8" and len(frame) == 3 and frame[2] in (0, 1): + self.tx_done_wait_seconds += self.clock() - started + if not frame[2]: + self.tx_failed += 1 + raise ProtocolError("Modem reported failed RF TX") + self.tx_frames += 1 + self.rf_bytes += len(radio) + if len(self.radio) == 7: + self.last_airtime_seconds = lora_airtime(len(radio), *self.radio[1:4], self.radio[5]) + self.estimated_airtime_seconds += self.last_airtime_seconds + else: + self.last_airtime_seconds = self.clock() - started + return + raise ProtocolError("Expected actual F8 TX_DONE, not queue acceptance") + raise ProtocolError("RF TX_DONE deadline exceeded; no automatic retransmission") + + +def lora_airtime(length, bandwidth_hz, sf, cr, preamble): + symbol = (1 << sf) / bandwidth_hz + low_rate = int(symbol >= .016) + payload_symbols = 8 + max(0, math.ceil((8 * length - 4 * sf + 28 + 16) / + (4 * (sf - 2 * low_rate))) * cr) + return (preamble + 4.25 + payload_symbols) * symbol + + +def send_batch(link, source, batch, *, sleep=time.sleep, clock=time.monotonic, deadline=float("inf")): + """Actual TX completion gates every next packet; no hidden RF retry.""" + def pause(seconds): + remaining = min(seconds, max(0, deadline - clock())) + while remaining > 0 and clock() < deadline: + interval = min(remaining, .5) + sleep(interval) + remaining -= interval + + previous_data = False + for reply in batch: + if clock() >= deadline: + return + packet = decode_radio(reply) + if packet is None: + raise ValueError("Invalid queued OTA response") + # Deployed receivers ask REQ_PROOF immediately after their final DATA. + # Leave room for that half-duplex request. Wide negotiation explicitly + # establishes a quiet DATA/PROOF train and needs no extra proof delay. + if packet.message[0] == PROOF and previous_data and not packet.wide: + request_airtime = (lora_airtime(9, *link.radio[1:4], link.radio[5]) + if len(link.radio) == 7 else link.last_airtime_seconds) + delay = max(.1, request_airtime * 1.5) + pause(delay) + if clock() >= deadline: + return + link.send_radio(reply) + source.note_sent(reply) + previous_data = packet.message[0] == DATA + quiet = source.pace.quiet(link.last_airtime_seconds) + if quiet: + pause(quiet) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", required=True) + parser.add_argument("--expect-serial", required=True) + parser.add_argument("--logical-port", required=True, type=int, choices=(0, 1)) + parser.add_argument("--expect-public-key") + parser.add_argument("--expected-frequency", type=float, help="MHz, checked before every RF frame") + parser.add_argument("--require-lab-lock", action="store_true") + parser.add_argument("--serve", action="store_true", help="explicitly permit requested OTA RF responses") + parser.add_argument("--zero-pacing", action="store_true") + parser.add_argument("--advertise", action="store_true", help="one initial discovery advert; requires --serve") + parser.add_argument("--mota", type=Path) + parser.add_argument("--duration", type=float, default=600) + parser.add_argument("--tx-timeout", type=float, default=10) + parser.add_argument("--minimum-pace", type=float, choices=(.25, .5, 1), default=.25, + help="adaptive floor; default0.25, starts at1.0") + args = parser.parse_args(argv) + if not 0 < args.duration <= 86400 or not 0 < args.tx_timeout <= 60: + parser.error("Use positive bounded duration<=86400s and TX timeout<=60s") + if args.serve and (not args.mota or args.expected_frequency is None): + parser.error("--serve requires --mota and --expected-frequency") + if (args.zero_pacing or args.advertise) and not args.serve: + parser.error("--zero-pacing/--advertise require --serve") + if args.expected_frequency is not None and (not math.isfinite(args.expected_frequency) or + not 100 <= args.expected_frequency <= 2500): + parser.error("Invalid explicit expected frequency") + public = None + if args.expect_public_key: + try: + public = bytes.fromhex(args.expect_public_key) + except ValueError: + parser.error("Expected public key must be64 hex characters") + if len(public) != 32: + parser.error("Expected public key must be64 hex characters") + port = None + source = link = None + report = {"success": False, "rf_frames": 0} + started = time.monotonic() + try: + # Validate/compress before owning USB or accepting live receiver requests. + source = MotaSource(args.mota.read_bytes(), b"\0" * 4, + minimum_pace=args.minimum_pace) if args.serve else None + if source: + print("Preparing cached raw DEFLATE with Google Zopfli1000 before opening KISS.", file=sys.stderr) + source.prepare() + port, info = open_verified_port(args.port, args.expect_serial) + link = KissLink(port, args.logical_port, timeout=args.tx_timeout) + safety, identity = link.preflight(expected_frequency=(round(args.expected_frequency * 1000000) + if args.expected_frequency is not None else None), expected_public_key=public, + require_lab_lock=args.require_lab_lock, serve=args.serve, zero_pacing=args.zero_pacing) + report.update(safety) + if source: + source.seeder_id = identity[:4] + if args.advertise: + link.send_radio(source.advert()) + until = time.monotonic() + args.duration + while time.monotonic() < until: + incoming = link.poll() + if incoming is None: + continue + batch = source.replies(incoming) + send_batch(link, source, batch, deadline=until) + report["serving_elapsed_seconds"] = args.duration - max(0, until - time.monotonic()) + report.update({"success": True, "usb_serial": info.serial_number}) + except (RuntimeError, OSError, ValueError) as error: + report["error"] = str(error) + finally: + if port is not None: + port.close() + if source is not None: + report["source"] = {"manifest_id": source.mid.hex(), "block_size": source.manifest.block_size, + "blocks": source.manifest.block_count, "compression": "Google Zopfli1000 raw DEFLATE; smaller only", + "pace": source.pace.report(), **source.stats} + if link is not None: + report.update({"rf_frames": link.tx_frames, "rf_bytes": link.rf_bytes, + "rf_frames_started": link.tx_started_frames, "rf_bytes_started": link.tx_started_bytes, + "kiss_bytes_written": link.kiss_bytes, "tx_failed": link.tx_failed, + "tx_done_wait_seconds": link.tx_done_wait_seconds, + "estimated_rf_airtime_seconds": link.estimated_airtime_seconds if args.logical_port == 1 else None, + "airtime_is_estimate": True, "local_tx_done_is_delivery_ack": False}) + report["elapsed_seconds"] = time.monotonic() - started + print(json.dumps(report, sort_keys=True), flush=True) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main())