From 0be4c7488c2b38d3cf69934b68a29f83a213c884 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 13 Oct 2026 01:23:46 -0700 Subject: [PATCH] Add bounded Morse receive and negotiated RS232 autosleep Add native narrow-band CW capture, ACK-first remote sessions, retained LoRa-readable pages, checked restoration and transport-safe progress. Add stock-compatible negotiated heartbeat/autosleep and recovery. Include host regressions and identity-bound HIL runners; keep unverified faster tuning HIL-only and document outstanding hardware validation. --- .github/workflows/run-unit-tests.yml | 11 + docs/carrier_wave.md | 178 +++++ docs/cli_commands.md | 88 +++ docs/test-results/morse_rx_hil_2026-10-12.md | 120 +++ examples/companion_radio/MyMesh.cpp | 170 ++++- examples/companion_radio/MyMesh.h | 27 +- examples/companion_radio/main.cpp | 33 +- examples/companion_radio/ui-new/UITask.cpp | 8 +- examples/companion_radio/ui-orig/UITask.cpp | 4 +- examples/companion_radio/ui-tiny/UITask.cpp | 8 +- examples/simple_repeater/MyMesh.cpp | 57 +- examples/simple_repeater/MyMesh.h | 70 +- examples/simple_repeater/UITask.cpp | 4 +- examples/simple_repeater/main.cpp | 7 +- examples/simple_room_server/MyMesh.cpp | 41 +- examples/simple_room_server/MyMesh.h | 2 - examples/simple_room_server/UITask.cpp | 4 +- examples/simple_room_server/main.cpp | 7 +- examples/simple_sensor/SensorMesh.cpp | 40 +- examples/simple_sensor/SensorMesh.h | 4 +- examples/simple_sensor/Tracker.cpp | 2 + examples/simple_sensor/UITask.cpp | 4 +- examples/simple_sensor/main.cpp | 7 +- scripts/check_firmware_ram.py | 12 +- src/Dispatcher.h | 43 ++ src/helpers/BaseChatMesh.cpp | 9 +- src/helpers/BaseChatMesh.h | 4 + src/helpers/CarrierWaveCLI.h | 7 + src/helpers/CommonCLI.cpp | 160 +++- src/helpers/CommonCLI.h | 16 + src/helpers/MorseCLI.h | 430 +++++++++++ src/helpers/MorseLoopPolicy.h | 15 + src/helpers/MorseReplyHandoff.h | 159 ++++ src/helpers/MorseRx.h | 421 +++++++++++ src/helpers/MorseSettings.cpp | 130 ++++ src/helpers/MorseSettings.h | 75 ++ src/helpers/MorseTx.h | 190 +++++ src/helpers/bridges/RS232Bridge.cpp | 309 +++++++- src/helpers/bridges/RS232Bridge.h | 58 ++ src/helpers/bridges/RS232Heartbeat.h | 215 ++++++ src/helpers/bridges/RS232PowerSettings.h | 52 ++ src/helpers/radiolib/CustomLLCC68.h | 10 + src/helpers/radiolib/CustomLLCC68Wrapper.h | 32 +- src/helpers/radiolib/CustomLR1110.h | 33 + src/helpers/radiolib/CustomLR1110Wrapper.h | 37 + src/helpers/radiolib/CustomLR1121.h | 28 +- src/helpers/radiolib/CustomLR1121Wrapper.h | 49 ++ src/helpers/radiolib/CustomLR2021.h | 52 +- src/helpers/radiolib/CustomLR2021Wrapper.h | 54 +- src/helpers/radiolib/CustomSTM32WLx.h | 12 +- src/helpers/radiolib/CustomSTM32WLxWrapper.h | 47 ++ src/helpers/radiolib/CustomSX1262.h | 15 + src/helpers/radiolib/CustomSX1262Wrapper.h | 27 + src/helpers/radiolib/CustomSX1268.h | 10 + src/helpers/radiolib/CustomSX1268Wrapper.h | 35 +- src/helpers/radiolib/CustomSX1276.h | 32 + src/helpers/radiolib/CustomSX1276Wrapper.h | 40 +- src/helpers/radiolib/LR11x0MorseRfSwitch.h | 37 + src/helpers/radiolib/RadioLibWrappers.cpp | 346 ++++++++- src/helpers/radiolib/RadioLibWrappers.h | 99 ++- src/helpers/radiolib/SX126xMorseRamp.h | 61 ++ src/helpers/radiolib/SX126xMorseReceiver.h | 72 ++ src/helpers/radiolib/SX126xMorseRssi.h | 17 + test/fixtures/advert_cache/test.cpp | 4 + test/fixtures/companion_lost_reply.cpp | 6 + test/fixtures/morse_chip_hooks.cpp | 170 +++++ test/fixtures/morse_cli.cpp | 223 ++++++ test/fixtures/morse_driver.cpp | 269 +++++++ test/fixtures/morse_encoder.cpp | 141 ++++ test/fixtures/morse_fem_guard.cpp | 96 +++ test/fixtures/morse_main_loop.cpp | 115 +++ test/fixtures/morse_reply_companion.cpp | 60 ++ test/fixtures/morse_reply_handoff.cpp | 190 +++++ test/fixtures/morse_reply_loop.cpp | 60 ++ test/fixtures/morse_reply_prefix.cpp | 21 + test/fixtures/morse_reply_role_callbacks.cpp | 22 + test/fixtures/morse_rx_admission.cpp | 78 ++ test/fixtures/morse_rx_chip_preparation.cpp | 55 ++ test/fixtures/morse_rx_chip_rssi.cpp | 97 +++ test/fixtures/morse_rx_cli.cpp | 135 ++++ test/fixtures/morse_rx_decay_shoulder.cpp | 38 + test/fixtures/morse_rx_decoder.cpp | 227 ++++++ test/fixtures/morse_rx_driver.cpp | 221 ++++++ test/fixtures/morse_rx_fast_off.cpp | 41 ++ test/fixtures/morse_rx_floor_step.cpp | 63 ++ test/fixtures/morse_rx_hil_driver.cpp | 32 + test/fixtures/morse_rx_mark_fade.cpp | 78 ++ test/fixtures/morse_rx_native.cpp | 219 ++++++ test/fixtures/morse_rx_reuse_admission.cpp | 40 + test/fixtures/morse_rx_sample_cadence.cpp | 25 + test/fixtures/morse_rx_terminal.cpp | 137 ++++ test/fixtures/morse_settings.cpp | 208 ++++++ test/fixtures/morse_trace_cli.cpp | 89 +++ .../remote_cli_command_identity/test.cpp | 55 ++ test/fixtures/room_client_paths.cpp | 10 + test/fixtures/room_receive_admission/test.cpp | 6 +- .../fixtures/rs232_heartbeat/HardwareSerial.h | 47 ++ test/fixtures/rs232_heartbeat/Stream.h | 22 + .../helpers/bridges/BridgeBase.h | 77 ++ test/fixtures/rs232_heartbeat/main.cpp | 693 ++++++++++++++++++ test/fixtures/rs232_heartbeat/stock_parser.h | 71 ++ .../rs232_heartbeat/upstream_stock_parser.h | 72 ++ test/fixtures/rs232_mqtt_runtime.cpp | 18 + test/fixtures/rs232_power_settings_cli.cpp | 109 +++ test/fixtures/rs232_power_settings_prefs.cpp | 126 ++++ test/fixtures/rs232_runtime/json.cpp | 4 + test/fixtures/rs232_runtime/main.cpp | 23 +- test/fixtures/rs232_runtime/prefs.cpp | 7 +- test/fixtures/sensor_tracker_client.h | 7 +- test/morse_rx_floor_step_experiment.py | 185 +++++ test/test_carrier_wave.py | 204 +++++- test/test_cli_settings_contract.py | 1 + test/test_companion_fleet_control.py | 3 +- ...est_companion_primary_radio_persistence.py | 8 + test/test_esp32_advert_cache.py | 4 +- test/test_firmware_ram.py | 14 +- test/test_fleet_radio_schedules.py | 2 + test/test_fleet_role_wiring.py | 6 + test/test_local_cli_access.py | 15 + test/test_morse_auth.py | 143 ++++ test/test_morse_cli.py | 89 +++ test/test_morse_loop_policy.py | 272 +++++++ test/test_morse_remote.py | 576 +++++++++++++++ test/test_morse_reply_handoff.py | 115 +++ test/test_morse_rx.py | 321 ++++++++ test/test_morse_terminal_input.py | 165 +++++ test/test_morse_tx.py | 126 ++++ test/test_nrf52_usb_client.py | 1 + test/test_ota_channel_persistence.py | 1 + test/test_radio_interrupt_recovery.py | 7 + test/test_radio_reply_mutation.py | 6 + test/test_remote_cli_command_identity.py | 9 +- .../test_repeater_radio_timing_integration.py | 3 + test/test_room_login_integration.py | 11 + test/test_rs232_driver_lifecycle.py | 27 +- test/test_rs232_heartbeat.py | 76 ++ test/test_rs232_heartbeat_owner.py | 325 ++++++++ test/test_rs232_mqtt_runtime.py | 2 + test/test_rs232_power_settings.py | 100 +++ test/test_rs232_runtime.py | 3 +- ...test_temp_radio_reply_delivery_contract.py | 3 +- test/test_transport_filter_wiring.py | 57 +- tools/hil/morse_bench.ini | 79 ++ tools/hil/morse_bench.py | 333 +++++++++ tools/hil/morse_nrf_flash.sh | 73 ++ tools/hil/morse_pi_inventory.py | 68 ++ tools/hil/morse_remote.py | 487 ++++++++++++ variants/muzi_base/target.cpp | 3 + variants/rak3x72/target.cpp | 1 + variants/tiny_relay/target.cpp | 1 + variants/wio-e5-dev/target.cpp | 1 + variants/wio-e5-mini/target.cpp | 1 + 152 files changed, 12631 insertions(+), 149 deletions(-) create mode 100644 docs/test-results/morse_rx_hil_2026-10-12.md create mode 100644 src/helpers/MorseCLI.h create mode 100644 src/helpers/MorseLoopPolicy.h create mode 100644 src/helpers/MorseReplyHandoff.h create mode 100644 src/helpers/MorseRx.h create mode 100644 src/helpers/MorseSettings.cpp create mode 100644 src/helpers/MorseSettings.h create mode 100644 src/helpers/MorseTx.h create mode 100644 src/helpers/bridges/RS232Heartbeat.h create mode 100644 src/helpers/bridges/RS232PowerSettings.h create mode 100644 src/helpers/radiolib/LR11x0MorseRfSwitch.h create mode 100644 src/helpers/radiolib/SX126xMorseRamp.h create mode 100644 src/helpers/radiolib/SX126xMorseReceiver.h create mode 100644 src/helpers/radiolib/SX126xMorseRssi.h create mode 100644 test/fixtures/morse_chip_hooks.cpp create mode 100644 test/fixtures/morse_cli.cpp create mode 100644 test/fixtures/morse_driver.cpp create mode 100644 test/fixtures/morse_encoder.cpp create mode 100644 test/fixtures/morse_fem_guard.cpp create mode 100644 test/fixtures/morse_main_loop.cpp create mode 100644 test/fixtures/morse_reply_companion.cpp create mode 100644 test/fixtures/morse_reply_handoff.cpp create mode 100644 test/fixtures/morse_reply_loop.cpp create mode 100644 test/fixtures/morse_reply_prefix.cpp create mode 100644 test/fixtures/morse_reply_role_callbacks.cpp create mode 100644 test/fixtures/morse_rx_admission.cpp create mode 100644 test/fixtures/morse_rx_chip_preparation.cpp create mode 100644 test/fixtures/morse_rx_chip_rssi.cpp create mode 100644 test/fixtures/morse_rx_cli.cpp create mode 100644 test/fixtures/morse_rx_decay_shoulder.cpp create mode 100644 test/fixtures/morse_rx_decoder.cpp create mode 100644 test/fixtures/morse_rx_driver.cpp create mode 100644 test/fixtures/morse_rx_fast_off.cpp create mode 100644 test/fixtures/morse_rx_floor_step.cpp create mode 100644 test/fixtures/morse_rx_hil_driver.cpp create mode 100644 test/fixtures/morse_rx_mark_fade.cpp create mode 100644 test/fixtures/morse_rx_native.cpp create mode 100644 test/fixtures/morse_rx_reuse_admission.cpp create mode 100644 test/fixtures/morse_rx_sample_cadence.cpp create mode 100644 test/fixtures/morse_rx_terminal.cpp create mode 100644 test/fixtures/morse_settings.cpp create mode 100644 test/fixtures/morse_trace_cli.cpp create mode 100644 test/fixtures/rs232_heartbeat/HardwareSerial.h create mode 100644 test/fixtures/rs232_heartbeat/Stream.h create mode 100644 test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h create mode 100644 test/fixtures/rs232_heartbeat/main.cpp create mode 100644 test/fixtures/rs232_heartbeat/stock_parser.h create mode 100644 test/fixtures/rs232_heartbeat/upstream_stock_parser.h create mode 100644 test/fixtures/rs232_power_settings_cli.cpp create mode 100644 test/fixtures/rs232_power_settings_prefs.cpp create mode 100644 test/morse_rx_floor_step_experiment.py create mode 100644 test/test_morse_auth.py create mode 100644 test/test_morse_cli.py create mode 100644 test/test_morse_loop_policy.py create mode 100644 test/test_morse_remote.py create mode 100644 test/test_morse_reply_handoff.py create mode 100644 test/test_morse_rx.py create mode 100644 test/test_morse_terminal_input.py create mode 100644 test/test_morse_tx.py create mode 100644 test/test_rs232_heartbeat.py create mode 100644 test/test_rs232_heartbeat_owner.py create mode 100644 test/test_rs232_power_settings.py create mode 100644 tools/hil/morse_bench.ini create mode 100644 tools/hil/morse_bench.py create mode 100644 tools/hil/morse_nrf_flash.sh create mode 100644 tools/hil/morse_pi_inventory.py create mode 100644 tools/hil/morse_remote.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6ea5e5f0d..4b56eb794 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -222,6 +222,9 @@ jobs: python3 -B test/test_rs232_runtime.py -v python3 -B test/test_rs232_mqtt_runtime.py -v python3 -B test/test_rs232_driver_lifecycle.py -v + python3 -B test/test_rs232_heartbeat.py -v + python3 -B test/test_rs232_heartbeat_owner.py -v + python3 -B test/test_rs232_power_settings.py -v - name: Verify saved repeat-off trace exception and relay guards run: | @@ -332,6 +335,14 @@ jobs: - name: Verify temporary-radio timing and exact reply delivery run: | python3 -B test/test_carrier_wave.py + python3 -B test/test_morse_tx.py -v + python3 -B test/test_morse_rx.py -v + python3 -B test/test_morse_cli.py -v + python3 -B test/test_morse_auth.py -v + python3 -B test/test_morse_reply_handoff.py -v + python3 -B test/test_morse_remote.py -v + python3 -B test/test_morse_loop_policy.py -v + python3 -B test/test_morse_terminal_input.py -v python3 -B test/test_radio_profile_cli.py python3 -B test/test_radio_profile_spiffs_missing.py -v python3 -B test/test_radio_reply_mutation.py diff --git a/docs/carrier_wave.md b/docs/carrier_wave.md index a2925095c..e97318d52 100644 --- a/docs/carrier_wave.md +++ b/docs/carrier_wave.md @@ -4,6 +4,184 @@ # Carrier-wave RF checks +## TX-only Morse messages + +Morse TX mode keys an unmodulated carrier into dots and dashes. It does not send +LoRa packets or automatically retransmit channel chat or +private messages. **The default is 902.100 MHz at 25 words per minute (WPM).** +Nothing transmits until an explicit `morse send` command is accepted. + +| Command | Action | +| --- | --- | +| `morse send TEST DE N0CALL` | Send the text once using the saved Morse frequency and speed. | +| `morse stop` | Stop Morse immediately and restore packet reception. | +| `get morse` | Show idle/active state, frequency, WPM and remaining time. | +| `get morse.freq` | Show the Morse TX frequency in MHz. | +| `set morse.freq 902.100` | Save the separate Morse TX frequency, rounded to 1 kHz. | +| `get morse.wpm` | Show the saved sending speed. | +| `set morse.wpm 12` | Slow transmission for easier hand decoding; supported range is 1–60 WPM. | + +The default 25 WPM can be changed; for an easy-to-copy test, set 10–12 WPM. +Standard timing uses one unit for a dot, three for a dash, one between elements, +three between letters and seven between words, with a dot of `1200 / WPM` +milliseconds. Letters are sent case-insensitively. Messages are bounded to +80 ASCII characters, must contain supported Morse characters, and must fit +within 180 seconds at the chosen speed. Supported punctuation is +`. , ? ' ! / ( ) & : ; = + - _ " $ @`; tabs, newlines and non-ASCII text are +rejected. Invalid messages are rejected before +the transmitter is keyed. There is no automatic callsign insertion. + +The Morse frequency is independent of `radio`, `tempradio`, `radio2` and +`tempradio2`. Before sending, the firmware rejects a frequency that overlaps +either packet-radio profile, including a guard outside its occupied bandwidth. +It does **not** automatically find another channel or change your mesh settings. +Choose another `morse.freq` if 902.100 MHz overlaps either profile. A frequency +accepted as a saved value must still be supported by the actual radio at send +time. Morse frequency/speed changes are rejected while Morse or CW is active. + +There is one physical transceiver: packet reception and transmission pause +throughout the message, including the spaces between dots and dashes. Normal +receive/profile scanning is restored afterward. Use local USB/BLE for immediate +status and stopping; a reply to a remote LoRa CLI command waits until the +message finishes. Changing either packet profile during Morse can abort it to +preserve the frequency-separation safety checks. + +An unmodulated carrier has no selectable LoRa bandwidth. This uses native CW +on SX1262/SX1268/LLCC68/STM32WLx and LR1110/LR1121/LR2021 radios, and +zero-deviation FSK CW on SX1276-family radios with reset/recovery support, rather +than pretending the narrowest receive filter sets the transmitted bandwidth. +Morse sidebands depend on keying speed and transmitter edges. This is not a +guarantee of a particular occupied RF bandwidth or freedom from interference. +Unsupported radios reject the command without beginning transmission. +The chips' configured TX ramps use 3400 microseconds on the SX126x-family and +304 microseconds on the LR-family to reduce keying splatter. These are driver +settings, not measured occupied-bandwidth results. Direct FEM gain changes are +blocked while Morse owns the radio. + +Frequency and speed are saved in a separate checked `/morse_prefs` file; +identity, contacts and existing radio preferences are unchanged. An active +message is never restored after reboot. TX uses the board's existing `set tx` +power setting and hardware power limits. +Unreadable or corrupt saved Morse settings without a usable backup block +sending rather than silently +transmitting on the default frequency. After storage recovers, verify and save +`morse.freq` (and the desired WPM); a verified save makes sending available again. + +Use an appropriate antenna or dummy load, lawful frequency/power and any +required operator identification. The default frequency is a convenience, not +permission to transmit or a claim that the channel is clear. Check the local +band plan and listen first. + +## Timed Morse reception and retained captures + +Supported radios can temporarily listen for keyed carriers and decode the +power envelope into text. This is a separate manual session, not another +MeshCore packet channel or an authenticated sender. Nothing decoded is +automatically transmitted, relayed or inserted into a channel/DM inbox. + +| Command | Action | +| --- | --- | +| `morse listen 1` | Listen for one minute at the saved `morse.freq`, using `morse.wpm` as the timing hint. | +| `morse listen 0.5` | Listen for 30 seconds. Fractions are allowed. | +| `morse listen 5` | Listen for the maximum five minutes, then restore packet reception. | +| `morse stop` | End TX or listening early. A received capture is retained. | +| `get morse` | Show TX/RX state, actual RX filter bandwidth, remaining time and capture length/truncation. | +| `morse stats` | Show retained receiver sample counts, noise/min/max/last power, timing unit, largest sample gap and read failures. | +| `morse read` | Read the first retained capture page. | +| `morse read 128` | Read beginning at character offset 128; use the offset returned by your previous page. | +| `morse clear` | Clear the retained capture when no Morse session is active. | + +Listening is bounded to **1–300 seconds** for every role. Keep the first +0.5 seconds quiet while the receiver learns its noise floor; start the sending +radio at least one second after listening begins. Start with matching WPM on +the sender and receiver. Noise, fading, interference and timing mismatch can +produce errors; decoded text has no sender identity or integrity check. + +The receiver uses a continuous native FSK receive filter and live received +signal strength (RSSI) to follow the carrier's on/off envelope. It does not +transmit or require a packet preamble, sync word, CRC or direct-data pin: + +| Radio family | Current Morse RX filter | +| --- | ---: | +| SX1276-family | 2.6 kHz | +| SX1262/SX1268/STM32WLx, LLCC68 | 4.8 kHz | +| LR1110/LR1121, LR2021 | 4.8 kHz | + +Small-flash STM32 production builds disable Morse reception by default +(`MESH_ENABLE_MORSE_RX=0`) because the receiver does not fit alongside their +existing features. Morse TX, frequency/WPM settings and stopping remain +available. Their listen/read/stats/clear commands return an unsupported-RX +error. The STM32 filter above describes an explicitly enabled custom build, +not RX support in those default production images. + +`get morse` reports the actual configured bandwidth; this is not a measured +selectivity or decoder-speed guarantee. Unsupported hardware rejects listening +without changing the radio. A board also needs a proven complete LoRa-modem +restoration path before native FSK RX is enabled. Narrow filters require +sufficiently aligned oscillator frequencies. Passive listening may use either packet profile's +frequency because it does not transmit; TX overlap protections still apply. +FEM and saved Morse settings cannot be changed while a session owns the radio. + +Morse sessions defer periodic sensor/GPS processing, screen redraws and +background storage work so they do not interrupt envelope timing. Pending +work and its deadlines are retained and resume when the session ends. Buttons, +command connections, watchdogs and battery shutdown checks remain active. +`morse stats` reports `gap` in milliseconds: the largest observed gap between +receiver samples, including unavoidable safety work. Large gaps can limit +reliable speed even when the RF signal is strong. + +Repeater, Room, Sensor and Companion roles retain a bounded **4096-character +RAM capture** after their normal packet receiver resumes. A successful new +listening session replaces it; a failed start does not. `morse clear` or a +reboot removes it. If the capture fills, listening stops early and +`truncated=1`/capture-quota status is shown instead of continuing to discard +incoming text. This does not change node keys, ACLs, stored radio +settings or other files. + +Capture replies fit one CLI response and begin with a page header such as +`> 0000/0250 next=0125 truncated=0 ` followed by text. Send `morse read 125` to +continue at the returned offset; `next=END` means the last page. Exact page +length depends on the requesting transport's reply capacity, so follow `next` +rather than assuming a fixed page size. Captures remain readable until you +start another listening session or clear them. + +A Full Companion's owned buffered USB ASCII terminal or browser terminal shows +decoded progress as `[Morse RX] ...`, paused while USB input is being edited or +an earlier reply is draining. Complete records use that terminal's existing +ordered output queue. Raw UART/HWCDC/TCP terminals use `get morse`/`morse read` +instead of unsolicited progress. Binary Companion USB/BLE frames never receive +raw text. +Companion apps and other clients can request the same capture through their +CLI using `morse read`; it is not presented as an authenticated chat message. +There is no automatic LCD/inbox injection. + +One transceiver cannot listen for Morse and MeshCore LoRa simultaneously. +During listening, normal packet reception/transmission is paused. An authorized +remote `morse listen` first queues its reply on the existing packet-radio +settings and starts only after the exact reply copies finish transmitting, +with at least one successful transmission. It does not infer that the remote +client received the reply. Failed queue admission/all-copy failure, permission +revocation, changed Morse settings or a 30-second handoff deadline cancel the +request without starting RX. A briefly busy radio can retry admission for up +to 10 seconds after reply transmission, within that same handoff deadline. +For remote captures, send `morse listen 1`, start the Morse sender after the +queued reply and initial quiet learning interval, wait beyond the selected +minute (plus any admission delay), then request +`get morse` and `morse read` over LoRa. Use a local console to stop early. +Hardware recovery failures hold exclusive ownership until the receiver can +be safely restored; `get morse` reports recovery pending. + +Lab images compiled with `MESH_MORSE_HIL=1` additionally provide +`morse trace [offset]`. A page begins `> offset/length t=total n=next x=wrapped` +and contains up to eight `elapsed_ms,rssi_dbm,flags;` records. `n=END` ends the +retained window; `x=1` means earlier entries were overwritten. Offsets address +the oldest of the latest 512 retained records; retrieve after stopping or +check that `t` stays unchanged between pages. Flags are raw key 1, committed +key 2, debounce candidate 4 and noise learning 8. This diagnostic command and +its trace storage are absent from ordinary production builds. + +## Continuous carrier checks + `cw` and `cw2` transmit a continuous, unmodulated carrier at the selected profile's frequency and the configured TX power. They are available in the shared ASCII CLI on SX1276-family, SX1262-family, and LR1110 builds, including repeaters, diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b99edbaf8..ee1655cc8 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -14,6 +14,26 @@ Chat remains plain text; NodeInfo is sent only when that command is requested. See [carrier-wave RF checks](carrier_wave.md) for `cw on [seconds]`, `cw2 on [seconds]`, and stopping a carrier with `off`. +For keyed Morse TX, use `morse send TEST DE N0CALL` and `morse stop`. +Defaults are **902.100 MHz and 25 WPM**; `set morse.freq ` and +`set morse.wpm <1–60>` save separate frequency/speed settings. +Small-flash STM32 production builds retain Morse TX but do not include Morse +RX by default; listen/read/stats/clear return an unsupported-RX error there. +`get morse` shows status. See [TX-only Morse messages](carrier_wave.md#tx-only-morse-messages) +for supported characters, limits, overlap checks and the temporary pause in +packet reception. Nothing transmits automatically. + +For timed Morse RX, use `morse listen ` (fractions allowed, 1 second +through 5 minutes). `morse stop` ends TX or RX. All roles retain the bounded +capture in RAM for `morse read [offset]`; follow each page's `next` value until +`next=END`. `morse clear` removes it when idle. A new successful session or +reboot replaces/removes the capture. Full Companion ASCII terminals show +decoded progress; binary clients request it through the CLI, without adding +unidentified text to authenticated channel/chat messages. Normal LoRa is paused +during listening, so a remote administrator must wait for the selected session +to end before reading the capture over LoRa. See [timed reception](carrier_wave.md#timed-morse-reception-and-retained-captures) +for arming time, real chip bandwidths and truncation behavior. + For copy/paste on/off recipes and the differences from Full Companion, see [feature switches by role](role_feature_switches.md). The [USB web console](https://flasher.meshcore.io/console) opens the default ASCII @@ -2370,6 +2390,8 @@ on battery. `set powersaving on` still enables sensor/GPS power-saving policies; the CPU continues normal idle yielding while keeping the console available. A running MQTT, RS232 or ESP-NOW bridge keeps infrastructure awake even when `get powersaving` reports `on`; that readback alone does not prove MCU sleep. +Negotiated RS232 standby can stop an unresponsive UART owner without changing +its saved enabled flag; see `rs232.autosleep` below. Other sleep blockers still apply. Adding UART wake alone would still discard the characters that trigger wake, so it cannot preserve an unmodified web console's first command. See the [ESP-IDF UART wake documentation](https://docs.espressif.com/projects/esp-idf/en/v4.4.7/esp32s3/api-reference/system/sleep_modes.html#uart-wakeup-light-sleep-only). @@ -4853,6 +4875,72 @@ MQTT preferences and retains explicit settings across reboots. --- +#### RS232 heartbeat and automatic standby + +Available on repeater builds with an RS232 bridge. This is a UART-local control +exchange, not an advert or a LoRa packet. Room builds without RS232 are unaffected. + +**Usage:** + +- `get rs232.status` +- `get rs232.heartbeat` +- `set rs232.heartbeat on|off` +- `get rs232.autosleep` +- `set rs232.autosleep on|off|force` +- `get rs232.idle` +- `set rs232.idle ` (60 through 3600; default 300) + +Heartbeat and negotiated automatic standby are **on by default**, including +upgrades. An active UART pings every 30 seconds (52 bytes per ping). +Stock bridges ignore that traffic and still receive the original data frames; +they are not automatically put to sleep: standby is armed only after a fresh +heartbeat reply proves support. Fresh, session/challenge-matched replies +prove a bidirectional peer; +ordinary valid incoming data can keep a legacy link awake but cannot prove +heartbeat support. The status separates saved intent, actual UART state, +standby/probe phase, and confirmed versus legacy/unknown peer liveness. + +Enable on both updated peers: + +```text +set rs232.enabled on +set rs232.heartbeat on +set powersaving on +set rs232.idle 300 +set rs232.autosleep on +get rs232.status +``` + +With heartbeat, autosleep, and global powersaving all on and the peer's heartbeat +support confirmed, when no peer responds for the idle interval the UART is soft-stopped while +`rs232.enabled` stays `on`. It wakes after 30 seconds for a 2-second probe; +every eighth probe listens for 40 seconds. That longer window spans the other +side's sleep interval, so two sleeping updated peers can reconnect without +manual intervention (normally within about five minutes with working wiring +and timely loop servicing). Healthy peers remain running; this is dead-link +power saving, not an idle-data duty cycle. + +Stock receivers ignore these control frames; they are deliberately invalid +zero-length stock frames with an ASCII-only remainder. They do not allocate a +mesh packet or enter LoRa forwarding. Stock peers do **not** answer heartbeats, +so default negotiated standby never arms on a stock-only link. Valid legacy +data after heartbeat replies cease also disarms negotiated standby, allowing +a replacement stock peer to stay awake. Use `set rs232.autosleep force` only +to sleep an absent/unconfirmed UART peer; this explicitly bypasses stock +protection and can miss one-off stock packets. +If a previously confirmed updated peer is silently replaced with stock while +the local UART is already sleeping, its first packet can be missed before +replacement is detected. Set autosleep off before that change, or restart the +local bridge to clear the old negotiated session. +While the UART is stopped it cannot receive a wake byte; mesh traffic +during standby is not queued for later UART delivery. Turning autosleep or +global powersaving off resumes the saved-enabled UART; `rs232.enabled off` +remains an explicit off and never automatically wakes. +`rs232.heartbeat off` stops initiating heartbeats and prevents standby. An +updated active peer can still answer a valid incoming ping in passive mode. + +--- + #### Add a delay to packets routed through this bridge **Usage:** - `get bridge.delay` diff --git a/docs/test-results/morse_rx_hil_2026-10-12.md b/docs/test-results/morse_rx_hil_2026-10-12.md new file mode 100644 index 000000000..0f81f5649 --- /dev/null +++ b/docs/test-results/morse_rx_hil_2026-10-12.md @@ -0,0 +1,120 @@ +# Morse receive hardware checkpoint - 12 October 2026 + +Status: **incomplete hardware validation**. The release default remains +25 WPM / 902.100 MHz and the public limit remains 60 WPM. Experimental +255-WPM builds are not a validated release limit. + +## Implementation and host verification + +- Companion, repeater, room and sensor command paths support bounded receive + sessions, a volatile 4096-character capture, paged reads and checked LoRa + recovery. Receive duration is one second through five minutes. +- Remote receive starts only after its acknowledgement has actually transmitted. +- Fresh focused Morse run: 56/56 passed. Additional high-speed HIL OFF-debounce + tests and decoder matrix: 3/3 passed, including 188 synthetic waveform cases. +- Radio/role regression run: 390/390. RS232 overlap: 19/19. USB/BLE/logging: + 222 test methods with no functional failures; eight optional DFU emulation + subcases skipped because the host lacks Unicorn. +- Build checks passed for the tested nRF52 companions/repeater, ESP32 room and + sensor, and compact STM32 builds. Compact STM32 retains TX but excludes RX + by default to respect its existing flash boundary. + +## Hardware identities and isolation + +The Mercerwood Pi was reached at `100.78.195.38`. Only identity-verified +application DFU packages were written through the existing `mcfirmware.sh` +path. No bootloader or SoftDevice was replaced, no services were stopped, +and the Pi was not rebooted by this test. + +| Board | USB chip serial | +| --- | --- | +| RAK4631 | `9AB3B64C641BA927` | +| Heltec T096 | `651F8E496197F882` | +| Heltec MeshPocket | `93DA83ADF7B2092B` | + +The RAK3401 service radio and MeshTower were left alone. + +Early tests put CW at 909.5 MHz and packet profiles elsewhere. This was +corrected: the requested CW default is **902.100 MHz**, separate from the +LoRa lab control tuple **909.5 MHz / 500 kHz / SF5 / CR5**. Results from the +early layout are not proof of packet-spectrum isolation. + +`set radio` only saves Companion primary settings. The test runners now use +`set tempradio` and poll the exact active tuple; saved or pending settings +do not satisfy the check. The USB-only HIL recipes explicitly include the +existing temporary-radio capability. Names and saved primary settings are +not changed by the corrected runners. + +## Observations, not a new maximum + +- With experimental warm-XOSC TX, 800-us PA ramp, RSSI window 2 and 1-ms + polling, MeshPocket-to-RAK decoded the alphabet/digit, word-spacing and + punctuation messages correctly in one 120-WPM sweep at 902.100 MHz. +- Reverse-direction captures contained additional noise characters. At + 200/240 WPM, there were real character errors in both directions. Strict + comparison includes extra characters; matching only a substring is not + accepted. +- Keeping ordinary FSK packet preamble/sync/CRC did not establish a reliable + faster limit. That experiment is not enabled in production. +- A no-transmitter control can produce extra characters; the source of those + RSSI excursions is not established. Noise, another carrier and a radio + measurement artifact must not be conflated without further evidence. +- Sample-gap diagnostics confirmed mostly 1/2-ms cadence after noncritical + sensor, display and filesystem work was deferred during capture. The + protection of voltage cutoffs, GPIO expiry, watchdogs, transport input and + stop commands remains active. +- Integer-millisecond timing maps 219-255 WPM to the same nominal five-ms dot. + SPI/PA overhead further slows the envelope. USB-observed TX duration is + not a precision measurement of physical on-air WPM. + +## Remote acknowledgement and retained-capture evidence + +A 30-second LoRa-controlled test physically demonstrated: + +1. A six-minute request was rejected in 0.682 seconds. +2. The valid request was acknowledged in 0.684 seconds before RX started. +3. The timed receiver returned to LoRa after 30.889 seconds as observed by + the host. +4. All 34 retained characters read locally were returned byte-for-byte in + the remote LoRa read. + +The decoded RF text was not equal to the transmitted message, so this run +failed overall. It used the early shared CW/control frequency and is not a +clean decoder validation. The full five-minute and multiple-page remote +tests remain outstanding. + +## Why hardware work stopped + +At approximately 22:43 Pacific the RAK USB connection disappeared during the +zero-gate isolated-frequency sweep. It subsequently reported +`v1.17.1.10-halo-keymind-cascade-preview3-9ce2a076`, with `get morse` unknown, +instead of the just-verified Morse application. Another concurrent job had +flashed the shared RAK. This is not evidence of a Morse firmware crash. +The affected sweep is incomplete and must not be counted as a completed +speed test. Hardware writes were stopped for user coordination. + +## Ready-to-resume experiment + +An isolated HIL-only `MESH_MORSE_HIL_FAST_OFF_DEBOUNCE=1` experiment keeps +ON confirmation at two ms and permits OFF confirmation at one ms for fast +nominal dots. Default/production behavior, capture size and low-speed timing +are unchanged. Synthetic tests pass; physical results are still pending. + +The following application-only packages were compiled and staged on the Pi +but **not installed**: + +| Package | SHA-256 | +| --- | --- | +| `RAK_4631_companion_radio_usb-morse-fastoff.zip` | `0340b6135c36c7d6cb61be45ac28555ead5a2f6529feb3702466b284d2abd204` | +| `Mesh_pocket_companion_radio_usb-morse-fastoff.zip` | `e0d94389310244655136564a1dbd55d5e4d4d18a17bbae6e1c7ae018f5bc7952` | + +Pi run directory: `/home/mikec/hwtest/runs/morse-20261012.3Lp6jA`. +Local raw evidence is retained in the ignored `out/morse-rx/` directory; +the strict test runners are in `tools/hil/morse_bench.py` and +`tools/hil/morse_remote.py`. + +Before resuming, coordinate exclusive use of those three radios, verify the +installed images and exact USB identities, and run the isolated timing A/B, +repeated mixed-message speed boundary tests, and the five-minute multipage +LoRa capture/recovery test. Only then choose and verify any production cap +or tuning changes. No commit or push was performed for this checkpoint. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 74cc69361..2ebd9b8a5 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #include #include "NotificationSettingsFile.h" #include "MyMesh.h" @@ -1795,6 +1797,7 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) { #endif bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { + if (_morse_reply.ownsReply(packet)) return false; if (packet == NULL || _prefs.flood_retry_attempts == 0) return false; // A companion may retry its own advert once, using the core's deliberately // slow origin-advert delay. Do not add retries while relaying a neighbour's @@ -1850,12 +1853,32 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk } } -#if MESH_ENABLE_FLEET_CONTROL void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) { mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority); + _morse_reply.copy(original, packet); +#if MESH_ENABLE_FLEET_CONTROL if (original && original == _fleet_admission_original) _fleet_admission_copy = packet; +#endif } +void MyMesh::onSendComplete(mesh::Packet* packet) { + mesh::Mesh::onSendComplete(packet); + _morse_reply.complete(packet); +} + +void MyMesh::onSendFail(mesh::Packet* packet) { + mesh::Mesh::onSendFail(packet); + _morse_reply.fail(packet); +} + +bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, + uint8_t next_hop_hash_len) const { + return !_morse_reply.ownsReply(packet) + && mesh::Mesh::allowDirectRetry(packet, next_hop_hash, next_hop_hash_len); +} + +#if MESH_ENABLE_FLEET_CONTROL + bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length) { if (!envelope || !length || length > mesh::FleetCommand::MaxEnvelopeLength) return false; if (length <= mesh::FleetCommand::MaxPayloadLength) { @@ -2023,15 +2046,33 @@ void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32 const char *text, char* reply) { markConnectionActive(from); // in case this is from a server, and we have a connection if (from.isRemoteCLIAllowed()) { + const uint32_t before = _morse_reply.generation(); + _morse_reply.beginCommand(from.id.pub_key); // A timestamp received over the radio must never grant local-console access. if (!handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)) { strcat(reply, "Unknown command"); // reply may have cmd prefix from 'text' } + _morse_reply.endCommand(); + _morse_reply_admission = _morse_reply.generation() != before; } else { queueMessage(from, TXT_TYPE_CLI_COMMAND, pkt, sender_timestamp, NULL, 0, text); } } +void MyMesh::onCLIReplyPrepared(const ContactInfo& from, mesh::Packet* reply) { + if (!_morse_reply_admission || !_morse_reply.pending() + || memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) != 0) return; + _morse_reply.prepareReply(reply); + if (reply) reply->radio_reply = true; +} + +void MyMesh::onCLIReplyQueued(const ContactInfo& from, mesh::Packet* reply, bool queued) { + if (!_morse_reply_admission) return; + if (memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) == 0) + _morse_reply.armReply(reply, queued); + _morse_reply_admission = false; +} + bool MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) { if (!queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text)) return false; @@ -2780,6 +2821,7 @@ void MyMesh::begin(bool has_display, bool radio_available, initializeOfflineQueue(); BaseChatMesh::begin(); _radio_profiles.begin(_store->getPrimaryFS(), _radio, getRTCClock()); + mesh::loadMorseSettings(_store->getPrimaryFS()); #if defined(ENABLE_OTA) mesh::ota::beginSpeedConfig(_store->getPrimaryFS()); #endif @@ -3961,6 +4003,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { snprintf(reply, reply_size, "Error: use set radio.fem.rxgain on|off"); + } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { + snprintf(reply, reply_size, "Error: stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemLna()) { snprintf(reply, reply_size, "Error: unsupported"); } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { @@ -3990,6 +4034,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { snprintf(reply, reply_size, "Error: use set radio.fem.txgain on|off"); + } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { + snprintf(reply, reply_size, "Error: stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemPaGain()) { snprintf(reply, reply_size, "%s", board.isLoRaFemPaGainEnabled() ? "Error: TX PA is fixed on; use set tx to adjust power" @@ -5250,6 +5296,8 @@ void MyMesh::execCommand(char* cmd, char* reply) { bool enabled; if (!wcParseBool(value, enabled)) { strcpy(reply, "Error: must be on or off"); + } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { + strcpy(reply, "Error: stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemLna()) { strcpy(reply, "Error: unsupported"); } else if (!applyAndSaveFemRxGain(enabled)) { @@ -5263,6 +5311,8 @@ void MyMesh::execCommand(char* cmd, char* reply) { bool enabled; if (!wcParseBool(value, enabled)) { strcpy(reply, "Error: must be on or off"); + } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { + strcpy(reply, "Error: stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemPaGain()) { strcpy(reply, "Error: unsupported"); } else if (!applyAndSaveFemTxGain(enabled)) { @@ -7929,6 +7979,9 @@ MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) { } bool MyMesh::applyAndSaveFemRxGain(bool enabled) { + // Binary Companion setters and WebConfig also enter through this helper. + // Do not alter an external FEM while the Morse transmitter owns it. + if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) return false; if (!board.canControlLoRaFemLna()) return false; const bool previous_hardware = board.isLoRaFemLnaEnabled(); @@ -7951,6 +8004,7 @@ bool MyMesh::applyAndSaveFemRxGain(bool enabled) { } bool MyMesh::applyAndSaveFemTxGain(bool enabled) { + if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) return false; if (!board.canControlLoRaFemPaGain()) return false; const bool previous_hardware = board.isLoRaFemPaGainEnabled(); const uint8_t previous_pref = _prefs.radio_fem_txgain; @@ -8666,6 +8720,10 @@ void MyMesh::resetTerminalSession() { #if COMPANION_FEATURE_READER mesh::cancelReaderCommand(); #endif + _terminal_input_editing = false; + _morse_terminal_owner = nullptr; + _morse_terminal_pending_chars = 0; + _morse_terminal_was_receiving = false; _terminal_recipient_set = false; memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key)); clearTerminalLogin(); @@ -8680,6 +8738,76 @@ void MyMesh::resetTerminalSession() { } } +void MyMesh::serviceMorseTerminal() { + // Only an explicitly owned ASCII terminal receives spontaneous text. + // Never use terminalOutput()'s Serial fallback or append to BLE/USB frames. + if (!_radio || !hasTerminalOutput()) { + _morse_terminal_owner = nullptr; + _morse_terminal_pending_chars = 0; + _morse_terminal_was_receiving = false; + return; + } + // A temporarily silent/paused terminal still owns its cursor. Resuming + // must not replay the capture or reset a pending partial line. + if (!_terminal_morse_atomic || isTerminalWaitingForInput() + || _terminal_input_editing || _terminal_output->available() > 0 + || _terminal_login_pending || _terminal_trace_pending + || _terminal_command.isPending()) return; + // The existing USB facade owns all queued suffixes, including replies and + // echo. Never append progress in the middle of an earlier text producer. + if (_terminal_mode && _terminal_output == &mesh::usbTerminalPort() + && mesh::hasPendingUsbTerminalOutput()) return; + const bool receiving = _radio->isMorseRxActive(); + const uint32_t generation = _radio->morseCaptureGeneration(); + const size_t total = _radio->morseCaptureLength(); + const uint32_t now = _ms->getMillis(); + if (_morse_terminal_owner != _terminal_output || _morse_terminal_generation != generation) { + _morse_terminal_owner = _terminal_output; + _morse_terminal_generation = generation; + _morse_terminal_offset = receiving ? 0 : total; + _morse_terminal_pending_chars = 0; + _morse_terminal_was_receiving = receiving; + _morse_terminal_next_at = now; + } + if (receiving) _morse_terminal_was_receiving = true; + if (!_morse_terminal_was_receiving) return; + if (!_morse_terminal_pending_chars) { + if (_morse_terminal_offset >= total) { + if (!receiving) _morse_terminal_was_receiving = false; + return; + } + if (int32_t(now - _morse_terminal_next_at) < 0) return; + const size_t pending = total - _morse_terminal_offset; + _morse_terminal_pending_chars = pending < 48 ? pending : 48; + } + // Admit one complete record to an explicitly atomic stream. Its existing + // queue handles hardware short writes in chronological order with replies; + // Morse never retains an application-level suffix that can overtake input. + char text[49], line[80]; + const size_t copied = _radio->readMorseCapture(_morse_terminal_offset, text, + size_t(_morse_terminal_pending_chars) + 1); + if (copied != _morse_terminal_pending_chars) { + _morse_terminal_pending_chars = 0; + return; + } + const int length = snprintf(line, sizeof(line), "\r\n[Morse RX] %s\r\n> ", text); + if (length <= 0 || size_t(length) >= sizeof(line)) return; + const int available = _terminal_output->availableForWrite(); + if (available < length) return; + const size_t written = _terminal_output->write( + reinterpret_cast(line), length); + if (written == size_t(length)) { + _morse_terminal_offset += _morse_terminal_pending_chars; + _morse_terminal_pending_chars = 0; + _morse_terminal_next_at = now + 250; + } else if (written) { + // A stream that violates its atomic admission contract is no longer safe + // for unsolicited progress. Keep the full capture available via CLI. + _terminal_morse_atomic = false; + _morse_terminal_pending_chars = 0; + } +} + void MyMesh::printTerminalBanner(bool show_binary_stop) { Stream& output = terminalOutput(); #if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_NETWORK_TERMINAL @@ -8703,6 +8831,12 @@ void MyMesh::enterTerminalMode(bool show_banner) { _terminal_mode = true; _terminal_usb_silent = !show_banner; _terminal_output = &mesh::usbTerminalPort(); +#if MESH_ESP32_TINYUSB_NONBLOCKING \ + || (defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))) + _terminal_morse_atomic = true; +#else + _terminal_morse_atomic = false; +#endif resetTerminalSession(); if (show_banner) printTerminalBanner(true); } @@ -8710,14 +8844,16 @@ void MyMesh::enterTerminalMode(bool show_banner) { void MyMesh::exitTerminalMode() { _terminal_mode = false; _terminal_usb_silent = false; + _terminal_morse_atomic = false; resetTerminalSession(); if (_terminal_output == &mesh::usbTerminalPort()) _terminal_output = NULL; } #if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG) -bool MyMesh::enterNetworkTerminalMode(Stream& output) { +bool MyMesh::enterNetworkTerminalMode(Stream& output, bool atomic_output) { if (_terminal_mode || (_terminal_output != NULL && _terminal_output != &output)) return false; _terminal_output = &output; + _terminal_morse_atomic = atomic_output; resetTerminalSession(); printTerminalBanner(false); return true; @@ -8726,6 +8862,7 @@ bool MyMesh::enterNetworkTerminalMode(Stream& output) { void MyMesh::exitNetworkTerminalMode(Stream& output) { if (_terminal_mode || _terminal_output != &output) return; resetTerminalSession(); + _terminal_morse_atomic = false; _terminal_output = NULL; } @@ -9782,6 +9919,8 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { terminalOutput().print(" ERROR: use set radio.fem.rxgain \r\n"); + } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { + terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n"); } else if (!board.canControlLoRaFemLna()) { terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { @@ -9796,6 +9935,8 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { terminalOutput().print(" ERROR: use set radio.fem.txgain \r\n"); + } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { + terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n"); } else if (!board.canControlLoRaFemPaGain()) { terminalOutput().print(board.isLoRaFemPaGainEnabled() ? " ERROR: TX PA is fixed on; use set tx to adjust power\r\n" @@ -10312,6 +10453,9 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } #endif + if (mesh::handleMorseReplyCommand(_morse_reply, _radio, command, reply, + reply_capacity, millis())) return true; + if (mesh::handleMorseCommand(_radio, command, reply, reply_capacity)) return true; if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true; if (!strncmp(command, "set tempradio ", 14)) command += 4; if (!strcmp(command, "get tempradio")) command += 4; @@ -10900,6 +11044,14 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if MESH_ENABLE_MORSE_RX + if (_morse_reply.pending()) { + const ContactInfo* actor = lookupContactByPubKey(_morse_reply.actor(), PUB_KEY_SIZE); + const auto& settings = mesh::morseSettings(); + _morse_reply.service(_radio, millis(), actor && actor->isRemoteCLIAllowed(), + mesh::morseSettingsUsable(), settings.frequency_khz, settings.wpm); + } +#endif #if COMPANION_FEATURE_NOTIFICATIONS _notifications.loop(millis(), _serial->isConnected()); #if defined(PIN_USER_BTN) && defined(USER_BTN_PRESSED) @@ -10909,14 +11061,14 @@ void MyMesh::loop() { #endif #endif - _radio_profiles.loop(); + if (!mesh::morseTimingActive(_radio)) _radio_profiles.loop(); #if MESH_ENABLE_MESHTEXT_CHAT refreshMeshtextProfile(); #endif #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection(); #endif - if (_scheduled_reboot_at != 0 + if (!mesh::morseTimingActive(_radio) && _scheduled_reboot_at != 0 && (_scheduled_reboot_at == _ms->getMillis() || millisHasNowPassed(_scheduled_reboot_at))) { _scheduled_reboot_at = 0; @@ -10928,7 +11080,7 @@ void MyMesh::loop() { return; } #if COMPANION_FEATURE_TEMP_RADIO - serviceTempRadio(); + if (!mesh::morseTimingActive(_radio)) serviceTempRadio(); #endif #if defined(OTA_SHARED_COMPANION_QUEUE) mesh::ota::ota_release_context_if_idle(isAnyTempRadioActive() || _temp_radio_set_at != 0); @@ -10941,19 +11093,20 @@ void MyMesh::loop() { #endif BaseChatMesh::loop(); #ifdef COMPANION_MESH_CLOCK_SYNC - _clock_sync.loop(); + if (!mesh::morseTimingActive(_radio)) _clock_sync.loop(); #endif #if COMPANION_FEATURE_TEXT_TERMINAL serviceTerminalLogin(); serviceTerminalCommand(); serviceTerminalTrace(); + serviceMorseTerminal(); #endif servicePendingSerialReply(); #if COMPANION_FEATURE_READER mesh::serviceReaderCommand(); #endif serviceBinaryTraceReply(); - serviceSigningSession(); + if (!mesh::morseTimingActive(_radio)) serviceSigningSession(); if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound() #if COMPANION_FEATURE_TEMP_RADIO && !_temp_radio_applied && _temp_radio_set_at == 0 @@ -11006,7 +11159,7 @@ void MyMesh::loop() { servicePersistence(); #else // is there are pending dirty contacts write needed? - if (isContactWriteDue()) { + if (!mesh::morseTimingActive(_radio) && isContactWriteDue()) { const bool success = _store->serviceContactWrites(this, save_filter); if (!success) { scheduleContactWriteRetry(); @@ -11036,6 +11189,7 @@ void MyMesh::loop() { #if defined(ESP32_PLATFORM) void MyMesh::servicePersistence() { + if (mesh::morseTimingActive(_radio)) return; // Advert crypto runs before serial service. Defer its cache I/O until here, // and alternate with due contact work so neither job can starve the other. // Only one persistence job is serviced in a loop pass. diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 479a68e47..0d25830e9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -27,6 +27,7 @@ #include "DataStore.h" #include +#include #include #if MESH_ENABLE_MESHTEXT_CHAT #include @@ -305,10 +306,15 @@ public: bool isTerminalWaitingForInput() const { return _terminal_mode && _terminal_usb_silent; } + // The input parser publishes its real edit state for this exact owner. + // Silent default-ASCII ownership above is a separate protocol state. + void setTerminalInputEditing(const Stream& output, bool editing) { + if (_terminal_output == &output) _terminal_input_editing = editing; + } void handleTerminalCommand(char* command, bool reader_prompt = true, mesh::ReaderTryWrite reader_try_write = nullptr); #if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG) - bool enterNetworkTerminalMode(Stream& output); + bool enterNetworkTerminalMode(Stream& output, bool atomic_output = false); void exitNetworkTerminalMode(Stream& output); bool isNetworkTerminalMode(const Stream& output) const; bool isAnyNetworkTerminalMode() const { @@ -406,8 +412,12 @@ protected: bool sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; #if MESH_ENABLE_FLEET_CONTROL bool sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length); - void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) override; #endif + void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) override; + void onSendComplete(mesh::Packet* packet) override; + void onSendFail(mesh::Packet* packet) override; + bool allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, + uint8_t next_hop_hash_len) const override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; #if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT @@ -450,6 +460,8 @@ protected: const char *text) override; void onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text, char* reply) override; + void onCLIReplyPrepared(const ContactInfo& from, mesh::Packet* reply) override; + void onCLIReplyQueued(const ContactInfo& from, mesh::Packet* reply, bool queued) override; bool onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) override; void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, @@ -630,6 +642,7 @@ private: void clearTerminalTrace(uint32_t now); void serviceTerminalTrace(); void serviceTerminalTrace(uint32_t now); + void serviceMorseTerminal(); void sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, uint8_t hop_count, const char* target); void sendTerminalTrace(ContactInfo& recipient); @@ -692,6 +705,8 @@ private: DataStore* _store; mesh::RadioProfileCLI _radio_profiles; + mesh::MorseReplyHandoff _morse_reply; + bool _morse_reply_admission = false; #if MESH_ENABLE_MESHTEXT_CHAT mesh::mt::Region _mt_region = mesh::mt::Region::Unset; uint32_t _mt_slot = 0, _mt_node = 0, _mt_packet_id = 0; @@ -755,6 +770,8 @@ private: #if COMPANION_FEATURE_TEXT_TERMINAL bool _terminal_mode; bool _terminal_usb_silent = false; + bool _terminal_input_editing = false; + bool _terminal_morse_atomic = false; Stream* _terminal_output; mesh::TerminalDisplayFilter _terminal_display; bool _terminal_recipient_set; @@ -774,6 +791,12 @@ private: unsigned long _terminal_trace_sent_at; unsigned long _terminal_trace_expires_at; char _terminal_trace_target[32]; + Stream* _morse_terminal_owner = nullptr; + uint32_t _morse_terminal_generation = 0; + uint32_t _morse_terminal_next_at = 0; + uint16_t _morse_terminal_offset = 0; + uint8_t _morse_terminal_pending_chars = 0; + bool _morse_terminal_was_receiving = false; #endif bool saved_radio_apply_pending; bool _radio_available; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 52572b5e0..f1ceb7615 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include "MyMesh.h" @@ -683,6 +684,14 @@ static Stream& usbTerminalOutput() { return mesh::usbTerminalPort(); } +static void syncUsbTerminalInputEditing() { +#if COMPANION_FEATURE_TEXT_TERMINAL + the_mesh.setTerminalInputEditing(usbTerminalOutput(), + usb_terminal_line_len != 0 || usb_terminal_discard_line + || mesh::usbCompanionPort().available() > 0); +#endif +} + static void clearUsbTerminalLine() { memset(usb_terminal_line, 0, sizeof(usb_terminal_line)); usb_terminal_line_len = 0; @@ -1468,7 +1477,7 @@ bool MyMesh::beginStreamTerminal(Stream& output) { hasObservableActiveUsbTerminalClient(), input_idle, usb_serial_interface.getCompletedFrameCount())) return false; if (ascii_selected) leaveUsbTerminalMode(false); - if (enterNetworkTerminalMode(output)) { + if (enterNetworkTerminalMode(output, true)) { serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); return true; } @@ -1489,7 +1498,7 @@ void MyMesh::endStreamTerminal(Stream& output) { } #elif defined(WITH_WEBCONFIG) bool MyMesh::beginStreamTerminal(Stream& output) { - return enterNetworkTerminalMode(output); + return enterNetworkTerminalMode(output, true); } void MyMesh::endStreamTerminal(Stream& output) { @@ -3835,6 +3844,9 @@ void loop() { #if COMPANION_FEATURE_USB_MOTA_SOURCE serviceUsbMotaResponseBoundary(); #endif +#endif +#if defined(ENABLE_USB_INTERFACE) + syncUsbTerminalInputEditing(); #endif the_mesh.loop(); #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) @@ -3857,6 +3869,7 @@ void loop() { #endif #if defined(ENABLE_USB_INTERFACE) serviceUsbTerminal(); + syncUsbTerminalInputEditing(); // Commands can synchronously enqueue a multi-line reply. Give it the // remaining CDC FIFO capacity now; later loops retain any unwritten suffix. mesh::serviceUsbTerminalPort(); @@ -3865,17 +3878,20 @@ void loop() { #if COMPANION_FEATURE_BLE_MOTA_SOURCE ble_mota_source_control.loop(); #endif - sensors.loop(); + const bool morse_timing = mesh::morseTimingActive(&radio_driver); + if (!morse_timing) sensors.loop(); #if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) && defined(PIN_USER_BTN) serviceWioE5HeadlessControls(); #endif #ifdef DISPLAY_CLASS + // Keep button/touch and critical UI power checks live. UITask itself skips + // only background redraws while the radio needs precise Morse timing. #ifdef INDICATOR_WIFI_FONT_RECOVERY // The Indicator keeps rendering with its built-in fallback while a missing // SD font is recovered by a background TLS task. This poll only launches // work after station Wi-Fi connects and installs a completed font on the // main/UI task. - display.serviceFontRecovery(); + if (!morse_timing) display.serviceFontRecovery(); #endif #if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG) \ && UI_WIFI_SETUP_HOME_PAGE != 1 @@ -3887,8 +3903,8 @@ void loop() { #if defined(TBEAM_1W) && defined(PIN_WIFI_BTN) ui_task.serviceWiFiToggleButton(); #endif - ui_task.loop(); // Continue button/touch handling while the portal is active. - renderCompanionSetupDisplay(); + ui_task.loop(); + if (!morse_timing) renderCompanionSetupDisplay(); } else { ui_task.loop(); } @@ -3897,7 +3913,7 @@ void loop() { #endif #endif #if defined(COMPANION_PAIRING_UI_HIL) - companionPairingUiHilProbe(); + if (!morse_timing) companionPairingUiHilProbe(); #endif rtc_clock.tick(); board.loop(); @@ -3916,6 +3932,7 @@ void loop() { // USB power alone (for example, a wall charger) does not inhibit sleep. // Host sessions, live logging, and button activity still need service. bool can_sleep = the_mesh.getNodePrefs()->powersaving_enabled + && !morse_timing && !the_mesh.hasPendingWork() && !mesh::isUsbLoggingWatchdogArmed() && !mesh::wireless::control().pending(); @@ -3967,7 +3984,7 @@ void loop() { #endif } #if defined(ESP32_PLATFORM) - else if (the_mesh.getNodePrefs()->powersaving_enabled) { + else if (!morse_timing && the_mesh.getNodePrefs()->powersaving_enabled) { delay(1); // CPU idle remains available while USB/logging/button holds are active. } #endif diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9690e1313..4bd186803 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,5 +1,6 @@ #include "UITask.h" #include +#include #if defined(NRF52_POWER_MANAGEMENT) #include #endif @@ -2267,6 +2268,7 @@ bool UITask::isButtonGesturePending() const { } void UITask::loop() { + const bool morse_timing = mesh::morseTimingActive(&radio_driver); serviceWiFiToggleButton(); servicePairingState(); @@ -2456,13 +2458,13 @@ void UITask::loop() { if (buzzer.isPlaying()) buzzer.loop(); #endif - if (curr) curr->poll(); + if (!morse_timing && curr) curr->poll(); #if COMPANION_FEATURE_READER - if (reader && curr != reader) reader->poll(); + if (!morse_timing && reader && curr != reader) reader->poll(); #endif if (_display != NULL && _display->isOn()) { - if (millis() >= _next_refresh && curr && !isButtonGesturePending()) { + if (!morse_timing && millis() >= _next_refresh && curr && !isButtonGesturePending()) { _display->startFrame(); int delay_millis = curr->render(*_display); renderPairingBanner(); diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 5342b873e..ade079a98 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -1,4 +1,5 @@ #include "UITask.h" +#include #if UI_SMALL_MESSAGE_FONT == 1 #include #endif @@ -707,6 +708,7 @@ void UITask::servicePairingState() { } void UITask::loop() { + const bool morse_timing = mesh::morseTimingActive(&radio_driver); #ifdef PIN_VIBRATION vibration.loop(); #endif @@ -731,7 +733,7 @@ void UITask::loop() { servicePendingShutdown(); #endif - if (_display != NULL && _display->isOn()) { + if (!morse_timing && _display != NULL && _display->isOn()) { if (!isPairingScreenActive() && !_alert[0] && !(_origin[0] && _msg[0]) && (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) { serviceRadioProfileDisplayPage(); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 649426561..726edb3b0 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -3,6 +3,7 @@ #include #endif #include +#include #include #include #include @@ -858,6 +859,7 @@ bool UITask::isButtonPressed() const { } void UITask::loop() { + const bool morse_timing = mesh::morseTimingActive(&radio_driver); servicePairingState(); char c = 0; @@ -949,9 +951,9 @@ void UITask::loop() { if (buzzer.isPlaying()) buzzer.loop(); #endif - if (curr) curr->poll(); + if (!morse_timing && curr) curr->poll(); - if (_display != NULL && _display->isOn()) { + if (!morse_timing && _display != NULL && _display->isOn()) { _statusBar.update(*_display, _node_prefs->node_name, _cached_batt_mv, @@ -1018,7 +1020,7 @@ void UITask::loop() { next_batt_chck = millis() + 8000; } #else - if (_display != NULL && _display->isOn() && millis() >= next_batt_chck) { + if (!morse_timing && _display != NULL && _display->isOn() && millis() >= next_batt_chck) { _cached_batt_mv = getBattMilliVolts(); next_batt_chck = millis() + 8000; } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 6f742ec36..2ef0a8725 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -9,6 +9,7 @@ #endif #endif #include +#include #include #include #if MESH_ENABLE_FLEET_CONTROL @@ -1524,6 +1525,7 @@ uint32_t MyMesh::getDirectRetryAttemptStepMillis() const { } bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + if (_cli.morseReply().ownsReply(packet)) return false; if (_prefs.disable_fwd && _prefs.trace_when_repeat_off != 1 && packet && packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE && packet->path_len > 0) return false; @@ -2066,6 +2068,7 @@ MyMesh::FloodRetryBridgeState* MyMesh::floodRetryBridgeStateFor(const mesh::Pack } bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { + if (_cli.morseReply().ownsReply(packet)) return false; if (_prefs.disable_fwd || constrain(_prefs.flood_retry_attempts, 0, 15) == 0) { return false; } @@ -3053,7 +3056,10 @@ bool MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, // Prepare before admission so Dispatcher can register its second copy; // the caller arms the barrier only after admission succeeds. const bool allow_redundant_copies = queued_packet == NULL; - if (!allow_redundant_copies) temp_radio_reply_barrier.prepare(packet); + if (!allow_redundant_copies) { + if (_cli.morseReply().pending()) _cli.morseReply().prepareReply(packet); + else temp_radio_reply_barrier.prepare(packet); + } const bool queued = sendClientReplyWithFallbackScope( client, packet, CLI_REPLY_DELAY_MILLIS, path_hash_size, fallback_scope, allow_redundant_copies); @@ -3266,6 +3272,7 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { } const uint32_t primary_mutation_before = primary_radio_mutation_generation; const uint32_t secondary_mutation_before = _cli.radioProfiles().replyMutationGeneration(); + const uint32_t morse_mutation_before = _cli.morseReply().generation(); // setperm may compact the ACL, including removal of this sender. Keep the // authenticated pre-command destination/path as a fallback if it is removed. // Command permission checks still use the live ACL entry below. @@ -3325,10 +3332,12 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { } else { #endif _cli.radioProfiles().beginReplyCommand(); + _cli.morseReply().beginCommand(client->id.pub_key); handleCommand(deferred_cli_command.sender_timestamp, client, deferred_cli_command.command, reply, client_index, deferred_cli_command.path_hash_size); _cli.radioProfiles().endReplyCommand(); + _cli.morseReply().endCommand(); #if MESH_ENABLE_HOST_CLI } #endif @@ -3340,6 +3349,7 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { const bool arms_primary_radio = primary_radio_mutation_generation != primary_mutation_before; const bool arms_secondary_radio = _cli.radioProfiles().replyMutationGeneration() != secondary_mutation_before; const bool arms_temp_radio = arms_primary_radio || arms_secondary_radio; + const bool arms_morse = _cli.morseReply().generation() != morse_mutation_before; const char* parsed_command = deferred_cli_command.command; if (strlen(parsed_command) > 3 && parsed_command[2] == '|') parsed_command += 3; const bool private_key_backup = strncmp(parsed_command, "backup prv.key ", 15) == 0; @@ -3348,7 +3358,7 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { if (!private_key_backup) { remote_cli_reply_cache.remember(deferred_cli_command.client_pub_key, deferred_cli_command.request_id, - command_fingerprint, reply, arms_temp_radio); + command_fingerprint, reply, arms_temp_radio || arms_morse); } // Route changes made by this command must apply to its acknowledgement too. // Resolve the full authenticated key again: compaction may have moved it or @@ -3362,7 +3372,8 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { deferred_cli_command.path_hash_size, deferred_cli_command.sender_timestamp, reply, deferred_cli_reply_scoped ? &deferred_cli_reply_scope : NULL, - arms_temp_radio ? &queued_reply : NULL); + (arms_temp_radio || arms_morse) ? &queued_reply : NULL); + if (arms_morse) _cli.morseReply().armReply(queued_reply, reply_queued); if (arms_temp_radio) { radio_reply_secondary = arms_secondary_radio; if (reply_queued && queued_reply != NULL) { @@ -4176,6 +4187,7 @@ bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope, void MyMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); + _cli.morseReply().complete(packet); #if MESH_ENABLE_FLEET_CONTROL if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles()); #endif @@ -4193,11 +4205,13 @@ void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* if (_cli.fleetChannel()) _cli.fleetChannel()->copy(packet, original); #endif temp_radio_reply_barrier.trackCopy(original, packet); + _cli.morseReply().copy(original, packet); mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority); } void MyMesh::onSendFail(mesh::Packet* packet) { mesh::Mesh::onSendFail(packet); + _cli.morseReply().fail(packet); #if MESH_ENABLE_FLEET_CONTROL if (_cli.fleetChannel()) _cli.fleetChannel()->fail(packet, _cli.radioProfiles()); #endif @@ -5186,6 +5200,14 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { sleep_secs = limitSleepToMillisTimer(next_battery_alert_check, sleep_secs); } sleep_secs = limitSleepToScheduledRadioWork(sleep_secs); +#if defined(WITH_RS232_BRIDGE) + if (bridge && rs232BridgeEnabled()) { + // Floor the deadline, not ceil: the ESP32 timed-light-sleep API uses whole + // seconds and must never sleep past a UART rendezvous or cleanup retry. + const uint32_t bridge_sleep_secs = bridge->powerWakeDelayMs(millis()) / 1000UL; + if (bridge_sleep_secs < sleep_secs) sleep_secs = bridge_sleep_secs; + } +#endif return sleep_secs; } @@ -12250,6 +12272,16 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * // Compatibility path for manually defined legacy portable builds. Current // release builds use FULL and do not enter this branch. + // Keep the same permissions as the normal handoff below. In particular, + // RF test/Morse commands must never bypass admin checks in a legacy build. + if (sender && !sender->isAdmin()) { + bool allowed = (sender->isRegionMgr() && isRegionMgrAllowed(command)) + || (sender->isFilterMgr() && isFilterMgrAllowed(command)); + if (!allowed) { + strcpy(reply, "Err - not permitted"); + return; + } + } _cli.handleCommand(sender_timestamp, command, reply); #if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0; @@ -13099,6 +13131,17 @@ void MyMesh::sampleTelemetryHistory() { #endif void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { + if (mesh::morseTimingActive(_radio)) { + // RS232 heartbeat/wake servicing remains live; only optional periodic, + // sensor, filesystem and UI/network background work is deferred. +#if defined(WITH_RS232_BRIDGE) + serviceRs232BridgePower(); +#endif + const uint32_t now = millis(); + uptime_millis += now - last_millis; + last_millis = now; + return; + } if (pending_self_advert) { const uint32_t delay_millis = pending_self_advert_delay; const bool flood = pending_self_advert_flood; @@ -13135,7 +13178,7 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { #endif #endif #if defined(WITH_RS232_BRIDGE) - if (isRs232BridgeRunning()) bridge->loop(); + serviceRs232BridgePower(); #elif defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) \ && !defined(WITH_ESPNOW_BRIDGE) AbstractBridge* active_bridge = activeBridge(); @@ -13897,6 +13940,12 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) if (isRs232BridgeRunning()) return true; #endif +#if defined(WITH_RS232_BRIDGE) + // A partial GPS release/claim is not a completed standby. Never allow MCU + // sleep while its UART ownership remains unresolved, even if end stopped RX. + if (active_rs232_bridge_uart != 0) return true; + if (bridge && rs232BridgeEnabled() && bridge->powerWakeDelayMs(millis()) == 0) return true; +#endif #if defined(WITH_ESPNOW_BRIDGE) \ && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) if (espnow_bridge.isRunning()) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 050d48058..bd4059dff 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -1249,16 +1249,27 @@ public: #ifdef WITH_RS232_BRIDGE RS232Bridge* createRS232Bridge() { + RS232Bridge* created; #ifdef WITH_RS232_BRIDGE_ALT if (_prefs.bridge_uart == WITH_RS232_BRIDGE_ALT_UART) { - return new (std::nothrow) RS232Bridge(&_prefs, WITH_RS232_BRIDGE_ALT, + created = new (std::nothrow) RS232Bridge(&_prefs, WITH_RS232_BRIDGE_ALT, WITH_RS232_BRIDGE_ALT_RX, WITH_RS232_BRIDGE_ALT_TX, _mgr, getRTCClock()); - } + } else #endif - return new (std::nothrow) RS232Bridge(&_prefs, WITH_RS232_BRIDGE, + { + created = new (std::nothrow) RS232Bridge(&_prefs, WITH_RS232_BRIDGE, WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX, _mgr, getRTCClock()); + } + if (created) { + // A new physical owner gets a new control session. Soft UART standby + // retains this object/session; a manual off/restart does not. + uint64_t session = 0; + getRNG()->random(reinterpret_cast(&session), sizeof(session)); + created->setHeartbeatSession(session ? session : 1); + } + return created; } bool beginRS232Bridge() { @@ -1300,7 +1311,9 @@ public: } bool endRS232Bridge() { +#if ENV_INCLUDE_GPS == 1 const uint8_t released_uart = active_rs232_bridge_uart; +#endif bool stopped = true; // A fail-closed preflight can reject this object before begin() touches // its UART. Adafruit nRF Uart::end() is not safe as a generic cleanup for @@ -1335,6 +1348,47 @@ public: return bridge && bridge->isRunning(); } + void serviceRs232BridgePower() { + if (!bridge) return; + // Poll only once, before evaluating liveness. The policy must still run + // when UART is stopped, or two sleeping peers can never rendezvous. + bridge->setHeartbeatEnabled(_prefs.rs232_heartbeat == 1); + if (bridge->isRunning()) bridge->loop(); + if (!rs232BridgeEnabled()) return; + const uint32_t now = millis(); + const RS232Bridge::PowerAction action = bridge->servicePowerPolicy( + (_prefs.rs232_autosleep == 1 || _prefs.rs232_autosleep == 2) + && _prefs.powersaving_enabled != 0 + && _prefs.rs232_heartbeat == 1, + static_cast(_prefs.rs232_idle_seconds) * 1000UL, now, + _prefs.rs232_autosleep == 2); + if (action == RS232Bridge::PowerAction::None) return; + bool success; + if (action == RS232Bridge::PowerAction::Suspend) { + // An end may stop UART but fail to release GPS ownership. The driver + // stays in cleanup/retry until both physical owners confirm success. + success = endRS232Bridge(); + } else { + // A failed earlier start may have yielded GPS before UART begin failed. + // Release any partial ownership before trying to claim it again. + success = (active_rs232_bridge_uart == 0 || endRS232Bridge()) + && beginRS232Bridge(); + } + bridge->completePowerAction(action, success, millis()); + } + + void formatRs232Status(char* reply) override { + char heartbeat[84]; + if (bridge) bridge->formatHeartbeatStatus(heartbeat, sizeof(heartbeat), millis()); + else StrHelper::strncpy(heartbeat, "phase=off peer=unknown", sizeof(heartbeat)); + snprintf(reply, 160, "saved=%s running=%s heartbeat=%s autosleep=%s idle=%us %s", + rs232BridgeEnabled() ? "on" : "off", + isRs232BridgeRunning() ? "on" : "off", + _prefs.rs232_heartbeat == 1 ? "on" : "off", + _prefs.rs232_autosleep == 2 ? "force" : _prefs.rs232_autosleep == 1 ? "on" : "off", + static_cast(_prefs.rs232_idle_seconds), heartbeat); + } + bool setRs232BridgeState(bool enable) override { if (!enable) { const bool stopped = endRS232Bridge(); @@ -1347,7 +1401,10 @@ public: if (isRs232BridgeRunning()) return true; // Retry any tracked GPS release before constructing a new UART owner. if (active_rs232_bridge_uart != 0 && !endRS232Bridge()) return false; - if (!bridge) bridge = createRS232Bridge(); + // Explicit enable is a fresh owner, including when soft standby retained + // the previous one. It must not inherit a dormant heartbeat deadline. + delete bridge; + bridge = createRS232Bridge(); return bridge && beginRS232Bridge(); } @@ -1526,10 +1583,7 @@ public: } #endif #if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) - if (enable && !bridge) { - bridge = createRS232Bridge(); - if (!bridge) return false; - } + if (enable) return setRs232BridgeState(true); if (!enable) { const bool stopped = endRS232Bridge(); if (stopped) { diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index e2672e73c..e3cf865f4 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -2,6 +2,7 @@ #include "target.h" #include #include +#include #include #include #include @@ -844,6 +845,7 @@ void UITask::toggleDisplay(const char* source) { #endif void UITask::loop() { + const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio()); if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) { if (_display->isOn()) resetRadioProfileDisplayPage(); _next_refresh = 0; @@ -909,7 +911,7 @@ void UITask::loop() { } #endif - if (_display->isOn()) { + if (!morse_timing && _display->isOn()) { // Apply a live orientation change before drawing, including the first frame // after a preference was changed while the panel was blanked. applyDisplayFlip(); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 77459b1ef..ee85a80f9 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #if MESH_PACKET_LOGGING #include @@ -575,7 +576,8 @@ void loop() { #endif the_mesh.loop(); - sensors.loop(); + const bool morse_timing = mesh::morseTimingActive(&radio_driver); + if (!morse_timing) sensors.loop(); #ifdef DISPLAY_CLASS if (display_ready) ui_task.loop(); #endif @@ -593,6 +595,7 @@ void loop() { external_watchdog.loop(); #endif bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled + && !morse_timing && !board.isUsbDataConnected() && !mesh::isUsbLoggingWatchdogArmed() && !mesh::wireless::control().pending(); @@ -621,7 +624,7 @@ void loop() { } #if defined(ESP32_PLATFORM) - if (!can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { + if (!morse_timing && !can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { delay(1); // Idle without suspending USB or the button wake interval. } #endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 75a384848..58d45230c 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -1568,6 +1569,7 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + if (_cli.morseReply().ownsReply(packet)) return false; (void)packet; (void)next_hop_hash; (void)next_hop_hash_len; @@ -1600,6 +1602,7 @@ uint32_t MyMesh::getDirectRetryAttemptDelay(const mesh::Packet* packet, uint8_t } bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { + if (_cli.morseReply().ownsReply(packet)) return false; if (_prefs.disable_fwd || _prefs.flood_retry_attempts == 0) return false; if (packet != NULL && packet->flood_retry_policy == mesh::FLOOD_RETRY_POLICY_DENY) { @@ -1947,6 +1950,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, uint8_t temp[5 + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1]; temp[5] = 0; bool send_ack = false; + const uint32_t morse_mutation_before = _cli.morseReply().generation(); if (flags == TXT_TYPE_PLAIN) { const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; @@ -1978,9 +1982,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint32_t command_fingerprint = mesh::RemoteCliReplyCache::fingerprint(text, text_len); const char* cached_response = NULL; + bool cached_authoritative_reply = false; const bool cached_retry = remote_cli_reply_cache.lookup( client->id.pub_key, request_id, command_fingerprint, - &cached_response); + &cached_response, &cached_authoritative_reply); if (sender_timestamp < client->last_timestamp && !cached_retry) { MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); @@ -2010,6 +2015,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (cached_retry && !client->isAdmin() && !own_path_allowed) { strcpy((char*)&temp[5], "Err - admin permission required"); temp[4] = (TXT_TYPE_CLI_DATA << 2); + } else if (cached_retry && cached_authoritative_reply) { + // Only the original exact response owns the listen barrier. Do not + // replay a success-looking handoff reply after failure/cancellation. + return; } else if (cached_retry) { MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply"); size_t cached_len = strlen(cached_response); @@ -2028,8 +2037,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, && !handleRoomMailClientCommand(client, text, request_id, (char*)&temp[5], mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1)) { if (client->isAdmin()) { + _cli.morseReply().beginCommand(client->id.pub_key); handleCommand(sender_timestamp, (char*)text, (char*)&temp[5], i, packet->getPathHashSize()); + _cli.morseReply().endCommand(); } else { strcpy((char*)&temp[5], "Err - admin permission required"); } @@ -2038,7 +2049,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (temp[5] == 0) strcpy((char*)&temp[5], "OK"); remote_cli_reply_cache.remember(client->id.pub_key, request_id, command_fingerprint, - (char*)&temp[5]); + (char*)&temp[5], + _cli.morseReply().generation() != morse_mutation_before); temp[4] = (TXT_TYPE_CLI_DATA << 2); } // CLI_DATA replies are the result signal; no separate ACK is expected. @@ -2088,7 +2100,16 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + reply_text_len); - if (reply) { + if (_cli.morseReply().generation() != morse_mutation_before) { + // One first copy per radio profile, without alternate paths/retries + // that could remain queued after the listen handoff. + _cli.morseReply().prepareReply(reply); + if (reply) reply->radio_reply = true; + const bool queued = reply && (!mesh::Packet::isValidPathLen(client->out_path_len) + ? sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()) + : sendDirect(reply, client->out_path, client->out_path_len, delay_millis + SERVER_RESPONSE_DELAY)); + _cli.morseReply().armReply(reply, queued); + } else if (reply) { sendClientReply(client, reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } @@ -2264,24 +2285,31 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) { _clock_sync.observeGroupPacket(packet); } -#if MESH_ENABLE_FLEET_CONTROL void MyMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); + _cli.morseReply().complete(packet); +#if MESH_ENABLE_FLEET_CONTROL if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles()); +#endif } void MyMesh::onSendFail(mesh::Packet* packet) { mesh::Mesh::onSendFail(packet); + _cli.morseReply().fail(packet); +#if MESH_ENABLE_FLEET_CONTROL if (_cli.fleetChannel()) _cli.fleetChannel()->fail(packet, _cli.radioProfiles()); +#endif } void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) { mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority); + _cli.morseReply().copy(original, packet); +#if MESH_ENABLE_FLEET_CONTROL if (_cli.fleetChannel()) _cli.fleetChannel()->copy(packet, original); -} #endif +} #if defined(WITH_MQTT_NEIGHBORS) @@ -3846,6 +3874,9 @@ void MyMesh::loop() { servicePendingSerialOutput(); #endif _cli.loop(); + // Keep the radio and command/reply queues responsive while a Morse session + // owns RF. Deferred periodic work retains its deadlines and resumes after. + if (mesh::morseTimingActive(_radio)) return; #if MESH_ENABLE_FLEET_CONTROL if (_cli.fleetChannel()) { _cli.fleetChannel()->service(*this, _cli.radioProfiles(), _prefs.node_name, diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 9fadd5f96..4656db539 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -448,12 +448,10 @@ protected: void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; void onGroupPacketRecv(mesh::Packet* packet) override; -#if MESH_ENABLE_FLEET_CONTROL void onSendComplete(mesh::Packet* packet) override; void onSendFail(mesh::Packet* packet) override; void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) override; -#endif #if defined(WITH_MQTT_NEIGHBORS) void onControlDataRecv(mesh::Packet* packet) override; #endif diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 8efe9696d..9b7a96077 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -1,4 +1,5 @@ #include "UITask.h" +#include #include "target.h" #include #include @@ -495,6 +496,7 @@ void UITask::toggleDisplay(const char* source) { #endif void UITask::loop() { + const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio()); if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) { resetRadioProfileDisplayPage(); _next_refresh = 0; @@ -582,7 +584,7 @@ void UITask::loop() { } #endif - if (_display->isOn()) { + if (!morse_timing && _display->isOn()) { // Apply a live orientation change before drawing, including the first frame // after a preference was changed while the panel was blanked. applyDisplayFlip(); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 2eae50a05..de7fc2313 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #if MESH_PACKET_LOGGING #include #endif @@ -331,7 +332,8 @@ void loop() { #endif the_mesh.loop(); - sensors.loop(); + const bool morse_timing = mesh::morseTimingActive(&radio_driver); + if (!morse_timing) sensors.loop(); #ifdef DISPLAY_CLASS if (display_ready) ui_task.loop(); #endif @@ -348,6 +350,7 @@ void loop() { external_watchdog.loop(); #endif bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled + && !morse_timing && !board.isUsbDataConnected() && !mesh::isUsbLoggingWatchdogArmed() && !mesh::wireless::control().pending(); @@ -372,7 +375,7 @@ void loop() { #endif } #if defined(ESP32_PLATFORM) - if (!can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { + if (!morse_timing && !can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { delay(1); // Keep USB and the button wake interval serviced while idle. } #endif diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 8577ddd83..7b85178f9 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #if defined(ENABLE_OTA) #include @@ -442,6 +443,7 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { bool SensorMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + if (_cli.morseReply().ownsReply(packet)) return false; #if MESH_ENABLE_SENSOR_TRACKER if (tracker_pending && packet != NULL) { uint8_t hash[MAX_HASH_SIZE]; @@ -481,6 +483,7 @@ uint32_t SensorMesh::getDirectRetryAttemptDelay(const mesh::Packet* packet, uint } bool SensorMesh::allowFloodRetry(const mesh::Packet* packet) const { + if (_cli.morseReply().ownsReply(packet)) return false; if (packet != NULL && packet->flood_retry_policy == mesh::FLOOD_RETRY_POLICY_DENY) return false; if (_prefs.disable_fwd || _prefs.flood_retry_attempts == 0) return false; return packet == NULL || packet->getPayloadType() != PAYLOAD_TYPE_ADVERT @@ -1018,8 +1021,11 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i char *command = (char *) &data[5]; char *reply = (char *) &temp[5]; *reply = 0; + const uint32_t morse_mutation_before = _cli.morseReply().generation(); + _cli.morseReply().beginCommand(from->id.pub_key); handleCommand(sender_timestamp, command, reply, i, packet->getPathHashSize()); + _cli.morseReply().endCommand(); int text_len = strlen(reply); if (text_len > 0) { @@ -1032,13 +1038,20 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i temp[4] = (TXT_TYPE_CLI_DATA << 2); auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len); + const bool arms_morse = _cli.morseReply().generation() != morse_mutation_before; + if (arms_morse) { + _cli.morseReply().prepareReply(reply); + if (reply) reply->radio_reply = true; + } + bool queued = false; if (reply) { if (!mesh::Packet::isValidPathLen(from->out_path_len)) { - sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); + queued = sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); } else { - sendDirect(reply, from->out_path, from->out_path_len, CLI_REPLY_DELAY_MILLIS); + queued = sendDirect(reply, from->out_path, from->out_path_len, CLI_REPLY_DELAY_MILLIS); } } + if (arms_morse) _cli.morseReply().armReply(reply, queued); } } else { MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported text type received: flags=%02x", (uint32_t)flags); @@ -1061,6 +1074,24 @@ bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t return false; } +#if !MESH_ENABLE_SENSOR_TRACKER +void SensorMesh::onSendComplete(mesh::Packet* packet) { + mesh::Mesh::onSendComplete(packet); + _cli.morseReply().complete(packet); +} + +void SensorMesh::onSendFail(mesh::Packet* packet) { + mesh::Mesh::onSendFail(packet); + _cli.morseReply().fail(packet); +} +#endif + +void SensorMesh::onRadioProfileCopyQueued(mesh::Packet* packet, + const mesh::Packet* original, uint8_t priority) { + _cli.morseReply().copy(original, packet); + mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority); +} + #define CTL_TYPE_NODE_DISCOVER_REQ 0x80 #define CTL_TYPE_NODE_DISCOVER_RESP 0x90 @@ -1437,10 +1468,13 @@ bool SensorMesh::getGPS(uint8_t channel, float& lat, float& lon, float& alt) { void SensorMesh::loop() { #if MESH_ENABLE_SENSOR_TRACKER - if (tracker.enabled && trackerNeedsRadio()) wakeTrackerRadio(); + if (!mesh::morseTimingActive(_radio) && tracker.enabled && trackerNeedsRadio()) wakeTrackerRadio(); #endif _cli.loop(); mesh::Mesh::loop(); + // Do not consume sensor/advert/write deadlines during timed Morse capture. + // USB command service and the physical radio continue on every pass. + if (mesh::morseTimingActive(_radio)) return; _clock_sync.loop(); #if MESH_ENABLE_SENSOR_TRACKER serviceTracker(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index d8f7b03f2..0ed10b253 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -218,10 +218,10 @@ protected: bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; -#if MESH_ENABLE_SENSOR_TRACKER void onSendComplete(mesh::Packet* packet) override; void onSendFail(mesh::Packet* packet) override; -#endif + void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, + uint8_t priority) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; diff --git a/examples/simple_sensor/Tracker.cpp b/examples/simple_sensor/Tracker.cpp index 9d84b8ea3..233c6e20a 100644 --- a/examples/simple_sensor/Tracker.cpp +++ b/examples/simple_sensor/Tracker.cpp @@ -266,6 +266,7 @@ bool SensorMesh::handleTrackerPath(mesh::Packet* packet, uint8_t* path, uint8_t void SensorMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); + _cli.morseReply().complete(packet); if (!tracker_pending || !packet || packet->getPayloadType() != PAYLOAD_TYPE_REQ) return; uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); @@ -277,6 +278,7 @@ void SensorMesh::onSendComplete(mesh::Packet* packet) { void SensorMesh::onSendFail(mesh::Packet* packet) { mesh::Mesh::onSendFail(packet); + _cli.morseReply().fail(packet); if (!tracker_pending || !packet) return; uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index 43f2dacd9..971f42bd1 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -1,4 +1,5 @@ #include "UITask.h" +#include #include "target.h" #include #include @@ -204,6 +205,7 @@ void UITask::renderCurrScreen() { } void UITask::loop() { + const bool morse_timing = mesh::morseTimingActive(activeSensorMesh().getProfileRadio()); if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) { resetRadioProfileDisplayPage(); _next_refresh = 0; @@ -239,7 +241,7 @@ void UITask::loop() { } #endif - if (_display->isOn()) { + if (!morse_timing && _display->isOn()) { if (millis() >= _next_refresh) { _display->startFrame(); renderCurrScreen(); diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 93c848113..7ee29961c 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER #include static SoftwareTimer tracker_wake_timer; @@ -327,7 +328,8 @@ void loop() { board.loop(); // let the board feed its watchdog, run periodic housekeeping the_mesh.loop(); - sensors.loop(); + const bool morse_timing = mesh::morseTimingActive(&radio_driver); + if (!morse_timing) sensors.loop(); #ifdef DISPLAY_CLASS if (display_ready) ui_task.loop(); #endif @@ -342,6 +344,7 @@ void loop() { bool can_power_save = (the_mesh.getNodePrefs()->powersaving_enabled || the_mesh.isTrackerModeEnabled()) + && !morse_timing && !board.isUsbDataConnected() && !mesh::isUsbLoggingWatchdogArmed(); #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ @@ -372,7 +375,7 @@ void loop() { #endif } #if defined(ESP32_PLATFORM) - if (!can_power_save && (the_mesh.getNodePrefs()->powersaving_enabled + if (!morse_timing && !can_power_save && (the_mesh.getNodePrefs()->powersaving_enabled || the_mesh.isTrackerModeEnabled())) { delay(1); // Keep USB and the button wake interval serviced while idle. } diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 978152f08..91a97e49d 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -135,11 +135,11 @@ def requirements(platform, defines, target): if "BLE_PIN_CODE" in defines: parts["bluetooth_worker_stacks"] = 5920 if combined_rak_ethernet and "WITH_RS232_BRIDGE" in defines: - # The bridge owns a packet/ACK duplicate table as well as its RX - # frame. Its source assertion bounds the object to 2304 bytes; - # retain room for allocation metadata and the UART TX semaphore. + # The bridge owns a packet/ACK duplicate table, RX/TX frames and + # heartbeat state. The source assertion bounds the complete object + # to 2816 bytes; retain room for allocation metadata and the UART TX semaphore. # UART RX/TX arrays are embedded in the already-linked globals. - parts["uart_bridge_and_driver"] = 2560 + parts["uart_bridge_and_driver"] = 3072 elif platform == "ESP32_PLATFORM": parts["core_tasks_usb_filesystems"] = 24576 # MQTT joins with saved runtime credentials even without a compiled SSID @@ -153,10 +153,10 @@ def requirements(platform, defines, target): parts["mqtt_startup_allowance"] = mqtt["startup_allowance_bytes"] if "WITH_RS232_BRIDGE" in defines: # The UART bridge is allocated on enable, alongside WiFi/MQTT. - # RS232Bridge.cpp bounds its object to 2304 bytes. Reserve 2560 + # RS232Bridge.cpp bounds its object to 2816 bytes. Reserve 3072 # including allocation metadata, plus the unchanged 4 KiB for # the SDK UART task, buffers and allocator overhead. - parts["uart_bridge_and_driver"] = 2560 + 4096 + parts["uart_bridge_and_driver"] = 3072 + 4096 if "ENABLE_OTA" in defines: # Existing own-image/manual-source allowance is independent of # the generic folder leaf/output buffers counted below. diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 41399721d..7dceb14cb 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #ifndef RADIO_LIVENESS_SOFT_ONLY #include #endif @@ -42,6 +44,47 @@ public: virtual bool isCarrierWaveActive() const { return false; } virtual uint8_t carrierWaveProfile() const { return 0; } virtual uint32_t carrierWaveRemainingMillis() const { return 0; } + virtual bool supportsMorseTx() const { return false; } + virtual MorseTxResult startMorseTx(float frequency_mhz, uint8_t wpm, const char* text) { + (void)frequency_mhz; (void)wpm; (void)text; + return MorseTxResult::Unsupported; + } + virtual MorseTxResult stopMorseTx() { return MorseTxResult::Unsupported; } + virtual bool isMorseTxActive() const { return false; } + virtual float morseTxFrequency() const { return MorseTx::DefaultFrequencyMHz; } + virtual uint8_t morseTxWpm() const { return MorseTx::DefaultWpm; } + virtual uint32_t morseTxRemainingMillis() const { return 0; } + virtual bool supportsMorseRx() const { return false; } + virtual MorseRxResult startMorseRx(float frequency_mhz, uint8_t wpm, uint32_t duration_ms) { + (void)frequency_mhz; (void)wpm; (void)duration_ms; + return MorseRxResult::Unsupported; + } + virtual MorseRxResult stopMorseRx() { return MorseRxResult::Unsupported; } + virtual bool isMorseRxActive() const { return false; } + virtual bool morseRxRecovering() const { return false; } + virtual bool morseRxLearning() const { return false; } + virtual float morseRxFrequency() const { return MorseTx::DefaultFrequencyMHz; } + virtual float morseRxBandwidthKHz() const { return 0; } + virtual uint32_t morseRxRemainingMillis() const { return 0; } + virtual uint32_t morseCaptureGeneration() const { return 0; } + virtual bool morseRxDiagnostics(MorseRxDiagnostics& out) const { out = {}; return false; } + virtual size_t morseCaptureLength() const { return 0; } + virtual bool morseCaptureTruncated() const { return false; } + virtual const char* morseCaptureText() const { return ""; } + virtual size_t readMorseCapture(size_t offset, char* out, size_t capacity) const { + (void)offset; + if (out && capacity) out[0] = 0; + return 0; + } + virtual void clearMorseCapture() { } +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + virtual size_t morseTraceLength() const { return 0; } + virtual uint32_t morseTraceTotal() const { return 0; } + virtual bool morseTraceTruncated() const { return false; } + virtual bool readMorseTrace(size_t offset, MorseRssiTraceEntry& out) const { + (void)offset; (void)out; return false; + } +#endif virtual void begin() { } virtual RadioProfiles* profiles() { return nullptr; } virtual const RadioProfiles* profiles() const { return nullptr; } diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 155b0eef4..c6541065c 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -419,14 +419,17 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender temp[4] = (TXT_TYPE_CLI_DATA << 2); auto reply_pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, from.id, secret, temp, 5 + text_len); + onCLIReplyPrepared(from, reply_pkt); + bool queued = false; if (reply_pkt) { if (from.out_path_len == OUT_PATH_UNKNOWN) { - sendFloodScoped(from, reply_pkt, CLI_REPLY_DELAY_MILLIS); + queued = sendFloodScoped(from, reply_pkt, CLI_REPLY_DELAY_MILLIS); } else { - sendDirect(reply_pkt, from.getPath(), from.out_path_len, CLI_REPLY_DELAY_MILLIS); + queued = sendDirect(reply_pkt, from.getPath(), from.out_path_len, CLI_REPLY_DELAY_MILLIS); } } - } + onCLIReplyQueued(from, reply_pkt, queued); + } else onCLIReplyQueued(from, nullptr, false); } else if (flags == TXT_TYPE_SIGNED_PLAIN) { if (len < 9) return; // timestamp, flags and complete author prefix const uint32_t previous_sync_since = from.sync_since; diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 6ece54f53..d7f9850c6 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -176,6 +176,10 @@ protected: virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; virtual void onCommandDataRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; virtual void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) = 0; + // Default-no-op admission hooks let a radio-owning maintenance operation + // wait for its exact encrypted CLI response without changing the protocol. + virtual void onCLIReplyPrepared(const ContactInfo&, mesh::Packet*) {} + virtual void onCLIReplyQueued(const ContactInfo&, mesh::Packet*, bool) {} // Return true only after retaining/delivering the room post. A false result // leaves the catch-up cursor unchanged and suppresses the delivery ACK. virtual bool onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0; diff --git a/src/helpers/CarrierWaveCLI.h b/src/helpers/CarrierWaveCLI.h index ccca9bae7..d5580cf50 100644 --- a/src/helpers/CarrierWaveCLI.h +++ b/src/helpers/CarrierWaveCLI.h @@ -17,6 +17,13 @@ inline bool handleCarrierWaveCommand(Radio* radio, const char* command, if (!secondary && !(length == 2 && !strncmp(text, "cw", 2))) return false; const char* name = secondary ? "cw2" : "cw"; const char* args = cli::skipRecentRepeaterSpaces(text + length); + // The shared maintenance guard also excludes packet work during Morse RX. + // It is not evidence that the transmitter is emitting a continuous carrier. + if (radio && !*args && (radio->isMorseRxActive() || radio->isMorseTxActive())) { + snprintf(reply, capacity, "> Morse %s owns radio; use get morse", + radio->isMorseRxActive() ? "RX" : "TX"); + return true; + } if (!radio || !radio->supportsCarrierWave()) { snprintf(reply, capacity, "Error: CW unsupported on this radio"); return true; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a9d9c50be..a607c0fd1 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -5,7 +5,12 @@ #include "HilStartupTrace.h" #include #include +#include +#include #include "CLICommandUtils.h" +#ifdef WITH_RS232_BRIDGE +#include "bridges/RS232PowerSettings.h" +#endif #include "GpsPowerPolicy.h" #include "FloodAdvertCLI.h" #include "StorageLayout.h" @@ -155,8 +160,19 @@ static bool isGpioConfig(const char* config) { #endif void CommonCLI::loop() { - _radio_profiles.loop(); - loopManagement(); +#if MESH_ENABLE_MORSE_RX + if (_morse_reply.pending()) { + const ClientInfo* actor = _acl->getClient(_morse_reply.actor(), PUB_KEY_SIZE); + const auto& settings = mesh::morseSettings(); + _morse_reply.service(_callbacks->getProfileRadio(), millis(), + actor && actor->isAdmin(), mesh::morseSettingsUsable(), + settings.frequency_khz, settings.wpm); + } +#endif + if (!mesh::morseTimingActive(_callbacks->getProfileRadio())) { + _radio_profiles.loop(); + loopManagement(); + } #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) _user_gpio.loop(); UserGpio::Completion completion; @@ -749,6 +765,7 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { void CommonCLI::loadPrefs(FILESYSTEM* fs) { mesh::hilStartupTrace("prefs_profiles_begin"); _radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true); + mesh::loadMorseSettings(fs); mesh::hilStartupTrace("prefs_profiles_ready"); _prefs->primary_radio_preamble = _radio_profiles.primaryPreamble(); #if !defined(WITH_MQTT_BRIDGE) && (defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)) @@ -785,6 +802,9 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { _prefs->trace_when_repeat_off = 0; _prefs->ota_channel = 0; #ifdef WITH_RS232_BRIDGE + _prefs->rs232_heartbeat = 1; + _prefs->rs232_autosleep = 1; + _prefs->rs232_idle_seconds = mesh::bridge::rs232power::DEFAULT_IDLE_SECONDS; _prefs->bridge_uart = WITH_RS232_BRIDGE_UART; #else _prefs->bridge_uart = 0; @@ -848,6 +868,12 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #endif #endif if (legacy && _prefs->loadSerial(legacy)) { +#ifdef WITH_RS232_BRIDGE + // Observer JSON migration can use the checked writer directly. Normalize + // runtime fields here too, not just the bytes emitted by that writer. + mesh::bridge::rs232power::sanitize(_prefs->rs232_heartbeat, + _prefs->rs232_autosleep, _prefs->rs232_idle_seconds); +#endif loaded = true; is_upgrade = true; #if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ @@ -1031,6 +1057,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->gps_sync_interval_hours = 0; _prefs->ota_channel = 0; _prefs->rs232_bridge_enabled = 0; +#ifdef WITH_RS232_BRIDGE + _prefs->rs232_heartbeat = 1; + _prefs->rs232_autosleep = 1; + _prefs->rs232_idle_seconds = mesh::bridge::rs232power::DEFAULT_IDLE_SECONDS; +#endif #if defined(RP2040_PLATFORM) File file = fs->open(filename, "r"); #else @@ -1468,6 +1499,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { #ifdef WITH_RS232_BRIDGE has_runtime_rs232_intent = rs232_profile == 0xB1 && rs232_intent <= 1; + if (has_runtime_rs232_intent && file.available() > 0) { + uint8_t power_tail[mesh::bridge::rs232power::PREFS_SIZE]; + const size_t count = file.read(power_tail, sizeof(power_tail)); + if (!mesh::bridge::rs232power::decodePrefs(power_tail, count, + _prefs->rs232_heartbeat, _prefs->rs232_autosleep, + _prefs->rs232_idle_seconds)) { + _com_prefs_needs_upgrade = true; + } + } #endif } } @@ -1871,6 +1911,13 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { const uint8_t rs232_profile = 0; #endif WRITE_COMMON_PREFS(&rs232_profile); // UART intent belongs to this profile +#ifdef WITH_RS232_BRIDGE + uint8_t power_tail[mesh::bridge::rs232power::PREFS_SIZE]; + mesh::bridge::rs232power::encodePrefs(power_tail, prefs->rs232_heartbeat, + prefs->rs232_autosleep, + prefs->rs232_idle_seconds); + WRITE_COMMON_PREFS_BYTES(power_tail, sizeof(power_tail)); +#endif #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -1885,6 +1932,10 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { _common_save_succeeded = false; _prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0; _prefs->trace_when_repeat_off = _prefs->trace_when_repeat_off == 1 ? 1 : 0; +#ifdef WITH_RS232_BRIDGE + mesh::bridge::rs232power::sanitize(_prefs->rs232_heartbeat, + _prefs->rs232_autosleep, _prefs->rs232_idle_seconds); +#endif #if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) _prefs->rs232_bridge_enabled = _prefs->bridge_enabled == 1 ? 1 : 0; #elif !defined(WITH_RS232_BRIDGE) @@ -2052,6 +2103,13 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { const uint8_t rs232_profile = 0; #endif file.write(&rs232_profile, sizeof(rs232_profile)); // UART intent belongs to this profile +#ifdef WITH_RS232_BRIDGE + uint8_t power_tail[mesh::bridge::rs232power::PREFS_SIZE]; + mesh::bridge::rs232power::encodePrefs(power_tail, _prefs->rs232_heartbeat, + _prefs->rs232_autosleep, + _prefs->rs232_idle_seconds); + file.write(power_tail, sizeof(power_tail)); +#endif _common_save_succeeded = file.commit(); if (!_common_save_succeeded) { @@ -2866,6 +2924,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (handleManagementCommand(command, reply)) return; if (mesh::wireless::control().handle(command, reply, 160, millis(), _callbacks->wirelessCommandSource(sender_timestamp))) return; + // Infrastructure callers can already have reflected a three-byte CLI + // request prefix into the same 160-byte reply. Reserve it for bulk pages. + if (mesh::handleMorseReplyCommand(_morse_reply, _callbacks->getProfileRadio(), + command, reply, 160 - 3, millis())) return; + if (mesh::handleMorseCommand(_callbacks->getProfileRadio(), command, reply, 160 - 3)) return; if (_radio_profiles.handle(command, reply, 160, sender_timestamp != 0)) return; #if defined(ENABLE_OTA) const auto* ota_context = mesh::ota::ota_context_if_active(); @@ -3928,6 +3991,59 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } #endif #ifdef WITH_RS232_BRIDGE + if (strncmp(config, "rs232.heartbeat ", 16) == 0) { + bool enable = false; + if (!parseOnOffStrict(config + 16, enable)) { + strcpy(reply, "Error: use set rs232.heartbeat on|off"); + return; + } + const uint8_t previous = _prefs->rs232_heartbeat; + _prefs->rs232_heartbeat = enable ? 1 : 0; + if (!trySavePrefs()) { + _prefs->rs232_heartbeat = previous; + strcpy(reply, "Error: RS232 heartbeat not saved; unchanged"); + } else { + strcpy(reply, enable ? "OK - RS232 heartbeat enabled" : "OK - heartbeat and standby disabled"); + } + return; + } + if (strncmp(config, "rs232.autosleep ", 16) == 0) { + bool enable = false; + const bool force = strcmp(config + 16, "force") == 0; + if (!force && !parseOnOffStrict(config + 16, enable)) { + strcpy(reply, "Error: use set rs232.autosleep on|off|force"); + return; + } + const uint8_t previous = _prefs->rs232_autosleep; + _prefs->rs232_autosleep = force ? 2 : enable ? 1 : 0; + if (!trySavePrefs()) { + _prefs->rs232_autosleep = previous; + strcpy(reply, "Error: RS232 autosleep not saved; unchanged"); + } else { + strcpy(reply, force ? "OK - forced standby; stock/unknown peers can miss packets" + : enable ? "OK - standby after peer heartbeat confirmed; stock stays active" + : "OK - RS232 standby disabled"); + } + return; + } + if (strncmp(config, "rs232.idle ", 11) == 0) { + uint32_t seconds = 0; + if (!mesh::cli::parseUnsignedIntegerStrict(config + 11, seconds) + || seconds < mesh::bridge::rs232power::MIN_IDLE_SECONDS + || seconds > mesh::bridge::rs232power::MAX_IDLE_SECONDS) { + strcpy(reply, "Error: use set rs232.idle 60..3600 seconds"); + return; + } + const uint16_t previous = _prefs->rs232_idle_seconds; + _prefs->rs232_idle_seconds = static_cast(seconds); + if (!trySavePrefs()) { + _prefs->rs232_idle_seconds = previous; + strcpy(reply, "Error: RS232 idle not saved; unchanged"); + } else { + sprintf(reply, "OK - RS232 idle %us", static_cast(seconds)); + } + return; + } if (strncmp(config, "rs232.enabled ", 14) == 0) { bool enable = false; if (!parseOnOffStrict(config + 14, enable)) { @@ -3946,7 +4062,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint8_t& intent = _prefs->bridge_enabled; #endif const uint8_t previous_enabled = intent; - const bool previous_running = _callbacks->isRs232BridgeRunning(); intent = enable ? 1 : 0; const bool applied = _callbacks->setRs232BridgeState(enable); const bool saved = applied && trySavePrefs(); @@ -3955,7 +4070,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep #ifndef WITH_MQTT_BRIDGE _prefs->rs232_bridge_enabled = _prefs->bridge_enabled == 1 ? 1 : 0; #endif - const bool restored = _callbacks->setRs232BridgeState(previous_running); + // Saved-on may currently be in soft standby. Restore an owner as well + // as its intent, otherwise no object remains to execute the wake timer. + const bool restored = _callbacks->setRs232BridgeState(previous_enabled != 0); strcpy(reply, restored ? "Error: RS232 change failed; setting unchanged" : "Error: RS232 change failed; previous runtime could not be restored"); @@ -4192,6 +4309,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error: unsupported"); } } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { + const auto* radio = _callbacks->getProfileRadio(); + if (radio && (radio->isMorseTxActive() || radio->isMorseRxActive())) { + strcpy(reply, "Error: stop Morse before changing LoRa FEM gain"); + return; + } if (!_board->canControlLoRaFemLna()) { strcpy(reply, "Error: unsupported"); } else if (memcmp(&config[17], "on", 2) == 0) { @@ -4218,6 +4340,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error: state must be on or off"); } } else if (memcmp(config, "radio.fem.txgain ", 17) == 0) { + const auto* radio = _callbacks->getProfileRadio(); + if (radio && (radio->isMorseTxActive() || radio->isMorseRxActive())) { + strcpy(reply, "Error: stop Morse before changing LoRa FEM gain"); + return; + } if (!_board->canControlLoRaFemPaGain()) { strcpy(reply, "Error: unsupported"); } else if (memcmp(&config[17], "on", 2) == 0) { @@ -4867,7 +4994,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } const uint8_t previous_enabled = _prefs->bridge_enabled; - const bool previous_running = _callbacks->isRs232BridgeRunning(); + const bool previous_running = previous_enabled != 0; _prefs->bridge_enabled = enable; const bool applied = _callbacks->setRs232BridgeState(enable); if (applied && trySavePrefs()) { @@ -4942,7 +5069,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (mesh::cli::parseUnsignedIntegerStrict(&config[12], baud) && baud >= 9600 && baud <= BRIDGE_MAX_BAUD) { const uint32_t previous_baud = _prefs->bridge_baud; - const bool previous_running = _callbacks->isRs232BridgeRunning(); + const bool previous_running = +#ifdef WITH_MQTT_BRIDGE + _prefs->rs232_bridge_enabled != 0; +#else + _prefs->bridge_enabled != 0; +#endif _prefs->bridge_baud = baud; const bool applied = #ifdef WITH_MQTT_BRIDGE @@ -4994,7 +5126,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } const uint8_t previous_uart = _prefs->bridge_uart; - const bool previous_running = _callbacks->isRs232BridgeRunning(); + const bool previous_running = +#ifdef WITH_MQTT_BRIDGE + _prefs->rs232_bridge_enabled != 0; +#else + _prefs->bridge_enabled != 0; +#endif _prefs->bridge_uart = (uint8_t)uart; const bool applied = #ifdef WITH_MQTT_BRIDGE @@ -5565,6 +5702,15 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "rx" : "tx"); #endif #ifdef WITH_RS232_BRIDGE + } else if (configKeyEquals(config, "rs232.heartbeat")) { + sprintf(reply, "> %s", _prefs->rs232_heartbeat ? "on" : "off"); + } else if (configKeyEquals(config, "rs232.autosleep")) { + sprintf(reply, "> %s", _prefs->rs232_autosleep == 2 ? "force" + : _prefs->rs232_autosleep == 1 ? "on" : "off"); + } else if (configKeyEquals(config, "rs232.idle")) { + sprintf(reply, "> %us", static_cast(_prefs->rs232_idle_seconds)); + } else if (configKeyEquals(config, "rs232.status")) { + _callbacks->formatRs232Status(reply); } else if (configKeyEquals(config, "rs232.enabled")) { sprintf(reply, "> %s", #ifdef WITH_MQTT_BRIDGE diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 109ac1616..72fcf1c2e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #if MESH_ENABLE_FLEET_CONTROL @@ -213,6 +214,12 @@ public: // retain bridge_enabled as their legacy alias and mirror it on save. // Appended after the profile markers, never beside the legacy bridge byte. uint8_t rs232_bridge_enabled = 0; + // Default standby is negotiated by a valid heartbeat reply. Stock peers + // never arm it. Explicit force (2) also permits unconfirmed-link standby. + // Stored in a checked append-only tail, never in the legacy bridge layout. + uint8_t rs232_autosleep = 1; + uint8_t rs232_heartbeat = 1; + uint16_t rs232_idle_seconds = 300; // Appended after the ESP-NOW intent. Zero keeps the board's legacy cadence. uint16_t gps_sync_interval_hours = 0; uint8_t retry_preset = 0; @@ -352,6 +359,11 @@ private: def("format", _parent->bridge_format); def("usb_log", _parent->usb_logging_enabled); def("usb_dbg", _parent->usb_debug_enabled); +#ifdef WITH_RS232_BRIDGE + def("rs232_heartbeat", _parent->rs232_heartbeat); + def("rs232_autosleep", _parent->rs232_autosleep); + def("rs232_idle", _parent->rs232_idle_seconds); +#endif } public: @@ -623,6 +635,7 @@ public: virtual bool restartRs232Bridge() { return restartBridge(); } virtual bool isEspNowBridgeRunning() { return isBridgeRunning(); } virtual bool isRs232BridgeRunning() const { return isBridgeRunning(); } + virtual void formatRs232Status(char* reply) { strcpy(reply, "RS232 unavailable"); } virtual void restartBridgeSlot(int slot) { // Default: fall back to full restart @@ -840,6 +853,7 @@ class CommonCLI { bool _common_save_succeeded = false; uint32_t _prefs_save_failures = 0; mesh::RadioProfileCLI _radio_profiles; + mesh::MorseReplyHandoff _morse_reply; #if MESH_ENABLE_FLEET_CONTROL mesh::FleetChannel* _fleet_channel = nullptr; // allocate only when enrolled bool handleFleetCommand(const char* command, char* reply); @@ -900,6 +914,8 @@ public: uint16_t preamble); mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; } const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; } + mesh::MorseReplyHandoff& morseReply() { return _morse_reply; } + const mesh::MorseReplyHandoff& morseReply() const { return _morse_reply; } #if MESH_ENABLE_FLEET_CONTROL mesh::FleetChannel* fleetChannel() { return _fleet_channel; } #endif diff --git a/src/helpers/MorseCLI.h b/src/helpers/MorseCLI.h new file mode 100644 index 000000000..3002c1cea --- /dev/null +++ b/src/helpers/MorseCLI.h @@ -0,0 +1,430 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace mesh { + +inline const char* morseUsageError() { +#if MESH_ENABLE_MORSE_RX + return "Error: use morse send | listen | read [offset] | stats | clear | stop"; +#else + return "Error: use morse send | stop"; +#endif +} + +// Intercept only an explicitly authenticated remote callback. Local USB/BLE +// commands keep immediate listening; a nonzero timestamp alone is not authority. +inline bool handleMorseReplyCommand(MorseReplyHandoff& handoff, Radio* radio, + const char* command, char* reply, + size_t capacity, uint32_t now) { +#if MESH_ENABLE_MORSE_RX + if (!command || !reply || !capacity) return false; + const char* key = command; + bool query = false, setting = false; + if (!strncmp(key, "get ", 4)) { query = true; key += 4; } + else if (!strncmp(key, "set ", 4)) { setting = true; key += 4; } + const size_t key_length = strcspn(key, " \t"); + const bool base = key_length == 5 && !strncmp(key, "morse", 5); + const char* action = cli::skipRecentRepeaterSpaces(key + key_length); + const size_t action_length = strcspn(action, " \t"); + const char* argument = cli::skipRecentRepeaterSpaces(action + action_length); + const bool stop = base && !query && !setting && action_length == 4 + && !strncmp(action, "stop", 4) && !*argument; + const bool listen = base && !query && !setting && action_length == 6 + && !strncmp(action, "listen", 6); + if (stop && handoff.pending()) { + handoff.cancel(); + snprintf(reply, capacity, "OK - pending Morse RX cancelled; packet radio unchanged"); + return true; + } + if (handoff.pending()) { + if (base && !setting && !*action) { + snprintf(reply, capacity, "> Morse RX queued; waiting for command reply to transmit; use morse stop to cancel"); + return true; + } + // Prevent a second explicit RF/settings action from replacing the staged + // request. Read-only capture/status queries remain available. + const bool read_only_morse = base && ((action_length == 4 && !strncmp(action, "read", 4)) + || (action_length == 5 && !strncmp(action, "stats", 5)) +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + || (action_length == 5 && !strncmp(action, "trace", 5)) +#endif + ); + const bool cw = (key_length == 2 && !strncmp(key, "cw", 2)) + || (key_length == 3 && !strncmp(key, "cw2", 3)); + if (setting || (base && !query && !read_only_morse) + || (!query && cw && *action) || !strncmp(key, "tempradio", 9) + || (!query && key_length == 5 && !strncmp(key, "radio", 5) && *action) + || (!query && key_length == 6 && !strncmp(key, "radio2", 6) && *action)) { + snprintf(reply, capacity, "Error: Morse reply handoff pending; use morse stop or retry later"); + return true; + } + } + if (!handoff.remoteCommand() || !listen) return false; + float minutes = 0; + if (!radio || !radio->supportsMorseRx() || !morseSettingsUsable() + || !cli::parseDecimalStrict(argument, minutes) || minutes <= 0 || minutes > 5) return false; + const uint32_t duration = uint32_t(minutes * 60000.0f + 0.5f); + if (duration < 1000 || duration > 300000) return false; + if (radio->isCarrierWaveActive() || radio->isMorseTxActive() || radio->isMorseRxActive()) { + snprintf(reply, capacity, "Error: radio busy; stop Morse/CW or retry when idle"); + return true; + } + const auto& settings = morseSettings(); + if (!handoff.stage(settings.frequency_khz, settings.wpm, duration, now)) { + snprintf(reply, capacity, "Error: Morse reply handoff pending; retry later"); + return true; + } + snprintf(reply, capacity, + "OK - Morse RX queued %.3f MHz for %.2f min; starts after this reply transmits; keep first 0.5 s quiet", + settings.frequencyMHz(), minutes); + return true; +#else + (void)handoff; (void)radio; (void)command; (void)reply; (void)capacity; (void)now; + return false; +#endif +} + +#if MESH_ENABLE_MORSE_RX +inline void formatMorseCapturePage(Radio* radio, const char* argument, + char* reply, size_t capacity) { + uint32_t offset = 0; + if (*argument && !cli::parseUnsignedIntegerStrict(argument, offset)) { + snprintf(reply, capacity, "Error: use morse read [character offset]"); + return; + } + const size_t total = radio->morseCaptureLength(); + // The current bounded capture is 4096 characters. Fixed-width page headers + // reserve their exact space before copying text into a single CLI reply. + if (total > MorseRx::CaptureCapacity || total > 9999 || offset > total) { + snprintf(reply, capacity, "Error: capture offset must be 0-%u", unsigned(total)); + return; + } + const int prefix = snprintf(reply, capacity, "> %04u/%04u next=---- truncated=%u ", + unsigned(offset), unsigned(total), unsigned(radio->morseCaptureTruncated())); + if (prefix < 0 || size_t(prefix) + 1 >= capacity) { + snprintf(reply, capacity, "Error: reply buffer too small for capture page"); + return; + } + const size_t copied = radio->readMorseCapture(offset, reply + prefix, capacity - prefix); + const size_t next = offset + copied; + char following[5]; + if (next >= total) memcpy(following, "END ", 5); + else snprintf(following, sizeof(following), "%04u", unsigned(next)); + char* marker = strstr(reply, "next=----"); + if (marker) memcpy(marker + 5, following, 4); +} + +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +inline void formatMorseTracePage(Radio* radio, const char* argument, + char* reply, size_t capacity) { + uint32_t offset = 0; + if (*argument && !cli::parseUnsignedIntegerStrict(argument, offset)) { + snprintf(reply, capacity, "Error: use morse trace [retained entry offset]"); + return; + } + const size_t length = radio->morseTraceLength(); + if (length > 512 || offset > length) { + snprintf(reply, capacity, "Error: trace offset must be 0-%u", unsigned(length)); + return; + } + // `t` is the absolute total entry count; offset is relative to the retained + // oldest sample. A changed t between pages identifies a moving HIL window. + const int prefix = snprintf(reply, capacity, "> %03u/%03u t=%lu n=--- x=%u ", + unsigned(offset), unsigned(length), (unsigned long)radio->morseTraceTotal(), + unsigned(radio->morseTraceTruncated())); + if (prefix < 0 || size_t(prefix) + 1 >= capacity) { + snprintf(reply, capacity, "Error: reply buffer too small for trace page"); + return; + } + size_t used = prefix, next = offset; + for (unsigned count = 0; count < 8 && next < length; ++count) { + MorseRssiTraceEntry entry{}; + if (!radio->readMorseTrace(next, entry)) { + snprintf(reply, capacity, "Error: trace changed; retry from offset 0"); + return; + } + char row[32]; + const int bytes = snprintf(row, sizeof(row), "%lu,%d,%u;", + (unsigned long)entry.millis, int(entry.rssi_dbm), unsigned(entry.flags)); + if (bytes < 0 || size_t(bytes) >= sizeof(row) || used + size_t(bytes) >= capacity) break; + memcpy(reply + used, row, bytes); + used += bytes; + reply[used] = 0; + ++next; + } + if (next == offset && next < length) { + snprintf(reply, capacity, "Error: reply buffer too small for trace entry"); + return; + } + char following[4]; + if (next >= length) memcpy(following, "END", 4); + else snprintf(following, sizeof(following), "%03u", unsigned(next)); + char* marker = strstr(reply, "n=---"); + if (marker) memcpy(marker + 2, following, 3); +} +#endif +#endif // MESH_ENABLE_MORSE_RX + +// Only explicit commands enter TX/listening. Chat, private messages, captured +// Morse and boot never trigger a transmission. Caller authentication applies. +inline bool handleMorseCommand(Radio* radio, const char* command, + char* reply, size_t capacity) { + if (!command || !reply || !capacity) return false; + bool query = false, setting = false; + const char* key = command; + if (!strncmp(key, "get ", 4)) { query = true; key += 4; } + else if (!strncmp(key, "set ", 4)) { setting = true; key += 4; } + const size_t length = strcspn(key, " \t"); + const bool base = length == 5 && !strncmp(key, "morse", 5); + const bool frequency = length == 10 && !strncmp(key, "morse.freq", 10); + const bool speed = length == 9 && !strncmp(key, "morse.wpm", 9); + if (!base && !frequency && !speed) return false; + if (!radio || (!radio->supportsMorseTx() && !radio->supportsMorseRx())) { + snprintf(reply, capacity, "Error: Morse unsupported on this radio"); + return true; + } + const char* args = cli::skipRecentRepeaterSpaces(key + length); + const auto& current = morseSettings(); + if ((query || (base && !*args && !setting)) && !morseSettingsUsable()) { + snprintf(reply, capacity, "Error: saved Morse settings unavailable; verify and save morse.freq"); + return true; + } + if (frequency || speed) { + if (query) { + if (*args) snprintf(reply, capacity, "Error: unexpected argument"); + else if (frequency) snprintf(reply, capacity, "> %.3f", current.frequencyMHz()); + else snprintf(reply, capacity, "> %u", unsigned(current.wpm)); + return true; + } + if (!setting) { + snprintf(reply, capacity, "Error: use get/set morse.%s", frequency ? "freq" : "wpm"); + return true; + } + if (speed && !morseSettingsUsable()) { + snprintf(reply, capacity, "Error: verify and save morse.freq before changing Morse WPM"); + return true; + } + if (radio->isCarrierWaveActive() || radio->isMorseTxActive() || radio->isMorseRxActive()) { + snprintf(reply, capacity, "Error: radio busy; stop Morse/CW before changing settings"); + return true; + } + auto candidate = current; + if (frequency) { + float value = 0; + if (!cli::parseDecimalStrict(args, value) + || value < MorseMinFrequencyKHz / 1000.0f + || value > MorseMaxFrequencyKHz / 1000.0f) { + snprintf(reply, capacity, "Error: morse.freq must be 100-1000 MHz"); + return true; + } + candidate.frequency_khz = uint32_t(value * 1000.0f + 0.5f); + } else { + uint32_t value = 0; + if (!cli::parseUnsignedIntegerStrict(args, value) + || value < MorseMinWpm || value > MorseMaxWpm) { + snprintf(reply, capacity, "Error: morse.wpm must be %u-%u", + unsigned(MorseMinWpm), unsigned(MorseMaxWpm)); + return true; + } + candidate.wpm = uint8_t(value); + } + if (!saveMorseSettings(candidate)) { + snprintf(reply, capacity, "Error: Morse settings could not be saved"); + } else if (frequency) { + snprintf(reply, capacity, "OK - morse.freq %.3f MHz", candidate.frequencyMHz()); + } else snprintf(reply, capacity, "OK - morse.wpm %u", unsigned(candidate.wpm)); + return true; + } + if ((!*args && !setting)) { +#if MESH_ENABLE_MORSE_RX + if (radio->isMorseRxActive()) { + const uint32_t left = radio->morseRxRemainingMillis(); + if (radio->morseRxRecovering()) snprintf(reply, capacity, + "> Morse RX stopped; packet-radio recovery pending; captured=%u; truncated=%u", + unsigned(radio->morseCaptureLength()), unsigned(radio->morseCaptureTruncated())); + else snprintf(reply, capacity, + "> RX%s %.3f MHz; BW %.2f kHz; %lu.%03lu seconds left; captured=%u; truncated=%u", + radio->morseRxLearning() ? " learning/keep quiet" : "", + radio->morseRxFrequency(), radio->morseRxBandwidthKHz(), + (unsigned long)(left / 1000), (unsigned long)(left % 1000), + unsigned(radio->morseCaptureLength()), unsigned(radio->morseCaptureTruncated())); + } else +#endif + if (radio->isMorseTxActive()) { + const uint32_t left = radio->morseTxRemainingMillis(); + if (!left) snprintf(reply, capacity, "> RF stop/receive recovery pending; %.3f MHz; TX only", + radio->morseTxFrequency()); + else snprintf(reply, capacity, "> on; %.3f MHz; %u WPM; %lu.%03lu seconds left; TX only", + radio->morseTxFrequency(), unsigned(radio->morseTxWpm()), + (unsigned long)(left / 1000), (unsigned long)(left % 1000)); + } +#if MESH_ENABLE_MORSE_RX + else if (radio->supportsMorseRx()) snprintf(reply, capacity, + "> off; %.3f MHz; %u WPM; captured=%u; truncated=%u%s; morse read", + current.frequencyMHz(), unsigned(current.wpm), + unsigned(radio->morseCaptureLength()), unsigned(radio->morseCaptureTruncated()), + radio->morseCaptureTruncated() ? "; capture quota reached; RX ended early" : ""); +#endif + else snprintf(reply, capacity, "> off; %.3f MHz; %u WPM; TX only", + current.frequencyMHz(), unsigned(current.wpm)); + return true; + } + if (query || setting) { + snprintf(reply, capacity, "%s", morseUsageError()); + return true; + } + const size_t action_length = strcspn(args, " \t"); + const char* text = cli::skipRecentRepeaterSpaces(args + action_length); + const bool stop = action_length == 4 && !strncmp(args, "stop", 4) && !*text; + const bool send = action_length == 4 && !strncmp(args, "send", 4) && *text; + const bool listen = action_length == 6 && !strncmp(args, "listen", 6); + const bool read = action_length == 4 && !strncmp(args, "read", 4); + const bool clear = action_length == 5 && !strncmp(args, "clear", 5) && !*text; + const bool stats = action_length == 5 && !strncmp(args, "stats", 5) && !*text; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + const bool trace = action_length == 5 && !strncmp(args, "trace", 5); +#else + const bool trace = false; +#endif + if (!stop && !send && !listen && !read && !clear && !stats && !trace) { + snprintf(reply, capacity, "%s", morseUsageError()); + return true; + } + if ((listen || read || clear || stats || trace) +#if MESH_ENABLE_MORSE_RX + && !radio->supportsMorseRx() +#endif + ) { + snprintf(reply, capacity, "Error: Morse RX unsupported on this radio"); + return true; + } +#if MESH_ENABLE_MORSE_RX + if (read) { + formatMorseCapturePage(radio, text, reply, capacity); + return true; + } +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (trace) { + formatMorseTracePage(radio, text, reply, capacity); + return true; + } +#endif + if (stats) { + MorseRxDiagnostics data{}; + if (!radio->morseRxDiagnostics(data)) snprintf(reply, capacity, "> no Morse RX statistics"); + else snprintf(reply, capacity, + "> samples=%lu; noise=%.1f; min=%.1f; max=%.1f; last=%.1f dBm; unit=%u ms; gap=%lu ms; bad=%u", + (unsigned long)data.samples, data.noise_dbm, data.minimum_dbm, data.maximum_dbm, + data.last_dbm, unsigned(data.unit_ms), (unsigned long)data.max_sample_gap_ms, + unsigned(data.read_errors)); + return true; + } + if (clear) { + if (radio->isMorseRxActive() || radio->isMorseTxActive()) + snprintf(reply, capacity, "Error: stop Morse before clearing capture"); + else { radio->clearMorseCapture(); snprintf(reply, capacity, "OK - Morse capture cleared"); } + return true; + } + if (stop && radio->isMorseRxActive()) { + const auto result = radio->stopMorseRx(); + if (result == MorseRxResult::Stopped) snprintf(reply, capacity, + "OK - Morse RX stopped; capture retained%s", radio->isInRecvMode() ? "; receive resumed" : ""); + else snprintf(reply, capacity, "Error: Morse RX stop/receive recovery pending; capture retained"); + return true; + } +#endif // MESH_ENABLE_MORSE_RX + // Do not stop an unrelated CW meter test through the Morse command. + if (stop && !radio->isMorseTxActive()) { + snprintf(reply, capacity, "OK - Morse not running"); + return true; + } + if ((send || listen) && !morseSettingsUsable()) { + snprintf(reply, capacity, "Error: saved Morse settings unavailable; verify and save morse.freq"); + return true; + } +#if MESH_ENABLE_MORSE_RX + if (listen) { + float minutes = 0; + if (!cli::parseDecimalStrict(text, minutes) || minutes <= 0 || minutes > 5) { + snprintf(reply, capacity, "Error: use morse listen "); + return true; + } + const uint32_t duration = uint32_t(minutes * 60000.0f + 0.5f); + if (duration < 1000 || duration > 300000) { + snprintf(reply, capacity, "Error: Morse listening must last 1-300 seconds"); + return true; + } + const auto result = radio->startMorseRx(current.frequencyMHz(), current.wpm, duration); + switch (result) { + case MorseRxResult::Started: + snprintf(reply, capacity, "OK - Morse RX %.3f MHz; BW %.2f kHz; %.2f min; keep first 0.5 s quiet; LoRa paused", + current.frequencyMHz(), radio->morseRxBandwidthKHz(), minutes); + break; + case MorseRxResult::Busy: + snprintf(reply, capacity, "Error: radio busy; stop Morse/CW or retry when idle"); break; + case MorseRxResult::Unsupported: + snprintf(reply, capacity, "Error: Morse RX unsupported on this radio"); break; + case MorseRxResult::InvalidFrequency: + snprintf(reply, capacity, "Error: Morse frequency unsupported by this radio"); break; + case MorseRxResult::Overlap: + snprintf(reply, capacity, "Error: Morse frequency overlaps radio or radio2; change morse.freq"); break; + case MorseRxResult::InvalidSpeed: + snprintf(reply, capacity, "Error: morse.wpm must be %u-%u", unsigned(MorseMinWpm), unsigned(MorseMaxWpm)); break; + case MorseRxResult::InvalidDuration: + snprintf(reply, capacity, "Error: Morse listening must last 1-300 seconds"); break; + default: + snprintf(reply, capacity, "Error: Morse RX transition failed; check radio recovery"); break; + } + return true; + } +#endif + if (send && !radio->supportsMorseTx()) { + snprintf(reply, capacity, "Error: Morse TX unsupported on this radio"); + return true; + } + const auto result = stop ? radio->stopMorseTx() + : radio->startMorseTx(current.frequencyMHz(), current.wpm, text); + switch (result) { + case MorseTxResult::Started: + snprintf(reply, capacity, "OK - Morse TX %.3f MHz at %u WPM; RX paused; use morse stop", + current.frequencyMHz(), unsigned(current.wpm)); + break; + case MorseTxResult::Stopped: + snprintf(reply, capacity, "OK - Morse stopped%s", + radio->isInRecvMode() ? "; receive resumed" : ""); + break; + case MorseTxResult::Busy: + snprintf(reply, capacity, "Error: radio busy; stop Morse/CW or retry when idle"); + break; + case MorseTxResult::Unsupported: + snprintf(reply, capacity, "Error: Morse TX unsupported on this radio"); + break; + case MorseTxResult::InvalidFrequency: + snprintf(reply, capacity, "Error: Morse frequency unsupported by this radio"); + break; + case MorseTxResult::Overlap: + snprintf(reply, capacity, "Error: Morse frequency overlaps radio or radio2; change morse.freq"); + break; + case MorseTxResult::InvalidText: + snprintf(reply, capacity, "Error: supported Morse ASCII only (80 characters max)"); + break; + case MorseTxResult::InvalidSpeed: + snprintf(reply, capacity, "Error: morse.wpm must be %u-%u", unsigned(MorseMinWpm), unsigned(MorseMaxWpm)); + break; + case MorseTxResult::TooLong: + snprintf(reply, capacity, "Error: limit 80 characters/180 seconds; shorten text or raise WPM"); + break; + default: + snprintf(reply, capacity, "Error: Morse failed; check radio recovery"); + break; + } + return true; +} + +} // namespace mesh diff --git a/src/helpers/MorseLoopPolicy.h b/src/helpers/MorseLoopPolicy.h new file mode 100644 index 000000000..f4b918bfb --- /dev/null +++ b/src/helpers/MorseLoopPolicy.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace mesh { + +// Keyed Morse and RSSI-envelope capture require a responsive foreground loop. +// Defer optional background work without advancing its deadlines; the next +// ordinary pass resumes it. Keep voltage safety/watchdogs and command I/O live. +// An unkeyed CW meter test does not opt into this scheduling policy. +inline bool morseTimingActive(const Radio* radio) { + return radio && (radio->isMorseTxActive() || radio->isMorseRxActive()); +} + +} // namespace mesh diff --git a/src/helpers/MorseReplyHandoff.h b/src/helpers/MorseReplyHandoff.h new file mode 100644 index 000000000..2cc7580c1 --- /dev/null +++ b/src/helpers/MorseReplyHandoff.h @@ -0,0 +1,159 @@ +#pragma once + +#include +#include +#if MESH_ENABLE_MORSE_RX +#include +#include +#endif + +namespace mesh { + +// A remote listen command must acknowledge on the packet radio before giving +// that radio to Morse RX. Keep exact packet ownership with Dispatcher; never +// infer delivery from a timestamp, ciphertext, a queue delay or another reply. +#if MESH_ENABLE_MORSE_RX +class MorseReplyHandoff { + TempRadioReplyBarrier barrier_; + const void* prepared_reply_ = nullptr; + const void* prepared_copy_ = nullptr; + const uint8_t* command_actor_ = nullptr; // valid only inside the authenticated callback + uint8_t actor_[32] = {}; + uint32_t frequency_khz_ = 0, duration_ms_ = 0, deadline_ = 0, generation_ = 0; + uint32_t start_deadline_ = 0, retry_at_ = 0; + uint8_t wpm_ = 0; + bool pending_ = false, delivered_ = false; + bool start_wait_ = false, retry_pending_ = false; + +public: + static constexpr uint32_t ReplyDeadlineMillis = 30000; + static constexpr uint32_t StartDeadlineMillis = 10000; + void beginCommand(const uint8_t* actor) { command_actor_ = actor; } + void endCommand() { command_actor_ = nullptr; } + bool remoteCommand() const { return command_actor_ != nullptr; } + bool pending() const { return pending_; } + uint32_t generation() const { return generation_; } + const uint8_t* actor() const { return actor_; } + bool contains(const void* packet) const { return barrier_.contains(packet); } + bool ownsReply(const void* packet) const { + return pending_ && packet && (packet == prepared_reply_ || packet == prepared_copy_); + } + + bool stage(uint32_t frequency_khz, uint8_t wpm, uint32_t duration_ms, uint32_t now) { + if (!command_actor_ || pending_) return false; + memcpy(actor_, command_actor_, sizeof(actor_)); + frequency_khz_ = frequency_khz; + wpm_ = wpm; + duration_ms_ = duration_ms; + deadline_ = now + ReplyDeadlineMillis; + ++generation_; + pending_ = true; + delivered_ = false; + start_deadline_ = retry_at_ = 0; + start_wait_ = retry_pending_ = false; + barrier_.clear(); + prepared_reply_ = prepared_copy_ = nullptr; + return true; + } + void prepareReply(const void* packet) { + if (pending_) { + prepared_reply_ = packet; + prepared_copy_ = nullptr; + barrier_.prepare(packet); + } + } + void armReply(const void* packet, bool queued) { + if (!pending_) return; + if (!queued || !packet) { cancel(); return; } + barrier_.arm(packet); + } + void copy(const void* original, const void* packet) { + if (pending_) { + if (original && original == prepared_reply_) prepared_copy_ = packet; + barrier_.trackCopy(original, packet); + } + } + void complete(const void* packet) { + if (barrier_.contains(packet)) { + if (packet == prepared_reply_) prepared_reply_ = nullptr; + if (packet == prepared_copy_) prepared_copy_ = nullptr; + } + if (pending_ && barrier_.complete(packet) && !barrier_.waiting()) delivered_ = true; + } + void fail(const void* packet) { + if (!pending_) return; + if (barrier_.contains(packet)) { + if (packet == prepared_reply_) prepared_reply_ = nullptr; + if (packet == prepared_copy_) prepared_copy_ = nullptr; + } + if (barrier_.fail(packet)) cancel(); + else if (!barrier_.waiting() && barrier_.succeeded()) delivered_ = true; + } + void cancel() { + pending_ = delivered_ = false; + barrier_.clear(); + prepared_reply_ = prepared_copy_ = nullptr; + } + + // Recheck the complete identity, saved settings and driver admission after + // acknowledgement, not the possibly compacted ACL slot from command parsing. + // The driver revalidates both live profiles before any hardware transition. + bool service(Radio* radio, uint32_t now, bool authorized, bool settings_usable, + uint32_t current_frequency_khz, uint8_t current_wpm) { + if (!pending_) return false; + if (!authorized || !settings_usable + || current_frequency_khz != frequency_khz_ || current_wpm != wpm_ + || int32_t(now - deadline_) >= 0) { + cancel(); + return false; + } + if (!delivered_) return false; + if (!start_wait_) { + start_deadline_ = now + StartDeadlineMillis; + start_wait_ = true; + } + if (int32_t(now - start_deadline_) >= 0) { cancel(); return false; } + if (retry_pending_ && int32_t(now - retry_at_) < 0) return false; + const auto result = radio + ? radio->startMorseRx(frequency_khz_ / 1000.0f, wpm_, duration_ms_) + : MorseRxResult::Failed; + if (result == MorseRxResult::Busy) { + retry_at_ = now + 100; + retry_pending_ = true; + return false; + } + // Completion already drained the exact barrier, so a late callback or + // cached replay cannot start another session. Hard admission failures do + // not retry; transient packet-radio Busy is bounded and reauthorized. + cancel(); + return result == MorseRxResult::Started; + } +}; +#else +// Preserve the role callback API without retaining RX state or ACK handoff +// machinery on small-flash builds whose receiver is explicitly disabled. +// No command can stage a listen; Morse TX and unrelated replies are unchanged. +class MorseReplyHandoff { +public: + static constexpr uint32_t ReplyDeadlineMillis = 30000; + static constexpr uint32_t StartDeadlineMillis = 10000; + void beginCommand(const uint8_t*) {} + void endCommand() {} + bool remoteCommand() const { return false; } + bool pending() const { return false; } + uint32_t generation() const { return 0; } + const uint8_t* actor() const { return nullptr; } + bool contains(const void*) const { return false; } + bool ownsReply(const void*) const { return false; } + bool stage(uint32_t, uint8_t, uint32_t, uint32_t) { return false; } + void prepareReply(const void*) {} + void armReply(const void*, bool) {} + void copy(const void*, const void*) {} + void complete(const void*) {} + void fail(const void*) {} + void cancel() {} + bool service(Radio*, uint32_t, bool, bool, uint32_t, uint8_t) { return false; } +}; +#endif // MESH_ENABLE_MORSE_RX + +} // namespace mesh diff --git a/src/helpers/MorseRx.h b/src/helpers/MorseRx.h new file mode 100644 index 000000000..3d702e0ad --- /dev/null +++ b/src/helpers/MorseRx.h @@ -0,0 +1,421 @@ +#pragma once + +#include "MorseTx.h" +#include + +#ifndef MESH_ENABLE_MORSE_RX +// Current integrated STM32WL targets have 256 KiB flash. Keep their complete +// existing feature set and Morse TX; timed capture is an explicit larger-build +// opt-in rather than moving the deployed filesystem boundary. +#if defined(STM32_PLATFORM) +#define MESH_ENABLE_MORSE_RX 0 +#else +#define MESH_ENABLE_MORSE_RX 1 +#endif +#endif + +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL \ + && defined(MESH_MORSE_HIL_FAST_OFF_DEBOUNCE) \ + && MESH_MORSE_HIL_FAST_OFF_DEBOUNCE != 0 \ + && MESH_MORSE_HIL_FAST_OFF_DEBOUNCE != 1 +#error "HIL Morse fast OFF debounce flag must be 0 or 1" +#endif + +namespace mesh { + +enum class MorseRxResult : uint8_t { + Started, Stopped, Busy, Unsupported, InvalidFrequency, Overlap, + InvalidSpeed, InvalidDuration, Failed +}; + +struct MorseRxDiagnostics { + float noise_dbm = NAN, last_dbm = NAN, minimum_dbm = NAN, maximum_dbm = NAN; + uint32_t samples = 0; + uint32_t max_sample_gap_ms = 0; + uint16_t unit_ms = 0; + uint8_t read_errors = 0; +}; + +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +struct MorseRssiTraceEntry { + uint32_t millis; + int8_t rssi_dbm; + uint8_t flags; // raw key=1, committed key=2, candidate=4, learning=8 +}; + +// Lab-only sparse envelope diagnostics. Stored independently on admitted heap, +// never as an extra idle BSS buffer. Preserve the latest bounded window. +class MorseRssiTrace { +public: + static constexpr size_t Capacity = 512; + void clear() { _length = _next = 0; _total = _last_elapsed = 0; _triggered = _dropped = false; } + size_t length() const { return _length; } + uint32_t total() const { return _total; } + bool truncated() const { return _dropped || _total > Capacity; } + bool read(size_t offset, MorseRssiTraceEntry& out) const { + if (offset >= _length) return false; + size_t index = (_next + Capacity - 1) % Capacity; + uint32_t elapsed = _last_elapsed; + for (size_t position = _length - 1; position > offset; --position) { + elapsed -= _entries[index].delta_ms; + index = (index + Capacity - 1) % Capacity; + } + out = {elapsed, _entries[index].rssi_dbm, _entries[index].flags}; + return true; + } + void sample(float rssi, uint32_t elapsed, uint8_t flags) { + if (flags & 8) return; + // Debounced key edges retain the actual decoder transitions. Suppress + // intermediate debounce samples so a full message fits, but retain large + // quiet-floor/power changes for AGC/envelope diagnosis. + if (!_triggered) { + if (!(flags & 2)) return; + _triggered = true; + } + if (flags & 4) return; + int16_t value = static_cast(rssi >= 0 ? rssi + 0.5f : rssi - 0.5f); + if (value < -128) value = -128; + if (value > 127) value = 127; + if (_length) { + const auto& previous = _entries[(_next + Capacity - 1) % Capacity]; + const int16_t delta = value - previous.rssi_dbm; + if (((flags ^ previous.flags) & 2) == 0 && delta > -6 && delta < 6 + && elapsed - _last_elapsed < 60000U) return; + } + // Quiet anchors at sixty seconds bound the stored deltas even in a full + // five-minute capture. Public reads reconstruct exact 32-bit timestamps. + uint32_t delta_ms = _length ? elapsed - _last_elapsed : 0; + if (delta_ms > UINT16_MAX) { + // A long stalled sampler supplies no measurements for the missing span. + // Restart the retained window, explicitly marked incomplete, rather + // than wrapping the delta or inventing intermediate RF measurements. + _length = _next = 0; + _dropped = true; + delta_ms = 0; + } + _entries[_next] = {static_cast(delta_ms), static_cast(value), flags}; + _next = (_next + 1) % Capacity; + if (_length < Capacity) ++_length; + ++_total; + _last_elapsed = elapsed; + } +private: + struct StoredEntry { uint16_t delta_ms; int8_t rssi_dbm; uint8_t flags; }; + static_assert(sizeof(StoredEntry) == 4, "HIL stored entries must remain four bytes"); + StoredEntry _entries[Capacity]; + uint16_t _length = 0, _next = 0; + uint32_t _last_elapsed = 0, _total = 0; + bool _triggered = false, _dropped = false; +}; +#endif + +// RSSI envelope decoding, not an RF demodulator or an artificial narrow +// filter. The real receiver supplies its instantaneous power measurements. +// Quiet arming samples establish the noise floor before the sender keys up. +class MorseRx { +public: +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_SAMPLE_INTERVAL_MS) + static constexpr uint32_t SampleIntervalMillis = MESH_MORSE_HIL_SAMPLE_INTERVAL_MS; + static_assert(MESH_MORSE_HIL_SAMPLE_INTERVAL_MS >= 1 + && MESH_MORSE_HIL_SAMPLE_INTERVAL_MS <= 10, + "HIL Morse RSSI sample interval must be 1..10 milliseconds"); +#else + static constexpr uint32_t SampleIntervalMillis = 2; +#endif +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_RX_BITRATE_KBPS) + static constexpr float ReceiverBitRateKbps = MESH_MORSE_HIL_RX_BITRATE_KBPS; +#else + static constexpr float ReceiverBitRateKbps = 1.2f; +#endif + static_assert(ReceiverBitRateKbps >= 0.6f && ReceiverBitRateKbps <= 300.0f, + "HIL GFSK receiver clock must remain in the supported range"); + static constexpr uint32_t MinDurationMillis = 1000UL; + static constexpr uint32_t MaxDurationMillis = 300000UL; + static constexpr uint32_t NoiseLearningMillis = 500UL; + static constexpr size_t CaptureCapacity = 4096; + // A startup estimator heuristic, not a guaranteed radio/AGC floor shift. + static constexpr float StartupFloorUncertaintyDb = 30.0f; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + void setTrace(MorseRssiTrace* trace) { _trace = trace; } +#endif + + static MorseRxResult validate(uint8_t wpm, uint32_t duration_ms) { + if (!MorseTx::unitMillis(wpm)) return MorseRxResult::InvalidSpeed; + if (duration_ms < MinDurationMillis || duration_ms > MaxDurationMillis) + return MorseRxResult::InvalidDuration; + return MorseRxResult::Started; + } + + MorseRxResult start(uint8_t wpm, uint32_t duration_ms, uint32_t now) { + const auto result = validate(wpm, duration_ms); + if (result != MorseRxResult::Started) return result; + clear(); + _started = _last_edge = now; + _until = now + duration_ms; + _nominal_unit = _unit = MorseTx::unitMillis(wpm); + _noise = _signal = 0; + _samples = 0; + _last_sample_at = now; + _max_sample_gap_ms = 0; + _last_rssi = _minimum_rssi = _maximum_rssi = NAN; + _have_noise = _candidate_valid = _keyed = _floor_settled = false; + _active = true; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (_trace) _trace->clear(); +#endif + return MorseRxResult::Started; + } + + bool active() const { return _active; } + bool keyed() const { return _active && _keyed; } + bool learning(uint32_t now) const { + return _active && static_cast(now - _started) < NoiseLearningMillis; + } + uint32_t remainingMillis(uint32_t now) const { + if (!_active) return 0; + const int32_t left = static_cast(_until - now); + return left > 0 ? static_cast(left) : 0; + } + const char* text() const { return _capture; } + size_t length() const { return _length; } + bool truncated() const { return _truncated; } + float noiseDbm() const { return _have_noise ? _noise : -200.0f; } + uint16_t unitMillis() const { return _unit; } + void diagnostics(MorseRxDiagnostics& out) const { + out.noise_dbm = _have_noise ? _noise : NAN; + out.last_dbm = _last_rssi; + out.minimum_dbm = _minimum_rssi; + out.maximum_dbm = _maximum_rssi; + out.samples = _samples; + out.max_sample_gap_ms = _max_sample_gap_ms; + out.unit_ms = _unit; + out.read_errors = 0; + } + + size_t read(size_t offset, char* out, size_t capacity) const { + if (!out || !capacity) return 0; + if (offset >= _length) { out[0] = 0; return 0; } + size_t count = _length - offset; + if (count >= capacity) count = capacity - 1; + memcpy(out, _capture + offset, count); + out[count] = 0; + return count; + } + + void clear() { + _length = 0; + _capture[0] = 0; + _truncated = _bad_pattern = _letter_flushed = _word_flushed = false; + _code = 1; + _symbols = 0; + } + + void stop(uint32_t now) { + if (!_active) return; + // A keyed or debounce-pending tail may belong to an unfinished letter. + // Never manufacture its last dash merely because the capture timed out. + if (!_keyed && !_candidate_valid) flushLetter(); + _active = false; + _keyed = _candidate_valid = false; + _last_edge = now; + } + + bool service(uint32_t now) { + if (!_active) return false; + if (_samples) { + const uint32_t gap = static_cast(now - _last_sample_at); + if (gap > _max_sample_gap_ms) _max_sample_gap_ms = gap; + } + if (static_cast(now - _until) >= 0) { + stop(now); + return false; + } + if (!learning(now) && !_keyed && !_candidate_valid) flushGap(now); + return _active; + } + + void sample(float rssi_dbm, uint32_t now) { + if (!service(now) || !isfinite(rssi_dbm) || rssi_dbm < -180.0f || rssi_dbm > 20.0f) return; + _last_rssi = rssi_dbm; + _last_sample_at = now; + if (!_samples || rssi_dbm < _minimum_rssi) _minimum_rssi = rssi_dbm; + if (!_samples || rssi_dbm > _maximum_rssi) _maximum_rssi = rssi_dbm; + ++_samples; + if (!_have_noise) { + _have_noise = true; + _noise = rssi_dbm; + _signal = rssi_dbm + 12.0f; + } + if (learning(now)) { + _noise += (rssi_dbm - _noise) * 0.05f; + _signal = _noise + 12.0f; + return; + } + + const float span = _signal - _noise; + float rise = span * 0.5f; + if (rise < 6.0f) rise = 6.0f; + float threshold = _noise + rise - (_keyed ? 3.0f : 0.0f); + // Before the first confirmed OFF, some receivers still report a stale + // pre-carrier floor. Allow for a provisional floor shift without a hard + // signal-span discontinuity. This margin is a tested startup heuristic, + // not a chip guarantee; after a real OFF the ordinary midpoint takes over + // and tolerates strong within-mark fades without producing extra symbols. + if (_keyed && !_floor_settled) { + float provisional_fall = (span - StartupFloorUncertaintyDb) * 0.5f; + if (provisional_fall < 5.0f) provisional_fall = 5.0f; + if (threshold < _signal - provisional_fall) threshold = _signal - provisional_fall; + } + const bool raw_keyed = rssi_dbm >= threshold; + if (!_keyed && !raw_keyed && !_candidate_valid) { + // Drift slowly while quiet; follow a lower floor faster without letting + // an isolated power spike permanently raise the threshold above CW. + _noise += (rssi_dbm - _noise) * (rssi_dbm < _noise ? 0.05f : 0.01f); + // A falling RF envelope may commit OFF before it reaches the final + // valley. Keep the next rise anchored to the subsequently learned floor, + // not to a transient shoulder left by the previous strong mark. + _signal = _noise + 12.0f; + } + // Never adapt the mark estimate down toward quiet during OFF debounce: + // doing so moves its own threshold before the falling edge can commit. + if (_keyed && raw_keyed && rssi_dbm > _noise + 6.0f) + _signal += (rssi_dbm - _signal) * 0.1f; + + if (raw_keyed == _keyed) { _candidate_valid = false; traceSample(rssi_dbm, now, raw_keyed); return; } + if (!_candidate_valid || _candidate != raw_keyed) { + _candidate = raw_keyed; + _candidate_at = now; + _candidate_valid = true; + traceSample(rssi_dbm, now, raw_keyed); + return; + } + uint16_t debounce = _unit / 4; + uint16_t minimum_debounce = 2; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL \ + && defined(MESH_MORSE_HIL_FAST_OFF_DEBOUNCE) \ + && MESH_MORSE_HIL_FAST_OFF_DEBOUNCE == 1 + // Controlled fast-CW bench experiment: permit a one-ms falling-edge + // confirmation only for short nominal dots. Keep ON rejection and the + // ordinary quarter-dot rule intact; production ignores this lab flag. + if (!raw_keyed && _nominal_unit <= 12) minimum_debounce = 1; +#endif + if (debounce < minimum_debounce) debounce = minimum_debounce; + // A brief mark fade is not another symbol. Give OFF a little more time + // than ON, but retain a ten-ms ceiling so sparse quiet spikes cannot keep + // a very slow message keyed for an entire quarter-dot (up to 300 ms). + const uint16_t maximum_debounce = raw_keyed ? 5 : 10; + if (debounce > maximum_debounce) debounce = maximum_debounce; + if (static_cast(now - _candidate_at) < debounce) { + traceSample(rssi_dbm, now, raw_keyed); + return; + } + const uint32_t edge_at = _candidate_at; + _candidate_valid = false; + if (raw_keyed) { + flushGap(edge_at); + if (!_active) return; + _keyed = true; + // Seed immediately rather than taking a whole fast dot to train an EMA. + _signal = rssi_dbm; + _last_edge = edge_at; + _letter_flushed = _word_flushed = false; + } else { + recordMark(static_cast(edge_at - _last_edge)); + _keyed = false; + _noise = rssi_dbm; + _floor_settled = true; + _signal = _noise + 12.0f; // do not hide the next, weaker mark with an old peak + _last_edge = edge_at; + } + traceSample(rssi_dbm, now, raw_keyed); + } + +private: + char _capture[CaptureCapacity + 1] = {}; + uint32_t _started = 0, _until = 0, _last_edge = 0, _candidate_at = 0; + float _noise = 0, _signal = 0; + float _last_rssi = NAN, _minimum_rssi = NAN, _maximum_rssi = NAN; + uint32_t _samples = 0; + uint32_t _last_sample_at = 0, _max_sample_gap_ms = 0; + uint16_t _length = 0, _unit = 0, _nominal_unit = 0; + uint8_t _code = 1, _symbols = 0; + bool _active = false, _keyed = false, _have_noise = false; + bool _floor_settled = false; + bool _candidate = false, _candidate_valid = false; + bool _bad_pattern = false, _letter_flushed = false, _word_flushed = false, _truncated = false; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + MorseRssiTrace* _trace = nullptr; // lifetime belongs to the radio wrapper +#endif + + void traceSample(float rssi, uint32_t now, bool raw_keyed) { +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (_trace) _trace->sample(rssi, static_cast(now - _started), + (raw_keyed ? 1 : 0) | (_keyed ? 2 : 0) | (_candidate_valid ? 4 : 0) + | (learning(now) ? 8 : 0)); +#else + (void)rssi; (void)now; (void)raw_keyed; +#endif + } + + void append(char c) { + if (c == ' ' && (!_length || _capture[_length - 1] == ' ')) return; + if (_length >= CaptureCapacity) { capacityStop(); return; } + _capture[_length++] = c; + _capture[_length] = 0; + if (_length == CaptureCapacity) capacityStop(); + } + void capacityStop() { + // Full capture is an explicit early stop, not an invisible rolling/drop + // buffer. Retain the prefix, expose truncation, and resume the mesh radio. + _truncated = true; + _active = _keyed = _candidate_valid = false; + } + void flushLetter() { + if (!_symbols && !_bad_pattern) return; + char decoded = '?'; + if (!_bad_pattern) { + for (char c = '!'; c <= 'Z'; ++c) { + const uint8_t code = MorseTx::codeFor(c); + if (code && code == _code) { decoded = c; break; } + } + // The printable loop ends at Z; '_' is also an ITU punctuation code. + if (MorseTx::codeFor('_') == _code) decoded = '_'; + } + append(decoded); + _code = 1; + _symbols = 0; + _bad_pattern = false; + _letter_flushed = true; + } + void flushGap(uint32_t now) { + const uint32_t gap = static_cast(now - _last_edge); + // RSSI fall-edge delay lengthens marks while shortening the following + // gaps. Never feed that fixed delay back into an even longer gap threshold. + // Faster-peer adaptation still lowers the threshold; slower peers have + // longer physical gaps and remain distinguishable at the nominal timing. + const uint16_t gap_unit = _unit < _nominal_unit ? _unit : _nominal_unit; + if (gap * 2UL >= uint32_t(gap_unit) * 5UL && !_letter_flushed) flushLetter(); + if (gap >= uint32_t(gap_unit) * 6UL && !_word_flushed && _letter_flushed) { + append(' '); + _word_flushed = true; + } + } + void recordMark(uint32_t duration_ms) { + if (duration_ms * 3UL < _unit) return; // sub-third-dot glitches + if (duration_ms > uint32_t(_unit) * 6UL || _symbols >= 7) { + _bad_pattern = true; + return; + } + const bool dash = duration_ms >= uint32_t(_unit) * 2UL; + _code = uint8_t((_code << 1) | (dash ? 1 : 0)); + ++_symbols; + uint32_t observed = dash ? (duration_ms + 1) / 3 : duration_ms; + const uint32_t lower = uint32_t(_nominal_unit) * 3 / 4; + const uint32_t upper = uint32_t(_nominal_unit) * 5 / 4; + if (observed < lower) observed = lower; + if (observed > upper) observed = upper; + _unit = uint16_t((uint32_t(_unit) * 7 + observed + 4) / 8); + } +}; + +} // namespace mesh diff --git a/src/helpers/MorseSettings.cpp b/src/helpers/MorseSettings.cpp new file mode 100644 index 000000000..9263c65d5 --- /dev/null +++ b/src/helpers/MorseSettings.cpp @@ -0,0 +1,130 @@ +#include "MorseSettings.h" +#include "FilePresence.h" +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) +#include "ContactFileTransaction.h" +#endif + +namespace mesh { +namespace { +FILESYSTEM* settings_fs = nullptr; +MorseSettings settings; +bool writes_held = false; +bool settings_usable = false; +constexpr const char* Path = "/morse_prefs"; +constexpr const char* Backup = "/morse_prefs.bak"; + +enum class ReadResult : uint8_t { Missing, Valid, Invalid, Unreadable }; + +ReadResult read(const char* path, MorseSettings& result) { + if (!settings_fs) return ReadResult::Unreadable; + bool present = false; + if (!filePresence(settings_fs, path, present)) return ReadResult::Unreadable; + if (!present) return ReadResult::Missing; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file(*settings_fs); + if (!file.open(path, FILE_O_READ)) return ReadResult::Unreadable; +#else + File file = settings_fs->open(path, "r"); + if (!file) return ReadResult::Unreadable; +#endif + uint8_t image[MorseSettingsImageSize]; + const bool right_size = file.size() == sizeof(image); + const bool complete = right_size && file.read(image, sizeof(image)) == int(sizeof(image)); + file.close(); + if (!right_size) return ReadResult::Invalid; + if (!complete) return ReadResult::Unreadable; + return decodeMorseSettings(image, sizeof(image), result) + ? ReadResult::Valid : ReadResult::Invalid; +} + +bool repairBackup(const MorseSettings& candidate) { + bool present = false; + if (!filePresence(settings_fs, Path, present) + || (present && !settings_fs->remove(Path)) + || !settings_fs->rename(Backup, Path)) return false; + MorseSettings published; + return read(Path, published) == ReadResult::Valid + && published.frequency_khz == candidate.frequency_khz + && published.wpm == candidate.wpm; +} + +// An explicit retry may proceed after a transient I/O error has cleared. +// Never remove an unreadable committed image or retire the sole valid backup. +bool refreshWritableStore() { + MorseSettings primary_settings, backup_settings; + const auto primary = read(Path, primary_settings); + if (primary == ReadResult::Unreadable) return false; + if (primary == ReadResult::Valid) return true; + const auto backup = read(Backup, backup_settings); + if (backup == ReadResult::Unreadable) return false; + return backup != ReadResult::Valid || repairBackup(backup_settings); +} +} + +bool loadMorseSettings(FILESYSTEM* fs) { + settings_fs = fs; + settings = MorseSettings(); + writes_held = !fs; + settings_usable = false; + if (!fs) return false; + MorseSettings candidate; + const auto primary = read(Path, candidate); + if (primary == ReadResult::Valid) { + settings = candidate; + settings_usable = true; + return true; + } + const auto backup = read(Backup, candidate); + writes_held = primary == ReadResult::Unreadable || backup == ReadResult::Unreadable; + if (backup != ReadResult::Valid) { + settings_usable = primary == ReadResult::Missing && backup == ReadResult::Missing; + return false; + } + settings = candidate; + // Keep the sole validated backup protected if its publication cannot be + // repaired, rather than allowing the next save to retire it as stale. + if (writes_held) return true; + writes_held = !repairBackup(candidate); + settings_usable = !writes_held; + return true; +} + +const MorseSettings& morseSettings() { return settings; } +bool morseSettingsUsable() { return settings_usable; } + +bool saveMorseSettings(const MorseSettings& candidate) { + if (!settings_fs || !validMorseSettings(candidate)) return false; + if (writes_held && !refreshWritableStore()) return false; + writes_held = false; + uint8_t image[MorseSettingsImageSize]; + encodeMorseSettings(candidate, image); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter writer(settings_fs, Path); +#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + ContactFileTransaction writer(settings_fs, Path, filePresence); +#else + return false; +#endif +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!writer || writer.write(image, sizeof(image)) != sizeof(image) + || !writer.commit()) { + settings_usable = false; + writes_held = true; + return false; + } + MorseSettings checked; + if (read(Path, checked) != ReadResult::Valid + || checked.frequency_khz != candidate.frequency_khz || checked.wpm != candidate.wpm) { + writes_held = true; + settings_usable = false; + return false; + } + settings = candidate; + settings_usable = true; + return true; +#endif +} + +} // namespace mesh diff --git a/src/helpers/MorseSettings.h b/src/helpers/MorseSettings.h new file mode 100644 index 000000000..1a183bc22 --- /dev/null +++ b/src/helpers/MorseSettings.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +struct MorseSettings { + // A distinct TX-only channel. Neither packet-radio profile is modified. + uint32_t frequency_khz = 902100; + uint8_t wpm = MorseTx::DefaultWpm; + float frequencyMHz() const { return float(frequency_khz) / 1000.0f; } +}; + +constexpr uint32_t MorseMinFrequencyKHz = 100000; +constexpr uint32_t MorseMaxFrequencyKHz = 1000000; +constexpr uint8_t MorseMinWpm = MorseTx::MinWpm; +constexpr uint8_t MorseMaxWpm = MorseTx::MaxWpm; +constexpr size_t MorseSettingsImageSize = 12; + +inline bool validMorseSettings(const MorseSettings& prefs) { + return prefs.frequency_khz >= MorseMinFrequencyKHz + && prefs.frequency_khz <= MorseMaxFrequencyKHz + && prefs.wpm >= MorseMinWpm && prefs.wpm <= MorseMaxWpm; +} + +inline uint32_t morseSettingsCRC(const uint8_t* data, size_t size) { + uint32_t crc = 0xffffffffUL; + while (size--) { + crc ^= *data++; + for (uint8_t bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1U) ? 0xedb88320UL : 0); + } + return crc; +} + +inline void encodeMorseSettings(const MorseSettings& prefs, + uint8_t (&image)[MorseSettingsImageSize]) { + image[0] = 'M'; image[1] = 'C'; image[2] = 1; image[3] = prefs.wpm; + for (unsigned i = 0; i < 4; ++i) + image[4 + i] = uint8_t(prefs.frequency_khz >> (i * 8)); + const uint32_t crc = morseSettingsCRC(image, 8); + for (unsigned i = 0; i < 4; ++i) image[8 + i] = uint8_t(crc >> (i * 8)); +} + +inline bool decodeMorseSettings(const uint8_t* image, size_t size, + MorseSettings& prefs) { + if (!image || size != MorseSettingsImageSize || image[0] != 'M' + || image[1] != 'C' || image[2] != 1) return false; + uint32_t frequency = 0, crc = 0; + for (unsigned i = 0; i < 4; ++i) { + frequency |= uint32_t(image[4 + i]) << (i * 8); + crc |= uint32_t(image[8 + i]) << (i * 8); + } + MorseSettings candidate; + candidate.frequency_khz = frequency; + candidate.wpm = image[3]; + if (crc != morseSettingsCRC(image, 8) || !validMorseSettings(candidate)) + return false; + prefs = candidate; + return true; +} + +// Independent checked storage leaves legacy node/Companion layouts intact. +// Defaults and saved settings never begin transmitting automatically. +bool loadMorseSettings(FILESYSTEM* fs); +const MorseSettings& morseSettings(); +// A missing fresh store is ready with defaults. Unreadable/corrupt saved +// frequency must not silently become the default transmit frequency. +bool morseSettingsUsable(); +bool saveMorseSettings(const MorseSettings& candidate); + +} // namespace mesh diff --git a/src/helpers/MorseTx.h b/src/helpers/MorseTx.h new file mode 100644 index 000000000..58b458233 --- /dev/null +++ b/src/helpers/MorseTx.h @@ -0,0 +1,190 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +#ifndef MESH_MORSE_MAX_WPM +#define MESH_MORSE_MAX_WPM 60 +#endif +static_assert(MESH_MORSE_MAX_WPM >= 1 && MESH_MORSE_MAX_WPM <= 255, + "Morse WPM must fit the command/API byte"); + +enum class MorseTxResult : uint8_t { + Started, Stopped, Busy, Unsupported, InvalidFrequency, Overlap, + InvalidText, InvalidSpeed, TooLong, Failed +}; + +// A keyed, unmodulated carrier has no LoRa receive-bandwidth setting. Its +// occupied spectrum is determined by the keying envelope and oscillator. +// Keep its centre at least 10 kHz beyond both configured LoRa passbands; +// this prevents tuple overlap, not interference with unrelated RF users. +inline bool morseFrequencyOverlaps(float carrier_mhz, float profile_mhz, + float bandwidth_khz) { + return isfinite(carrier_mhz) && isfinite(profile_mhz) && isfinite(bandwidth_khz) + && profile_mhz > 0 && bandwidth_khz > 0 + // float MHz tuples quantize by about 61 Hz in the 900 MHz band. Keep a + // conservative 100 Hz tolerance so a nominal guard-edge value cannot + // round outward and accidentally pass the non-overlap check. + && fabsf(carrier_mhz - profile_mhz) * 1000.0f <= bandwidth_khz * 0.5f + 10.1f; +} + +// Portable, bounded ITU Morse encoder/timer. The leading 1 in each code is a +// length sentinel; subsequent bits are dot=0/dash=1, most significant first. +// No heap, delay(), or pre-expanded sequence of hundreds of marks is needed. +class MorseTx { +public: + static constexpr float DefaultFrequencyMHz = 902.100f; + static constexpr uint8_t DefaultWpm = 25; + static constexpr uint8_t MinWpm = 1; + static constexpr uint8_t MaxWpm = MESH_MORSE_MAX_WPM; + static constexpr size_t MaxTextLength = 80; + static constexpr uint32_t MaxDurationMillis = 180000UL; + enum class Edge : uint8_t { None, KeyDown, KeyUp, Complete, Expired }; + + static uint8_t codeFor(char character) { + if (character >= 'a' && character <= 'z') character -= 'a' - 'A'; + static const uint8_t letters[] = { + 5,24,26,12,2,18,14,16,4,23,13,20,7,6,15,22,29,10,8,3,9,17,11,25,27,28 + }; + static const uint8_t digits[] = {63,47,39,35,33,32,48,56,60,62}; + if (character >= 'A' && character <= 'Z') return letters[character - 'A']; + if (character >= '0' && character <= '9') return digits[character - '0']; + switch (character) { + case '.': return 85; case ',': return 115; case '?': return 76; + case '\'': return 94; case '!': return 107; case '/': return 50; + case '(': return 54; case ')': return 109; case '&': return 40; + case ':': return 120; case ';': return 106; case '=': return 49; + case '+': return 42; case '-': return 97; case '_': return 77; + case '"': return 82; case '$': return 137; case '@': return 90; + default: return 0; + } + } + + static uint8_t symbolCount(uint8_t code) { + uint8_t count = 0; + while (code > 1) { code >>= 1; ++count; } + return count; + } + + static uint16_t unitMillis(uint8_t wpm) { + return wpm >= MinWpm && wpm <= MaxWpm ? (1200U + wpm / 2) / wpm : 0; + } + + static MorseTxResult validate(const char* text, uint8_t wpm, + uint32_t* duration_ms = nullptr) { + const uint16_t unit = unitMillis(wpm); + if (!unit) return MorseTxResult::InvalidSpeed; + if (!text) return MorseTxResult::InvalidText; + uint32_t units = 0; + bool have_character = false, have_space = false; + size_t length = 0; + for (; length <= MaxTextLength && text[length]; ++length) { + const char character = text[length]; + if (character == ' ') { if (have_character) have_space = true; continue; } + const uint8_t code = codeFor(character); + if (!code) return MorseTxResult::InvalidText; + if (have_character) units += have_space ? 7 : 3; + const uint8_t count = symbolCount(code); + for (uint8_t bit = count; bit; --bit) { + units += (code & (1U << (bit - 1))) ? 3 : 1; + if (bit > 1) ++units; + } + have_character = true; + have_space = false; + } + if (length > MaxTextLength) return MorseTxResult::TooLong; + if (!have_character) return MorseTxResult::InvalidText; + const uint32_t duration = units * unit; + if (duration > MaxDurationMillis) return MorseTxResult::TooLong; + if (duration_ms) *duration_ms = duration; + return MorseTxResult::Started; + } + + MorseTxResult start(const char* text, uint8_t wpm, uint32_t now) { + uint32_t duration; + const auto result = validate(text, wpm, &duration); + if (result != MorseTxResult::Started) return result; + size_t output = 0; + bool space = false; + // Canonicalize case and collapse/trim spaces only after full validation. + for (size_t input = 0; text[input]; ++input) { + char c = text[input]; + if (c == ' ') { if (output) space = true; continue; } + if (space) _text[output++] = ' '; + space = false; + if (c >= 'a' && c <= 'z') c -= 'a' - 'A'; + _text[output++] = c; + } + _text[output] = 0; + _unit = unitMillis(wpm); + _character = _symbol = 0; + _code = codeFor(_text[0]); + _count = symbolCount(_code); + _active = _keyed = true; + _remaining = duration; + _phase_ms = markUnits() * _unit; + _next = now + _phase_ms; + _hard_end = now + MaxDurationMillis; + return MorseTxResult::Started; + } + + void stop() { _active = _keyed = false; _remaining = 0; } + bool active() const { return _active; } + bool keyed() const { return _active && _keyed; } + uint32_t hardDeadline() const { return _hard_end; } + uint32_t nextDeadline() const { return _next; } + // Hardware mode commands can take milliseconds. Start the entire next + // mark/gap after that command completes, rather than shortening Morse at + // the faster WPM values by the SPI/oscillator transition time. + void edgeApplied(uint32_t now) { if (_active) _next = now + _phase_ms; } + + uint32_t remainingMillis(uint32_t now) const { + if (!_active) return 0; + const int32_t phase_left = static_cast(_next - now); + const uint32_t other = _remaining > _phase_ms ? _remaining - _phase_ms : 0; + return other + (phase_left > 0 ? static_cast(phase_left) : 0); + } + + Edge advance(uint32_t now) { + if (!_active) return Edge::None; + if (static_cast(now - _hard_end) >= 0) { + stop(); + return Edge::Expired; + } + if (static_cast(now - _next) < 0) return Edge::None; + _remaining = _remaining > _phase_ms ? _remaining - _phase_ms : 0; + if (_keyed) { + _keyed = false; + uint8_t gap = 1; + if (++_symbol >= _count) { + if (!_text[++_character]) { stop(); return Edge::Complete; } + gap = 3; + if (_text[_character] == ' ') { ++_character; gap = 7; } + _symbol = 0; + _code = codeFor(_text[_character]); + _count = symbolCount(_code); + } + _phase_ms = gap * _unit; + // Rebase on actual service time. Never key a burst of missed edges. + _next = now + _phase_ms; + return Edge::KeyUp; + } + _keyed = true; + _phase_ms = markUnits() * _unit; + _next = now + _phase_ms; + return Edge::KeyDown; + } + +private: + char _text[MaxTextLength + 1] = {}; + uint32_t _next = 0, _hard_end = 0, _remaining = 0; + uint16_t _unit = 0, _phase_ms = 0; + uint8_t _character = 0, _symbol = 0, _code = 0, _count = 0; + bool _active = false, _keyed = false; + uint8_t markUnits() const { return (_code & (1U << (_count - 1 - _symbol))) ? 3 : 1; } +}; + +} // namespace mesh diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index a43d7d3ac..fd3b90569 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -2,18 +2,19 @@ #include "RS232UartUtils.h" #include +#include #ifdef WITH_RS232_BRIDGE #ifdef ESP32 // check_firmware_ram.py reserves this object plus the UART driver's runtime // allocation even when a combined Full image starts with the UART disabled. -static_assert(sizeof(RS232Bridge) <= 2304, "Update the ESP32 UART heap budget"); +static_assert(sizeof(RS232Bridge) <= 2816, "Update the ESP32 UART heap budget"); #endif #if defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET) // The combined RAK RAM policy reserves this object plus allocator metadata // and the UART driver's TX semaphore. Its RX/TX arrays are linked globals. -static_assert(sizeof(RS232Bridge) <= 2304, "Update the combined RAK UART heap budget"); +static_assert(sizeof(RS232Bridge) <= 2816, "Update the combined RAK UART heap budget"); #endif RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, @@ -24,8 +25,7 @@ RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, void RS232Bridge::begin() { _initialized = false; - _rx_buffer_pos = 0; - _rx_packet_ready = false; + resetTransportState(); BRIDGE_DEBUG_PRINTLN("Initializing at %d baud...\n", _prefs->bridge_baud); #if defined(ESP32) if (!((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin)) return; @@ -60,8 +60,227 @@ void RS232Bridge::end() { // Update bridge state _initialized = false; + resetTransportState(); +} + +void RS232Bridge::resetTransportState() { _rx_buffer_pos = 0; _rx_packet_ready = false; + _tx_size = _tx_pos = 0; + _tx_wire_pending = false; + _tx_is_ping = false; + _tx_ping_challenge = 0; + _outstanding_challenge = 0; + _pong_target = 0; + _pong_challenge = 0; + _ping_due = true; + _partial_suspend_grace = false; +} + +void RS232Bridge::setHeartbeatSession(uint64_t session) { + if (_session == session) return; + _session = session; + _challenge_counter = static_cast(session) + ^ static_cast(session >> 32) ^ 0x9E3779B9UL; + resetTransportState(); + _peer_session = 0; + _have_ack = false; + _ack_fresh = false; + _standby_armed = false; + _legacy_rx = false; + _legacy_fresh = false; + _power.resetPeer(millis()); +} + +void RS232Bridge::setHeartbeatEnabled(bool enabled) { + if (enabled && !_heartbeat_enabled) _ping_due = true; + _heartbeat_enabled = enabled; + if (!enabled) _standby_armed = false; + if (!enabled && _tx_is_ping && _tx_pos == 0) { + // An unsent PING can be withdrawn. A partially emitted frame must finish + // to preserve framing of later mesh data; passive PONGs remain supported. + _tx_size = 0; + _tx_is_ping = false; + _tx_ping_challenge = 0; + } +} + +uint32_t RS232Bridge::frameAssemblyMs() const { + const uint32_t baud = _prefs->bridge_baud ? _prefs->bridge_baud : 115200; + // Ten wire bits per byte (8N1), plus loop scheduling margin. Start-relative, + // not last-byte-relative: a noise trickle cannot extend an assembly forever. + uint32_t duration = static_cast( + (static_cast(MAX_SERIAL_PACKET_SIZE) * 10000 + baud - 1) / baud) + 1000; + if (duration < 1000) duration = 1000; + if (duration > 3000) duration = 3000; + return duration; +} + +void RS232Bridge::expirePartialReceive(uint32_t now) { + if (_rx_buffer_pos != 0 && !_rx_packet_ready + && static_cast(now - _rx_started) >= frameAssemblyMs()) { + _rx_buffer_pos = 0; + } +} + +void RS232Bridge::expireHeartbeatFreshness(uint32_t now) { + // Freshness expires permanently until real RX refreshes it. Historical + // negotiation remains separate, so dead updated peers can still rendezvous + // and an old timestamp cannot appear alive after a full millis() cycle. + if (_ack_fresh && static_cast(now - _last_ack) >= _idle_ms) { + _ack_fresh = false; + } + if (_legacy_fresh && static_cast(now - _last_legacy_rx) >= _idle_ms) { + _legacy_fresh = false; + } +} + +bool RS232Bridge::txPending(uint32_t now) const { + return _tx_size != 0 || (_tx_wire_pending + && !mesh::bridge::rs232heartbeat::due(now, _tx_wire_until)); +} + +RS232Bridge::PowerAction RS232Bridge::servicePowerPolicy( + bool allow_standby, uint32_t idle_ms, uint32_t now, bool force_unconfirmed) { + _idle_ms = idle_ms; + expireHeartbeatFreshness(now); + // Historical successful bidirectional negotiation survives a soft UART stop, + // but a fresh object/session has no such capability. Never stop a stock or + // never-confirmed peer by default; explicit force mode is for dead settings. + const bool armed = allow_standby && _heartbeat_enabled + && (_have_ack || force_unconfirmed); + _standby_armed = armed; + expirePartialReceive(now); + bool partial_blocks = false; + if (_rx_buffer_pos != 0 && !_rx_packet_ready + && _power.standbyDue(armed, idle_ms, now)) { + // One bounded grace period at the inactivity deadline; repeatedly starting + // malformed headers cannot keep UART awake indefinitely. + if (!_partial_suspend_grace) { + _partial_suspend_grace = true; + _partial_suspend_until = now + frameAssemblyMs(); + } + partial_blocks = !mesh::bridge::rs232heartbeat::due(now, _partial_suspend_until); + if (!partial_blocks) _rx_buffer_pos = 0; + } else if (!_power.standbyDue(armed, idle_ms, now)) { + _partial_suspend_grace = false; + } + return _power.service(armed, idle_ms, now, _initialized, + _rx_packet_ready || txPending(now) || partial_blocks); +} + +void RS232Bridge::completePowerAction(PowerAction action, bool success, uint32_t now) { + _power.complete(action, success, now); +} + +uint32_t RS232Bridge::powerWakeDelayMs(uint32_t now) const { + return _power.wakeDelay(now); +} + +void RS232Bridge::formatHeartbeatStatus(char* out, size_t capacity, uint32_t now) const { + if (!out || capacity == 0) return; + const bool confirmed = _ack_fresh && static_cast(now - _last_ack) < _idle_ms; + const char* phase = _power.retrying() ? "retrying" + : _power.suspended() ? "standby" : _power.probing() ? "probing" + : _initialized ? "active" : "stopped"; + const char* peer = confirmed ? "confirmed" : _legacy_fresh + && static_cast(now - _last_legacy_rx) < _idle_ms ? "legacy" : "unknown"; + const uint32_t age = _power.peerAge(now); + const uint32_t wake = powerWakeDelayMs(now); + snprintf(out, capacity, "state=%s,peer=%s,age_s=%lu,wake_s=%lu,heartbeat=%s,armed=%s", + phase, peer, static_cast(age == UINT32_MAX ? 0 : age / 1000), + static_cast(wake == UINT32_MAX ? 0 : (wake + 999) / 1000), + _heartbeat_enabled ? "on" : "passive", _standby_armed ? "on" : "off"); + out[capacity - 1] = 0; +} + +void RS232Bridge::drainTransmit(uint32_t now, bool blocking) { + if (!_initialized || _tx_size == 0) return; + size_t amount = static_cast(_tx_size - _tx_pos); + if (!blocking) { + const int available = _serial->availableForWrite(); + if (available <= 0) return; + if (amount > static_cast(available)) amount = static_cast(available); + } + size_t sent = _serial->write(_tx_buffer + _tx_pos, amount); + if (sent > amount) sent = amount; + if (sent == 0) return; + _tx_pos = static_cast(_tx_pos + sent); + const uint32_t baud = _prefs->bridge_baud ? _prefs->bridge_baud : 115200; + uint32_t base = now; + if (_tx_wire_pending && !mesh::bridge::rs232heartbeat::due(now, _tx_wire_until)) { + base = _tx_wire_until; + } + _tx_wire_until = base + static_cast((sent * 10000UL + baud - 1) / baud) + 1; + _tx_wire_pending = true; + if (_tx_pos == _tx_size) { + if (_tx_is_ping) { + _outstanding_challenge = _tx_ping_challenge; + _outstanding_since = now; + } + _tx_size = _tx_pos = 0; + _tx_is_ping = false; + } +} + +bool RS232Bridge::queueControl(char opcode, uint64_t target, uint32_t challenge) { + if (_tx_size != 0 || _session == 0) return false; + mesh::bridge::rs232heartbeat::Control control; + control.opcode = opcode; + control.sender = _session; + control.target = target; + control.challenge = challenge; + if (!mesh::bridge::rs232heartbeat::encode(_tx_buffer + 4, control)) return false; + _tx_buffer[0] = 0xC0; _tx_buffer[1] = 0x3E; + _tx_buffer[2] = _tx_buffer[3] = 0; + _tx_size = mesh::bridge::rs232heartbeat::FRAME_SIZE; + _tx_pos = 0; + _tx_is_ping = opcode == 'P'; + _tx_ping_challenge = challenge; + return true; +} + +void RS232Bridge::serviceHeartbeat(uint32_t now) { + if (_session == 0 || _tx_size != 0) return; + if (_pong_target != 0) { + if (queueControl('A', _pong_target, _pong_challenge)) { + _pong_target = 0; + _pong_challenge = 0; + } + } else if (_heartbeat_enabled && (_ping_due + || mesh::bridge::rs232heartbeat::due(now, _next_ping))) { + ++_challenge_counter; + if (_challenge_counter == 0) ++_challenge_counter; + if (queueControl('P', 0, _challenge_counter)) { + _ping_due = false; + _next_ping = now + (_power.probing() + ? mesh::bridge::rs232heartbeat::PROBE_PING_MS + : mesh::bridge::rs232heartbeat::ACTIVE_PING_MS); + } + } +} + +void RS232Bridge::handleControl(uint32_t now) { + mesh::bridge::rs232heartbeat::Control control; + if (_session == 0 || !mesh::bridge::rs232heartbeat::decode(_rx_buffer + 4, control) + || control.sender == _session) return; + if (control.opcode == 'P') { + // A replayed PING is not peer liveness. Only a fresh challenge response + // proves bidirectional reachability; replying also supports passive peers. + _pong_target = control.sender; + _pong_challenge = control.challenge; + } else if (control.target == _session && _outstanding_challenge != 0 + && control.challenge == _outstanding_challenge + && static_cast(now - _outstanding_since) + <= mesh::bridge::rs232heartbeat::ACK_MAX_AGE_MS) { + _outstanding_challenge = 0; // A matching ACK can be consumed only once. + _peer_session = control.sender; + _last_ack = now; + _have_ack = true; + _ack_fresh = true; + _power.notePeer(now); + _partial_suspend_grace = false; + } } void RS232Bridge::drainReceivedPackets() { @@ -72,7 +291,33 @@ void RS232Bridge::drainReceivedPackets() { const bool valid = pkt->readFrom(_rx_buffer + 4, len); _rx_buffer_pos = 0; _rx_packet_ready = false; - if (valid) onPacketReceived(pkt); + if (valid) { + // A cable loopback or replay of our own accepted mesh TX is not peer proof. + // Discard here to avoid testing/counting the same duplicate a second time + // inside BridgeBase::handleReceivedPacket(). + if (_seen_packets.wasSeen(pkt)) { + _mgr->free(pkt); + return; + } + // Legacy traffic proves inbound reachability, not a bidirectional heartbeat. + // If ordinary data continues after heartbeat replies have stopped, a stock + // peer may have replaced the negotiated one. Unarm the safe default rather + // than eventually sleeping that quiet legacy link. A later fresh ACK can + // immediately restore negotiated capability; force mode remains explicit. + if (_have_ack && (!_ack_fresh + || static_cast(_rx_packet_received_ms - _last_ack) + > mesh::bridge::rs232heartbeat::ACTIVE_PING_MS + + mesh::bridge::rs232heartbeat::ACK_MAX_AGE_MS)) { + _have_ack = false; + _peer_session = 0; + } + _legacy_rx = true; + _legacy_fresh = true; + _last_legacy_rx = _rx_packet_received_ms; + _power.notePeer(_rx_packet_received_ms); + _partial_suspend_grace = false; + onPacketReceived(pkt); + } else _mgr->free(pkt); } @@ -82,6 +327,15 @@ void RS232Bridge::loop() { return; } + const uint32_t now = millis(); + expireHeartbeatFreshness(now); + // Let a tail already buffered by the UART complete first. Flash/admin/sensor + // work can pause the loop beyond the assembly deadline even though the peer + // transmitted the complete frame on time; expiry before draining would drop + // valid legacy traffic. A still-incomplete tail expires after bounded work. + if (_serial->available() == 0) expirePartialReceive(now); + drainTransmit(now); + drainReceivedPackets(); // Snapshot bounded work; a noisy UART must not starve mesh/radio service. int remaining = _serial->available(); @@ -96,12 +350,14 @@ void RS232Bridge::loop() { if ((_rx_buffer_pos == 0 && b == ((BRIDGE_PACKET_MAGIC >> 8) & 0xFF)) || (_rx_buffer_pos == 1 && b == (BRIDGE_PACKET_MAGIC & 0xFF))) { _rx_buffer[_rx_buffer_pos++] = b; + if (_rx_buffer_pos == 1) _rx_started = now; } else { // Invalid magic byte, reset and start over _rx_buffer_pos = 0; // Check if this byte could be the start of a new magic word if (b == ((BRIDGE_PACKET_MAGIC >> 8) & 0xFF)) { _rx_buffer[_rx_buffer_pos++] = b; + _rx_started = now; } } } else { @@ -112,18 +368,33 @@ void RS232Bridge::loop() { uint16_t len = (_rx_buffer[2] << 8) | _rx_buffer[3]; // Validate length field - if (len == 0 || len > (MAX_TRANS_UNIT + 1)) { + if (len > (MAX_TRANS_UNIT + 1)) { BRIDGE_DEBUG_PRINTLN("RX invalid length %d, resetting\n", len); _rx_buffer_pos = 0; // Invalid length, reset continue; } + if (len == 0) { + // Zero length was invalid in the old mesh parser. The extension body + // is fixed-size ASCII, so old receivers cannot find C0 3E within it. + // New receivers resync immediately if a real new frame interrupts it. + if (_rx_buffer_pos > 4 && b >= 0x80) { + _rx_buffer_pos = 0; + if (b == 0xC0) { _rx_buffer[0] = b; _rx_buffer_pos = 1; _rx_started = now; } + } else if (_rx_buffer_pos == mesh::bridge::rs232heartbeat::FRAME_SIZE) { + handleControl(now); + _rx_buffer_pos = 0; + } + continue; + } + if (_rx_buffer_pos == len + SERIAL_OVERHEAD) { // Full packet received uint16_t received_checksum = (_rx_buffer[4 + len] << 8) | _rx_buffer[5 + len]; if (validateChecksum(_rx_buffer + 4, len, received_checksum)) { BRIDGE_DEBUG_PRINTLN("RX, len=%d crc=0x%04x\n", len, received_checksum); _rx_packet_ready = true; + _rx_packet_received_ms = now; drainReceivedPackets(); // continue this bounded batch when the pool is available } else { BRIDGE_DEBUG_PRINTLN("RX checksum mismatch, rcv=0x%04x\n", received_checksum); @@ -134,6 +405,9 @@ void RS232Bridge::loop() { } } drainReceivedPackets(); + expirePartialReceive(now); + serviceHeartbeat(now); + drainTransmit(now); } void RS232Bridge::sendPacket(mesh::Packet *packet) { @@ -150,14 +424,21 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) { if (!allowsPacket(packet)) return; + // Preserve the legacy data path's blocking UART acceptance/throughput. + // Finish a partially emitted control/data frame first, never interleave it. + // A genuine failed/short hardware write is retained for later loop service; + // only that failure case cannot accept this caller's additional packet. + drainTransmit(millis(), true); + if (_tx_size != 0) return; // Never interleave mesh traffic with a partial frame. + if (!_seen_packets.wasSeen(packet)) { const int expected = packet->getRawLength(); if (expected <= 0 || expected > MAX_TRANS_UNIT + 1) return; - uint8_t buffer[MAX_SERIAL_PACKET_SIZE]; + uint8_t* buffer = _tx_buffer; uint16_t len = packet->writeTo(buffer + 4); // Check if packet fits within our maximum payload size - if (len > (MAX_TRANS_UNIT + 1)) { + if (len == 0 || len > (MAX_TRANS_UNIT + 1)) { BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", len, MAX_TRANS_UNIT + 1); return; } @@ -173,10 +454,14 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) { buffer[4 + len] = (checksum >> 8) & 0xFF; // Checksum high byte buffer[5 + len] = checksum & 0xFF; // Checksum low byte - // Send complete packet - if (_serial->write(buffer, len + SERIAL_OVERHEAD) == static_cast(len + SERIAL_OVERHEAD)) { - _seen_packets.markSeen(packet); - } + // Own the complete frame before returning the shared mesh packet. Data + // uses the original blocking write contract; control-only loop work stays + // nonblocking. Local UART acceptance is never peer liveness. + _tx_size = static_cast(len + SERIAL_OVERHEAD); + _tx_pos = 0; + _tx_is_ping = false; + _seen_packets.markSeen(packet); + drainTransmit(millis(), true); BRIDGE_DEBUG_PRINTLN("TX, len=%d crc=0x%04x\n", len, checksum); } diff --git a/src/helpers/bridges/RS232Bridge.h b/src/helpers/bridges/RS232Bridge.h index 895d5a394..b13642d4b 100644 --- a/src/helpers/bridges/RS232Bridge.h +++ b/src/helpers/bridges/RS232Bridge.h @@ -1,6 +1,7 @@ #pragma once #include "helpers/bridges/BridgeBase.h" +#include "helpers/bridges/RS232Heartbeat.h" #include @@ -109,6 +110,19 @@ public: */ void onPacketReceived(mesh::Packet *packet) override; + using PowerAction = mesh::bridge::RS232PowerPolicy::Action; + + // The owner supplies a fresh, nonzero boot/object session before begin(). + // This is not a persistent identity or authentication secret. + void setHeartbeatSession(uint64_t session); + void setHeartbeatEnabled(bool enabled); + PowerAction servicePowerPolicy(bool allow_standby, uint32_t idle_ms, uint32_t now, + bool force_unconfirmed = false); + void completePowerAction(PowerAction action, bool success, uint32_t now); + uint32_t powerWakeDelayMs(uint32_t now) const; + void formatHeartbeatStatus(char* out, size_t capacity, uint32_t now) const; + bool isPowerSuspended() const { return _power.suspended(); } + private: /** * RS232 Protocol Structure: @@ -149,6 +163,50 @@ private: /** Current position in the receive buffer */ uint16_t _rx_buffer_pos = 0; + + // Data and control share this owned buffer, so partial UART writes can never + // interleave frames. It owns bytes, not a mesh::Packet from the shared pool. + uint8_t _tx_buffer[MAX_SERIAL_PACKET_SIZE]; + uint16_t _tx_size = 0; + uint16_t _tx_pos = 0; + uint32_t _tx_wire_until = 0; + bool _tx_wire_pending = false; + bool _tx_is_ping = false; + uint32_t _tx_ping_challenge = 0; + + mesh::bridge::RS232PowerPolicy _power; + uint64_t _session = 0; + uint64_t _peer_session = 0; + uint64_t _pong_target = 0; + uint32_t _pong_challenge = 0; + uint32_t _challenge_counter = 0; + uint32_t _outstanding_challenge = 0; + uint32_t _outstanding_since = 0; + uint32_t _next_ping = 0; + uint32_t _last_ack = 0; + uint32_t _last_legacy_rx = 0; + uint32_t _rx_started = 0; + uint32_t _rx_packet_received_ms = 0; + uint32_t _partial_suspend_until = 0; + uint32_t _idle_ms = 300000; + bool _heartbeat_enabled = true; + bool _standby_armed = false; + bool _ping_due = true; + bool _have_ack = false; + bool _ack_fresh = false; + bool _legacy_rx = false; + bool _legacy_fresh = false; + bool _partial_suspend_grace = false; + + void resetTransportState(); + void expirePartialReceive(uint32_t now); + void expireHeartbeatFreshness(uint32_t now); + uint32_t frameAssemblyMs() const; + bool txPending(uint32_t now) const; + void drainTransmit(uint32_t now, bool blocking = false); + void serviceHeartbeat(uint32_t now); + bool queueControl(char opcode, uint64_t target, uint32_t challenge); + void handleControl(uint32_t now); }; #endif diff --git a/src/helpers/bridges/RS232Heartbeat.h b/src/helpers/bridges/RS232Heartbeat.h new file mode 100644 index 000000000..08fb89546 --- /dev/null +++ b/src/helpers/bridges/RS232Heartbeat.h @@ -0,0 +1,215 @@ +#pragma once + +#include +#include + +// Portable wire/power helpers: no Arduino dependency, allocation, mesh packets, +// wall clock, or peer-controlled timing values. +namespace mesh { +namespace bridge { +namespace rs232heartbeat { + +static constexpr size_t BODY_SIZE = 48; +static constexpr size_t CHECKSUM_OFFSET = 44; +static constexpr size_t FRAME_SIZE = 4 + BODY_SIZE; +static constexpr uint32_t ACTIVE_PING_MS = 30000; +static constexpr uint32_t PROBE_PING_MS = 1000; +static constexpr uint32_t ACK_MAX_AGE_MS = 5000; +// One long listening window exceeds the other peer's maximum 30 s standby +// plus short probe/round-trip margin. The eighth-window rendezvous therefore +// reconnects two stopped UARTs at arbitrary phases without lucky random jitter +// or assuming that RX bytes can wake a physically ended nRF52 Uart driver. +static constexpr uint32_t STANDBY_MS = 30000; +static constexpr uint32_t SHORT_PROBE_MS = 2000; +static constexpr uint32_t LONG_PROBE_MS = 40000; +static constexpr uint32_t ACTION_RETRY_MS = 5000; +static constexpr uint8_t LONG_PROBE_EVERY = 8; + +inline bool due(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; +} + +inline uint32_t delayTo(uint32_t now, uint32_t deadline) { + return due(now, deadline) ? 0 : static_cast(deadline - now); +} + +inline uint16_t checksum(const uint8_t* bytes, size_t length) { + uint16_t a = 0, b = 0; + for (size_t i = 0; i < length; ++i) { + a = static_cast((a + bytes[i]) % 255); + b = static_cast((b + a) % 255); + } + return static_cast((b << 8) | a); +} + +inline void writeHex(uint8_t* out, uint64_t value, size_t digits) { + static const char HEX_DIGITS[] = "0123456789ABCDEF"; + for (size_t i = digits; i != 0; --i) { + out[i - 1] = static_cast(HEX_DIGITS[value & 15]); + value >>= 4; + } +} + +inline bool readHex(const uint8_t* in, size_t digits, uint64_t& value) { + value = 0; + for (size_t i = 0; i < digits; ++i) { + const uint8_t c = in[i]; + uint8_t nibble; + if (c >= '0' && c <= '9') nibble = static_cast(c - '0'); + else if (c >= 'A' && c <= 'F') nibble = static_cast(c - 'A' + 10); + else return false; + value = (value << 4) | nibble; + } + return true; +} + +struct Control { + uint64_t sender = 0; + uint64_t target = 0; + uint32_t challenge = 0; + char opcode = 0; +}; + +inline bool encode(uint8_t* body, const Control& control) { + if (!body || control.sender == 0 || control.challenge == 0 + || (control.opcode != 'P' && control.opcode != 'A') + || (control.opcode == 'P' && control.target != 0) + || (control.opcode == 'A' && control.target == 0)) return false; + body[0] = 'H'; body[1] = 'B'; body[2] = '1'; + body[3] = static_cast(control.opcode); + writeHex(body + 4, control.sender, 16); + writeHex(body + 20, control.target, 16); + writeHex(body + 36, control.challenge, 8); + writeHex(body + CHECKSUM_OFFSET, checksum(body, CHECKSUM_OFFSET), 4); + return true; +} + +inline bool decode(const uint8_t* body, Control& control) { + if (!body || body[0] != 'H' || body[1] != 'B' || body[2] != '1' + || (body[3] != 'P' && body[3] != 'A')) return false; + uint64_t sender, target, challenge, crc; + if (!readHex(body + 4, 16, sender) || !readHex(body + 20, 16, target) + || !readHex(body + 36, 8, challenge) + || !readHex(body + CHECKSUM_OFFSET, 4, crc) + || crc != checksum(body, CHECKSUM_OFFSET) + || sender == 0 || challenge == 0 + || (body[3] == 'P' && target != 0) + || (body[3] == 'A' && target == 0)) return false; + control.sender = sender; + control.target = target; + control.challenge = static_cast(challenge); + control.opcode = static_cast(body[3]); + return true; +} + +} // namespace rs232heartbeat + +class RS232PowerPolicy { +public: + enum class Action : uint8_t { None, Suspend, Resume }; + + void notePeer(uint32_t now) { + _started = true; + _last_peer = now; + _has_peer = true; + _probing = false; + _probe_count = 0; + } + + // Invalidating a session must not trust liveness from the previous session. + void resetPeer(uint32_t now) { + _started = true; + _last_peer = now; + _has_peer = false; + } + + bool standbyDue(bool allowed, uint32_t idle_ms, uint32_t now) const { + if (!allowed || _suspended || _requested != Action::None + || _retry_action != Action::None) return false; + return _probing ? rs232heartbeat::due(now, _phase_deadline) + : _started && static_cast(now - _last_peer) >= idle_ms; + } + + Action service(bool allowed, uint32_t idle_ms, uint32_t now, + bool running, bool blocked) { + if (!_started) { + _started = true; + _last_peer = now; + } + if (_requested != Action::None) return Action::None; + if (_retry_action != Action::None) { + if (!rs232heartbeat::due(now, _retry_deadline)) return Action::None; + _requested = _retry_action; + return _requested; + } + if (!allowed) { + _probing = false; + if (_suspended || !running) _requested = Action::Resume; + } else if (_suspended) { + if (rs232heartbeat::due(now, _phase_deadline)) _requested = Action::Resume; + } else if (!running) { + _requested = Action::Resume; + } else if (standbyDue(allowed, idle_ms, now) && !blocked) { + _requested = Action::Suspend; + } + return _requested; + } + + void complete(Action action, bool success, uint32_t now) { + if (action == Action::None || action != _requested) return; + _requested = Action::None; + if (!success) { + // A failed physical stop can leave GPS ownership pending although UART + // is already ended. Finish that same action, never restart prematurely. + _retry_action = action; + _retry_deadline = now + rs232heartbeat::ACTION_RETRY_MS; + return; + } + _retry_action = Action::None; + if (action == Action::Suspend) { + _suspended = true; + _probing = false; + _phase_deadline = now + rs232heartbeat::STANDBY_MS; + } else { + const bool was_suspended = _suspended; + _suspended = false; + if (was_suspended) { + _probing = true; + _probe_count = static_cast(_probe_count + 1); + const uint32_t window = _probe_count % rs232heartbeat::LONG_PROBE_EVERY == 0 + ? rs232heartbeat::LONG_PROBE_MS : rs232heartbeat::SHORT_PROBE_MS; + _phase_deadline = now + window; + } + } + } + + uint32_t wakeDelay(uint32_t now) const { + if (_requested != Action::None) return 0; + if (_retry_action != Action::None) return rs232heartbeat::delayTo(now, _retry_deadline); + if (_suspended || _probing) return rs232heartbeat::delayTo(now, _phase_deadline); + return UINT32_MAX; + } + + bool suspended() const { return _suspended; } + bool probing() const { return _probing; } + bool hasPeer() const { return _has_peer; } + bool retrying() const { return _retry_action != Action::None; } + uint32_t peerAge(uint32_t now) const { + return _has_peer ? static_cast(now - _last_peer) : UINT32_MAX; + } + +private: + uint32_t _last_peer = 0; + uint32_t _phase_deadline = 0; + uint32_t _retry_deadline = 0; + Action _requested = Action::None; + Action _retry_action = Action::None; + uint8_t _probe_count = 0; + bool _started = false; + bool _has_peer = false; + bool _suspended = false; + bool _probing = false; +}; + +} // namespace bridge +} // namespace mesh diff --git a/src/helpers/bridges/RS232PowerSettings.h b/src/helpers/bridges/RS232PowerSettings.h new file mode 100644 index 000000000..db96935b7 --- /dev/null +++ b/src/helpers/bridges/RS232PowerSettings.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include "RS232Heartbeat.h" + +namespace mesh { +namespace bridge { +namespace rs232power { + +static constexpr uint16_t DEFAULT_IDLE_SECONDS = 300; +static constexpr uint16_t MIN_IDLE_SECONDS = 60; +static constexpr uint16_t MAX_IDLE_SECONDS = 3600; +static constexpr size_t PREFS_SIZE = 10; + +inline void sanitize(uint8_t& heartbeat, uint8_t& autosleep, uint16_t& idle) { + heartbeat = heartbeat == 1 ? 1 : 0; + autosleep = autosleep <= 2 ? autosleep : 0; + if (idle < MIN_IDLE_SECONDS || idle > MAX_IDLE_SECONDS) idle = DEFAULT_IDLE_SECONDS; +} + +// Append-only, checked modes. A missing, torn or foreign tail defaults to +// negotiated standby, which never switches off an unconfirmed stock link. +inline void encodePrefs(uint8_t* out, uint8_t heartbeat, uint8_t autosleep, uint16_t idle) { + out[0] = 'R'; out[1] = 'S'; out[2] = 'P'; out[3] = 1; + out[4] = heartbeat == 1 ? 1 : 0; + out[5] = autosleep <= 2 ? autosleep : 0; + if (idle < MIN_IDLE_SECONDS || idle > MAX_IDLE_SECONDS) idle = DEFAULT_IDLE_SECONDS; + out[6] = static_cast(idle >> 8); + out[7] = static_cast(idle); + const uint16_t crc = rs232heartbeat::checksum(out, 8); + out[8] = static_cast(crc >> 8); + out[9] = static_cast(crc); +} + +inline bool decodePrefs(const uint8_t* in, size_t size, uint8_t& heartbeat, + uint8_t& autosleep, uint16_t& idle) { + if (!in || size != PREFS_SIZE || in[0] != 'R' || in[1] != 'S' + || in[2] != 'P' || in[3] != 1 || in[4] > 1 || in[5] > 2) return false; + const uint16_t seconds = static_cast((in[6] << 8) | in[7]); + const uint16_t crc = static_cast((in[8] << 8) | in[9]); + if (seconds < MIN_IDLE_SECONDS || seconds > MAX_IDLE_SECONDS + || crc != rs232heartbeat::checksum(in, 8)) return false; + heartbeat = in[4]; + autosleep = in[5]; + idle = seconds; + return true; +} + +} // namespace rs232power +} // namespace bridge +} // namespace mesh diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index 09af680a9..a87edf46f 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include "SX126xMorseRssi.h" #ifndef SX126X_TX_BUSY_TIMEOUT_MS #define SX126X_TX_BUSY_TIMEOUT_MS 1000UL @@ -14,6 +16,14 @@ class CustomLLCC68 : public LLCC68 { public: CustomLLCC68(Module *mod) : LLCC68(mod) { } + int16_t setMorseFastRssi() { + return setSX126xMorseRssiWindow(this); + } + float getMorseRssi() { + uint8_t raw = 0; + const int16_t status = this->getMod()->SPIreadStream(RADIOLIB_SX126X_CMD_GET_RSSI_INST, &raw, 1); + return status == RADIOLIB_ERR_NONE ? raw * -0.5f : NAN; + } #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 990c17acb..6764a4b18 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -4,6 +4,8 @@ #include "CustomLLCC68.h" #include "RadioLibWrappers.h" #include "SX126xReset.h" +#include "SX126xMorseRamp.h" +#include "SX126xMorseReceiver.h" class CustomLLCC68Wrapper : public RadioLibWrapper { protected: @@ -12,10 +14,37 @@ protected: public: ~CustomLLCC68Wrapper() { unregisterInterruptAction(); } CustomLLCC68Wrapper(CustomLLCC68& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + bool supportsMorseTx() const override { return true; } + bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } - void powerOff() { _radio->standby(); _radio->sleep(); } + void powerOff() { + if (isCarrierWaveActive()) setCarrierWave(0, 0); + _radio->standby(); _radio->sleep(); + } protected: + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; + if (!prepareRadioHardReset()) return RADIOLIB_ERR_UNKNOWN; + const int16_t status = prepareSX126xMorseReceiver(static_cast(_radio), frequency_mhz); + return status == RADIOLIB_ERR_NONE && !_board->finishRadioHardReset() + ? RADIOLIB_ERR_UNKNOWN : status; + } + float readMorseRssi() override { return static_cast(_radio)->getMorseRssi(); } + float morseReceiverBandwidthKHz() const override { return 4.8f; } + int16_t prepareMorseCarrier(float frequency_mhz) override { + const int16_t status = RadioLibWrapper::prepareMorseCarrier(frequency_mhz); + return status == RADIOLIB_ERR_NONE + ? setSX126xMorseRamp(static_cast(_radio)) : status; + } + int16_t leaveMorseCarrier() override { + return leaveSX126xMorseCarrier(static_cast(_radio)); + } + bool restoreCarrierWaveModem() override { + return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE + && RadioLibWrapper::restoreCarrierWaveModem(); + } bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { bool success = ((CustomLLCC68 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE && ((CustomLLCC68 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE @@ -32,6 +61,7 @@ protected: public: bool setCodingRate(uint8_t cr) override { + if (isCarrierWaveActive()) return false; return ((CustomLLCC68 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 6c8efe79c..efa4bc97c 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -5,6 +5,8 @@ #include "RXPowerSaving.h" #include "LR1110RxRecovery.h" #include "LR1110ProfileSwitchState.h" +#include "LR11x0MorseRfSwitch.h" +#include "../MorseRx.h" #ifndef MC_LR1110_FAST_PROFILE_SWITCH // All MeshCore LR1110 targets use the checked FS-mode continuous-RX retune. @@ -26,10 +28,41 @@ class CustomLR1110 : public LR1110 { bool _profileStandbyWarm = false; bool _profilePacketDirty = false; uint32_t _profileFastResumes = 0; + LR11x0MorseRfSwitch _morse_rf_switch; public: CustomLR1110(Module *mod) : LR1110(mod) { } + void setRfSwitchTable(const uint32_t (&pins)[Module::RFSWITCH_MAX_PINS], + const Module::RfSwitchMode_t table[]) { + _morse_rf_switch.capture(this->mod, pins, table); + LR1110::setRfSwitchTable(pins, table); + } + + int16_t beginMorseReceive(float frequency_mhz) { + _profileSwitch.invalidate(); + _profilePacketDirty = false; + int16_t status = LR1110::beginGFSK(frequency_mhz, + mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 4.8f, 2, 16, tcxoVoltage); + if (status == RADIOLIB_ERR_NONE) status = applyMeshCoreTcxoDelay(); + if (status == RADIOLIB_ERR_NONE) status = setRegulatorDCDC(); + if (status == RADIOLIB_ERR_NONE) status = _morse_rf_switch.restore( + [this](uint8_t enable, const uint8_t* modes) { + return setDioAsRfSwitch(enable, modes[0], modes[1], modes[2], + modes[3], modes[4], modes[5], modes[6]); + }); + if (status == RADIOLIB_ERR_NONE) status = LR1110::setRxBoostedGainMode(_rx_boosted); + if (status == RADIOLIB_ERR_NONE) { + // The LR11xx public sync-word setter rejects length zero. Stage the + // native no-lock packet tuple and commit it through checked setCRC. + syncWordLength = preambleDetLength = 0; + status = LR1110::setCRC(0); + } + // Infinite continuous RX samples envelope power independently of the + // FSK packet detector; no direct-data pin or TX/PA activation is used. + return status; + } + void setProfileSwitchOptimization(bool enabled) { _profileSwitch.enabled = enabled; _profileSwitch.invalidate(); diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 884f47f51..1879b7172 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -17,10 +17,47 @@ public: CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board), _deep_init(NULL) { } bool supportsCarrierWave() const override { return true; } + bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } protected: + int16_t startMorseReceiver() override { + return static_cast(_radio)->LR1110::startReceive( + RADIOLIB_LR11X0_RX_TIMEOUT_INF, 0, 0, 0); + } + float readMorseRssi() override { + float rssi = NAN; + return static_cast(_radio)->getRssiInst(&rssi) == RADIOLIB_ERR_NONE ? rssi : NAN; + } + float morseReceiverBandwidthKHz() const override { return 4.8f; } + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!supportsRadioDeepInit()) return RADIOLIB_ERR_UNSUPPORTED; + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; // any partial setup must rebuild LoRa on exit + if (!prepareRadioHardReset()) return RADIOLIB_ERR_CHIP_NOT_FOUND; + const int16_t status = static_cast(_radio)->beginMorseReceive(frequency_mhz); + const bool finished = _board->finishRadioHardReset(); + return status == RADIOLIB_ERR_NONE && !finished ? RADIOLIB_ERR_UNKNOWN : status; + } + int16_t prepareMorseCarrier(float frequency_mhz) override { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + auto* radio = static_cast(_radio); + const int16_t status = radio->setFrequency(frequency_mhz); + // The public power API preserves PA selection/calibration and converts + // the 304 us ramp to the LR11xx's offset encoding. + return status == RADIOLIB_ERR_NONE + ? radio->setOutputPower(_dbm_valid ? _cur_dbm : LORA_TX_POWER, false, 304) : status; + } + bool restoreCarrierWaveModem() override { + if (_morse_rx_fsk_mode) { + const bool restored = restoreAfterDeepInit(); + if (restored) _morse_rx_fsk_mode = false; + return restored; + } + return static_cast(_radio)->setOutputPower( + _dbm_valid ? _cur_dbm : LORA_TX_POWER) == RADIOLIB_ERR_NONE; + } void beginProfileRetune(bool continuousRx) override { static_cast(_radio)->beginProfileSwitch(continuousRx); } diff --git a/src/helpers/radiolib/CustomLR1121.h b/src/helpers/radiolib/CustomLR1121.h index e7b599432..5e7a30ab6 100644 --- a/src/helpers/radiolib/CustomLR1121.h +++ b/src/helpers/radiolib/CustomLR1121.h @@ -2,6 +2,8 @@ #include #include "MeshCore.h" +#include "LR11x0MorseRfSwitch.h" +#include "../MorseRx.h" class CustomLR1121 : public LR1121 { uint32_t _preambleMillis = 66; @@ -9,10 +11,34 @@ class CustomLR1121 : public LR1121 { uint32_t _activityAt = 0; bool _headerSeen = false; bool _rx_boosted = false; + LR11x0MorseRfSwitch _morse_rf_switch; public: CustomLR1121(Module *mod) : LR1121(mod) { } + void setRfSwitchTable(const uint32_t (&pins)[Module::RFSWITCH_MAX_PINS], + const Module::RfSwitchMode_t table[]) { + _morse_rf_switch.capture(this->mod, pins, table); + LR1121::setRfSwitchTable(pins, table); + } + + int16_t beginMorseReceive(float frequency_mhz) { + int16_t status = LR1121::beginGFSK(frequency_mhz, + mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 4.8f, 2, 16, tcxoVoltage); + if (status == RADIOLIB_ERR_NONE) status = setRegulatorDCDC(); + if (status == RADIOLIB_ERR_NONE) status = _morse_rf_switch.restore( + [this](uint8_t enable, const uint8_t* modes) { + return setDioAsRfSwitch(enable, modes[0], modes[1], modes[2], + modes[3], modes[4], modes[5], modes[6]); + }); + if (status == RADIOLIB_ERR_NONE) status = LR1121::setRxBoostedGainMode(_rx_boosted); + if (status == RADIOLIB_ERR_NONE) { + syncWordLength = preambleDetLength = 0; + status = LR1121::setCRC(0); + } + return status; + } + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, uint8_t syncWord = RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, int8_t power = 10, uint16_t preambleLength = 8, float tcxoVoltage = 1.6) { @@ -102,4 +128,4 @@ class CustomLR1121 : public LR1121 { } uint8_t getSpreadingFactor() const { return spreadingFactor; } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomLR1121Wrapper.h b/src/helpers/radiolib/CustomLR1121Wrapper.h index eab21f7d9..9a6dc561d 100644 --- a/src/helpers/radiolib/CustomLR1121Wrapper.h +++ b/src/helpers/radiolib/CustomLR1121Wrapper.h @@ -5,16 +5,58 @@ #include "LR11x0Reset.h" class CustomLR1121Wrapper : public RadioLibWrapper { + using DeepInitCallback = bool (*)(); + DeepInitCallback _deep_init = nullptr; public: ~CustomLR1121Wrapper() { unregisterInterruptAction(); } CustomLR1121Wrapper(CustomLR1121& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + bool supportsMorseTx() const override { return true; } + bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } + void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } void powerOff() { + if (isCarrierWaveActive()) setCarrierWave(0, 0); _radio->standby(); _radio->sleep(); } protected: + int16_t startMorseReceiver() override { + return static_cast(_radio)->LR1121::startReceive( + RADIOLIB_LR11X0_RX_TIMEOUT_INF, 0, 0, 0); + } + float readMorseRssi() override { + float rssi = NAN; + return static_cast(_radio)->getRssiInst(&rssi) == RADIOLIB_ERR_NONE ? rssi : NAN; + } + float morseReceiverBandwidthKHz() const override { return 4.8f; } + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!supportsRadioDeepInit()) return RADIOLIB_ERR_UNSUPPORTED; + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; + if (!prepareRadioHardReset()) return RADIOLIB_ERR_CHIP_NOT_FOUND; + const int16_t status = static_cast(_radio)->beginMorseReceive(frequency_mhz); + const bool finished = _board->finishRadioHardReset(); + return status == RADIOLIB_ERR_NONE && !finished ? RADIOLIB_ERR_UNKNOWN : status; + } + int16_t prepareMorseCarrier(float frequency_mhz) override { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + auto* radio = static_cast(_radio); + const int16_t status = radio->setFrequency(frequency_mhz); + return status == RADIOLIB_ERR_NONE + ? radio->setOutputPower(_dbm_valid ? _cur_dbm : LORA_TX_POWER, false, 304) : status; + } + bool restoreCarrierWaveModem() override { + if (_morse_rx_fsk_mode) { + const bool restored = restoreAfterDeepInit(); + if (restored) _morse_rx_fsk_mode = false; + return restored; + } + auto* radio = static_cast(_radio); + if (!_board->prepareRadioFrequency(_cur_freq) + || radio->setFrequency(_cur_freq) != RADIOLIB_ERR_NONE) return false; + return radio->setOutputPower(_dbm_valid ? _cur_dbm : LORA_TX_POWER) == RADIOLIB_ERR_NONE; + } bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { if (((CustomLR1121 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE || ((CustomLR1121 *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE @@ -56,6 +98,13 @@ public: uint8_t getSpreadingFactor() const override { return ((CustomLR1121 *)_radio)->getSpreadingFactor(); } protected: + bool radioDeepInit() override { + return _deep_init && prepareRadioHardReset() && _deep_init() + && _board->finishRadioHardReset(); + } + bool supportsRadioDeepInit() const override { + return _deep_init && supportsRadioHardResetPath(); + } bool applyRxBoostedGainMode(bool en) override { return ((CustomLR1121 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 625f62696..d639acd8b 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -3,6 +3,7 @@ #include #include "MeshCore.h" #include "LR2021Band.h" +#include "../MorseRx.h" #ifndef LR2021_TX_BUSY_TIMEOUT_MS #define LR2021_TX_BUSY_TIMEOUT_MS 1000UL @@ -17,10 +18,27 @@ class CustomLR2021 : public LR2021 { uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS] = {}; const Module::RfSwitchMode_t* _rf_switch_table = nullptr; - void restoreRfSwitchTable() { - if (_rf_switch_table != nullptr) { - LR2021::setRfSwitchTable(_rf_switch_pins, _rf_switch_table); + int16_t restoreRfSwitchTable() { + if (!_rf_switch_table) return RADIOLIB_ERR_NONE; + for (size_t i = 0; i < Module::RFSWITCH_MAX_PINS; ++i) { + const uint32_t pin = _rf_switch_pins[i]; + if (pin == RADIOLIB_NC || !(pin & RFSWITCH_PIN_FLAG)) continue; + const uint8_t dio = RADIOLIB_LRXXXX_DIOx_VAL(pin); + if (dio >= 7) return RADIOLIB_ERR_UNKNOWN; + uint8_t config = 0; + for (size_t j = 0; j < 8 && _rf_switch_table[j].mode != LR2021::MODE_END_OF_TABLE; ++j) { + const uint8_t mode = _rf_switch_table[j].mode; + if (mode > 0 && mode <= 8 + && _rf_switch_table[j].values[i] == this->mod->hal->GpioLevelHigh) + config |= 1UL << (mode - 1); + } + const uint8_t pull = dio == 0 ? RADIOLIB_LR2021_DIO_SLEEP_PULL_UP + : RADIOLIB_LR2021_DIO_SLEEP_PULL_AUTO; + int16_t status = setDioFunction(dio + 5, RADIOLIB_LR2021_DIO_FUNCTION_RF_SWITCH, pull); + if (status == RADIOLIB_ERR_NONE) status = setDioRfSwitchConfig(dio + 5, config); + if (status != RADIOLIB_ERR_NONE) return status; } + return RADIOLIB_ERR_NONE; } uint8_t rfSwitchRxMode() const { @@ -34,6 +52,24 @@ class CustomLR2021 : public LR2021 { public: CustomLR2021(Module *mod) : LR2021(mod) { irqDioNum = LR2021_IRQ_DIO; } + int16_t beginMorseReceive(float frequency_mhz) { + const uint8_t gain = mesh::lr2021::isHighBand(frequency_mhz) ? gainModeHf : gainModeLf; + int16_t status = LR2021::beginGFSK(frequency_mhz, + mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 4.8f, 2, 16, tcxoVoltage); + if (status == RADIOLIB_ERR_NONE) status = restoreRfSwitchTable(); + if (status == RADIOLIB_ERR_NONE) status = LR2021::setRxBoostedGainMode(gain); + if (status == RADIOLIB_ERR_NONE) { + uint8_t unused_sync = 0; + status = LR2021::setSyncWord(&unused_sync, 0); + } + if (status == RADIOLIB_ERR_NONE) { + preambleDetLength = 0; + status = LR2021::setCRC(0); + } + // launchMode() retains this chip's band-aware RX RF switch selection. + return status; + } + bool std_init(SPIClass* spi = NULL) { #ifdef LR2021_TCXO_VOLTAGE const float tcxo = LR2021_TCXO_VOLTAGE; @@ -81,7 +117,8 @@ class CustomLR2021 : public LR2021 { #endif // begin() resets the radio, including its DIO-controlled RF routing. - restoreRfSwitchTable(); + status = restoreRfSwitchTable(); + if (status != RADIOLIB_ERR_NONE) return false; return true; // success } @@ -108,6 +145,13 @@ class CustomLR2021 : public LR2021 { float getFreqMHz() const { return freqMHz; } + int16_t transmitMorseCarrier() { + // RadioLib's generic direct-TX path always selects MODE_TX. Use the + // band-aware routing also used by our normal packet transmit path. + this->mod->setRfSwitchState(rfSwitchTxMode()); + return this->setTxTestMode(RADIOLIB_LR2021_TX_TEST_MODE_CW); + } + int16_t setRxBoostedGainMode(uint8_t level) { int16_t status = LR2021::setRxBoostedGainMode(level); if (status == RADIOLIB_ERR_NONE) _rx_boosted = level != 0; diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 51ba6bab3..e32e38d14 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -18,10 +18,61 @@ class CustomLR2021Wrapper : public RadioLibWrapper { public: ~CustomLR2021Wrapper() { unregisterInterruptAction(); } CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + bool supportsMorseTx() const override { return true; } + bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } - void powerOff() { _radio->standby(); _radio->sleep(); } + void powerOff() { + if (isCarrierWaveActive()) setCarrierWave(0, 0); + _radio->standby(); _radio->sleep(); + } protected: + int16_t startMorseReceiver() override { + return static_cast(_radio)->LR2021::startReceive( + RADIOLIB_LR2021_RX_TIMEOUT_INF, 0, 0, 0); + } + float readMorseRssi() override { + float rssi = NAN; + return static_cast(_radio)->getRssiInst(&rssi) == RADIOLIB_ERR_NONE ? rssi : NAN; + } + float morseReceiverBandwidthKHz() const override { return 4.8f; } + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!supportsRadioDeepInit()) return RADIOLIB_ERR_UNSUPPORTED; + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; + if (!prepareRadioHardReset()) return RADIOLIB_ERR_CHIP_NOT_FOUND; + const int16_t status = static_cast(_radio)->beginMorseReceive(frequency_mhz); + const bool finished = _board->finishRadioHardReset(); + return status == RADIOLIB_ERR_NONE && !finished ? RADIOLIB_ERR_UNKNOWN : status; + } + bool validateMorseFrequency(float frequency_mhz) const override { + return (frequency_mhz >= 150.0f && frequency_mhz <= 1090.0f) + || (frequency_mhz >= 1900.0f && frequency_mhz <= 2500.0f); + } + int16_t prepareMorseCarrier(float frequency_mhz) override { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + auto* radio = static_cast(_radio); + const int16_t status = radio->setFrequency(frequency_mhz); + // Re-evaluate the PA and board power limit at the Morse frequency, not + // at the cached packet profile, when crossing LF/HF bands. + return status == RADIOLIB_ERR_NONE ? radio->setOutputPower( + mesh::clampLoRaTxPower(_dbm_valid ? _cur_dbm : LORA_TX_POWER, frequency_mhz), 304) : status; + } + int16_t enterMorseCarrier() override { + return static_cast(_radio)->transmitMorseCarrier(); + } + bool restoreCarrierWaveModem() override { + if (_morse_rx_fsk_mode) { + const bool restored = restoreAfterDeepInit(); + if (restored) _morse_rx_fsk_mode = false; + return restored; + } + auto* radio = static_cast(_radio); + if (!_board->prepareRadioFrequency(_cur_freq) + || radio->setFrequency(_cur_freq) != RADIOLIB_ERR_NONE) return false; + return radio->setOutputPower(mesh::clampLoRaTxPower( + _dbm_valid ? _cur_dbm : LORA_TX_POWER, _cur_freq)) == RADIOLIB_ERR_NONE; + } bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { CustomLR2021* radio = (CustomLR2021 *)_radio; if (!_board->prepareRadioFrequency(freq)) return false; @@ -53,6 +104,7 @@ protected: public: bool setCodingRate(uint8_t cr) override { + if (isCarrierWaveActive()) return false; return ((CustomLR2021 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSTM32WLx.h b/src/helpers/radiolib/CustomSTM32WLx.h index e312a5ee4..4efd425b9 100644 --- a/src/helpers/radiolib/CustomSTM32WLx.h +++ b/src/helpers/radiolib/CustomSTM32WLx.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include "SX126xMorseRssi.h" class CustomSTM32WLx : public STM32WLx { uint32_t _preambleMillis = 66; @@ -10,6 +12,14 @@ class CustomSTM32WLx : public STM32WLx { public: CustomSTM32WLx(STM32WLx_Module *mod) : STM32WLx(mod) { } + int16_t setMorseFastRssi() { + return setSX126xMorseRssiWindow(this); + } + float getMorseRssi() { + uint8_t raw = 0; + const int16_t status = this->getMod()->SPIreadStream(RADIOLIB_SX126X_CMD_GET_RSSI_INST, &raw, 1); + return status == RADIOLIB_ERR_NONE ? raw * -0.5f : NAN; + } int16_t startReceive() override { // include the PREAMBLE_DETECTED irq bit in reported flags @@ -68,4 +78,4 @@ class CustomSTM32WLx : public STM32WLx { MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index af3c94cbd..61351aaaf 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -4,17 +4,63 @@ #include "CustomSTM32WLx.h" #include "RadioLibWrappers.h" #include "SX126xReset.h" +#include "SX126xMorseRamp.h" +#include "SX126xMorseReceiver.h" #include class CustomSTM32WLxWrapper : public RadioLibWrapper { + using DeepInitCallback = bool (*)(); + DeepInitCallback _deep_init = nullptr; protected: int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } public: ~CustomSTM32WLxWrapper() { unregisterInterruptAction(); } CustomSTM32WLxWrapper(CustomSTM32WLx& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + bool supportsMorseTx() const override { return true; } +#if MESH_ENABLE_MORSE_RX + bool supportsMorseRx() const override { return supportsRadioDeepInit(); } +#endif + void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } protected: +#if MESH_ENABLE_MORSE_RX + float morseReceiverBandwidthKHz() const override { return 4.8f; } + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!supportsRadioDeepInit()) return RADIOLIB_ERR_UNSUPPORTED; + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; + return prepareSX126xMorseReceiver(static_cast(_radio), frequency_mhz); + } +#endif + bool radioDeepInit() override { + // STM32WLx begin() owns the integrated radio reset. Reuse target.cpp's + // complete RF switch/TCXO setup rather than pretending there is an NRST. + return _deep_init && _deep_init(); + } + bool supportsRadioDeepInit() const override { return _deep_init != nullptr; } +#if MESH_ENABLE_MORSE_RX + float readMorseRssi() override { return static_cast(_radio)->getMorseRssi(); } +#endif + int16_t prepareMorseCarrier(float frequency_mhz) override { + const int16_t status = RadioLibWrapper::prepareMorseCarrier(frequency_mhz); + return status == RADIOLIB_ERR_NONE + ? setSX126xMorseRamp(static_cast(_radio)) : status; + } + int16_t leaveMorseCarrier() override { + return leaveSX126xMorseCarrier(static_cast(_radio)); + } + bool restoreCarrierWaveModem() override { +#if MESH_ENABLE_MORSE_RX + if (_morse_rx_fsk_mode) { + const bool restored = restoreAfterDeepInit(); + if (restored) _morse_rx_fsk_mode = false; + return restored; + } +#endif + return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE + && RadioLibWrapper::restoreCarrierWaveModem(); + } bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { return ((CustomSTM32WLx *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE && ((CustomSTM32WLx *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE @@ -25,6 +71,7 @@ protected: public: bool setCodingRate(uint8_t cr) override { + if (isCarrierWaveActive()) return false; return ((CustomSTM32WLx *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 2e85d3f4f..9c842a01d 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -1,9 +1,11 @@ #pragma once #include +#include #include "MeshCore.h" #include "RXPowerSaving.h" #include "SX1262ProfileSwitchState.h" +#include "SX126xMorseRssi.h" #ifndef MC_SX1262_FAST_PROFILE_SWITCH // CustomSX1262 targets use direct radio I/O unless a board opts out. Keep @@ -42,6 +44,19 @@ class CustomSX1262 : public SX1262 { public: CustomSX1262(Module *mod) : SX1262(mod) { } + void invalidateMorseModemCache() { + _profileSwitch.invalidate(); + _coldStandby = true; + _tcxoWakePending = false; + } + int16_t setMorseFastRssi() { + return setSX126xMorseRssiWindow(this); + } + float getMorseRssi() { + uint8_t raw = 0; + const int16_t status = this->getMod()->SPIreadStream(RADIOLIB_SX126X_CMD_GET_RSSI_INST, &raw, 1); + return status == RADIOLIB_ERR_NONE ? raw * -0.5f : NAN; + } void setProfileSwitchOptimization(bool enabled) { _profileSwitch.enabled = enabled; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 3f3de161a..687880b1e 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -4,6 +4,8 @@ #include "CustomSX1262.h" #include "RadioLibWrappers.h" #include "SX126xReset.h" +#include "SX126xMorseRamp.h" +#include "SX126xMorseReceiver.h" #ifndef USE_SX1262 #define USE_SX1262 @@ -38,11 +40,36 @@ public: ~CustomSX1262Wrapper() { unregisterInterruptAction(); } CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsCarrierWave() const override { return true; } + bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } #if MESH_ENABLE_MESHTEXT_CHAT bool supportsMeshtext() const override { return true; } #endif protected: + float morseReceiverBandwidthKHz() const override { return 4.8f; } + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; // partial reset/configuration also needs full restoration + if (!prepareRadioHardReset()) return RADIOLIB_ERR_UNKNOWN; + auto* radio = static_cast(_radio); + radio->invalidateMorseModemCache(); + const int16_t status = prepareSX126xMorseReceiver(radio, frequency_mhz); + return status == RADIOLIB_ERR_NONE && !_board->finishRadioHardReset() + ? RADIOLIB_ERR_UNKNOWN : status; + } + float readMorseRssi() override { return static_cast(_radio)->getMorseRssi(); } + int16_t prepareMorseCarrier(float frequency_mhz) override { + const int16_t status = RadioLibWrapper::prepareMorseCarrier(frequency_mhz); + return status == RADIOLIB_ERR_NONE + ? setSX126xMorseRamp(static_cast(_radio)) : status; + } + int16_t leaveMorseCarrier() override { + return leaveSX126xMorseCarrier(static_cast(_radio)); + } + bool restoreCarrierWaveModem() override { + return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE + && RadioLibWrapper::restoreCarrierWaveModem(); + } #if MESH_ENABLE_MESHTEXT_CHAT bool applyProtocol(mesh::RadioProfileProtocol protocol) override { auto* radio = static_cast(_radio); diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index 18fc85050..eb0176b61 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include "SX126xMorseRssi.h" #ifndef SX126X_TX_BUSY_TIMEOUT_MS #define SX126X_TX_BUSY_TIMEOUT_MS 1000UL @@ -14,6 +16,14 @@ class CustomSX1268 : public SX1268 { public: CustomSX1268(Module *mod) : SX1268(mod) { } + int16_t setMorseFastRssi() { + return setSX126xMorseRssiWindow(this); + } + float getMorseRssi() { + uint8_t raw = 0; + const int16_t status = this->getMod()->SPIreadStream(RADIOLIB_SX126X_CMD_GET_RSSI_INST, &raw, 1); + return status == RADIOLIB_ERR_NONE ? raw * -0.5f : NAN; + } #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 0cff501de..6d0b6325d 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -4,6 +4,8 @@ #include "CustomSX1268.h" #include "RadioLibWrappers.h" #include "SX126xReset.h" +#include "SX126xMorseRamp.h" +#include "SX126xMorseReceiver.h" #ifndef USE_SX1268 #define USE_SX1268 @@ -16,10 +18,40 @@ protected: public: ~CustomSX1268Wrapper() { unregisterInterruptAction(); } CustomSX1268Wrapper(CustomSX1268& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + bool supportsMorseTx() const override { return true; } + bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } - void powerOff() { _radio->standby(); _radio->sleep(); } + void powerOff() { + if (isCarrierWaveActive()) setCarrierWave(0, 0); + _radio->standby(); _radio->sleep(); + } protected: + float morseReceiverBandwidthKHz() const override { return 4.8f; } + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; + if (!prepareRadioHardReset()) return RADIOLIB_ERR_UNKNOWN; + const int16_t status = prepareSX126xMorseReceiver(static_cast(_radio), frequency_mhz); + return status == RADIOLIB_ERR_NONE && !_board->finishRadioHardReset() + ? RADIOLIB_ERR_UNKNOWN : status; + } + float readMorseRssi() override { return static_cast(_radio)->getMorseRssi(); } + bool validateMorseFrequency(float frequency_mhz) const override { + return frequency_mhz >= 410.0f && frequency_mhz <= 810.0f; + } + int16_t prepareMorseCarrier(float frequency_mhz) override { + const int16_t status = RadioLibWrapper::prepareMorseCarrier(frequency_mhz); + return status == RADIOLIB_ERR_NONE + ? setSX126xMorseRamp(static_cast(_radio)) : status; + } + int16_t leaveMorseCarrier() override { + return leaveSX126xMorseCarrier(static_cast(_radio)); + } + bool restoreCarrierWaveModem() override { + return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE + && RadioLibWrapper::restoreCarrierWaveModem(); + } bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { bool success = ((CustomSX1268 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE && ((CustomSX1268 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE @@ -36,6 +68,7 @@ protected: public: bool setCodingRate(uint8_t cr) override { + if (isCarrierWaveActive()) return false; return ((CustomSX1268 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSX1276.h b/src/helpers/radiolib/CustomSX1276.h index 9e7ce1b85..20428b6b1 100644 --- a/src/helpers/radiolib/CustomSX1276.h +++ b/src/helpers/radiolib/CustomSX1276.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include "MeshCore.h" +#include "../MorseRx.h" #define RH_RF95_MODEM_STATUS_CLEAR 0x10 #define RH_RF95_MODEM_STATUS_HEADER_INFO_VALID 0x08 @@ -13,6 +15,36 @@ class CustomSX1276 : public SX1276 { public: CustomSX1276(Module *mod) : SX1276(mod) { } + int16_t beginMorseReceive(float frequency_mhz) { + int16_t status = SX1276::beginFSK(frequency_mhz, + mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 2.6f, 2, 16, false); +#ifdef SX127X_CURRENT_LIMIT + if (status == RADIOLIB_ERR_NONE) status = setCurrentLimit(SX127X_CURRENT_LIMIT); +#endif + // The API argument is the register encoding: zero means two samples, + // not zero samples. Continuous direct RX has no preamble/sync gating. + if (status == RADIOLIB_ERR_NONE) status = setRSSIConfig(0); + return status; + } + + float getMorseRssi() { + // SX127x has no SPI command-status byte. Check modem and chip identity + // instead of interpreting an absent/wrong-modem read as valid power. + const uint8_t version = getChipVersion(); + if (getActiveModem() != RADIOLIB_SX127X_FSK_OOK + || (version != RADIOLIB_SX1278_CHIP_VERSION + && version != RADIOLIB_SX1278_CHIP_VERSION_ALT + && version != RADIOLIB_SX1278_CHIP_VERSION_RFM9X)) return NAN; + const int16_t raw = this->mod->SPIgetRegValue(RADIOLIB_SX127X_REG_RSSI_VALUE_FSK); + return raw < 0 ? NAN : raw * -0.5f; + } + + int16_t setMorsePaRamp() { + // Slowest supported PA envelope (3400 us), to soften CW keying edges. + // std_init() restores RadioLib's normal packet ramp after Morse. + return this->mod->SPIsetRegValue(RADIOLIB_SX127X_REG_PA_RAMP, 0, 3, 0); + } + #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) #else diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index faed96f2a..40a421a97 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -12,6 +12,7 @@ public: ~CustomSX1276Wrapper() { unregisterInterruptAction(); } CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsCarrierWave() const override { return supportsRadioDeepInit(); } + bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -19,6 +20,9 @@ public: } protected: + int16_t startMorseReceiver() override { + return static_cast(_radio)->receiveDirect(); + } bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { return ((CustomSX1276 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE && ((CustomSX1276 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE @@ -46,6 +50,38 @@ public: uint8_t getSpreadingFactor() const override { return ((CustomSX1276 *)_radio)->spreadingFactor; } protected: + float morseReceiverBandwidthKHz() const override { return 2.6f; } + float readMorseRssi() override { return static_cast(_radio)->getMorseRssi(); } + int16_t prepareMorseReceiver(float frequency_mhz) override { + if (!supportsRadioDeepInit()) return RADIOLIB_ERR_UNSUPPORTED; + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + _morse_rx_fsk_mode = true; + if (!prepareRadioHardReset()) return RADIOLIB_ERR_CHIP_NOT_FOUND; + const int16_t status = static_cast(_radio)->beginMorseReceive(frequency_mhz); + const bool finished = _board->finishRadioHardReset(); + return status == RADIOLIB_ERR_NONE && !finished ? RADIOLIB_ERR_UNKNOWN : status; + } + bool validateMorseFrequency(float frequency_mhz) const override { + return isfinite(frequency_mhz) && frequency_mhz >= 137.0f && frequency_mhz <= 1020.0f; + } + + int16_t prepareMorseCarrier(float frequency_mhz) override { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + auto* radio = static_cast(_radio); + // Zero deviation produces an unmodulated carrier regardless of DIO2. + // RX bandwidth is immaterial for this TX-only operation. FSK is set up + // once per message, never once per dot/dash. + int16_t status = radio->beginFSK(frequency_mhz, 4.8, 0.0, 125.0, 2, 16, false); + if (status != RADIOLIB_ERR_NONE) return status; + status = radio->setMorsePaRamp(); + if (status != RADIOLIB_ERR_NONE) return status; +#ifdef SX127X_CURRENT_LIMIT + status = radio->setCurrentLimit(SX127X_CURRENT_LIMIT); + if (status != RADIOLIB_ERR_NONE) return status; +#endif + return applyCachedTxPower(_dbm_valid ? _cur_dbm : LORA_TX_POWER); + } + int16_t enterCarrierWave() override { auto* radio = static_cast(_radio); // Direct TX needs FSK. Zero deviation prevents a floating DIO2 data input @@ -65,7 +101,9 @@ protected: bool restoreCarrierWaveModem() override { // Restores LoRa, runtime profile/preamble, IRQ mapping and PA calibration. // Shared cleanup retains the stop/retry guard if any restoration fails. - return restoreAfterDeepInit(); + const bool restored = restoreAfterDeepInit(); + if (restored) _morse_rx_fsk_mode = false; + return restored; } int16_t performChannelScan() override { diff --git a/src/helpers/radiolib/LR11x0MorseRfSwitch.h b/src/helpers/radiolib/LR11x0MorseRfSwitch.h new file mode 100644 index 000000000..7c5cedd11 --- /dev/null +++ b/src/helpers/radiolib/LR11x0MorseRfSwitch.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +// Native beginGFSK resets the LR11xx's DIO switch registers. Cache the same +// seven mode masks used by RadioLib, then restore them with a checked command +// before entering RX (its public setRfSwitchTable otherwise discards errors). +class LR11x0MorseRfSwitch { + uint8_t enable_ = 0; + uint8_t modes_[7] = {}; + bool configured_ = false; +public: + void capture(Module* module, + const uint32_t (&pins)[Module::RFSWITCH_MAX_PINS], + const Module::RfSwitchMode_t table[]) { + enable_ = 0; + for (auto& mode : modes_) mode = 0; + configured_ = table != nullptr; + if (!configured_) return; + for (size_t i = 0; i < Module::RFSWITCH_MAX_PINS; ++i) { + if (pins[i] == RADIOLIB_NC || !(pins[i] & RFSWITCH_PIN_FLAG)) continue; + enable_ |= 1UL << RADIOLIB_LRXXXX_DIOx_VAL(pins[i]); + } + for (size_t i = 0; i < 7 && table[i].mode != LR11x0::MODE_END_OF_TABLE; ++i) { + const uint8_t index = table[i].mode - LR11x0::MODE_STBY; + if (index >= 7) continue; + for (size_t j = 0; j < Module::RFSWITCH_MAX_PINS; ++j) { + if (pins[j] == RADIOLIB_NC || !(pins[j] & RFSWITCH_PIN_FLAG)) continue; + if (table[i].values[j] == module->hal->GpioLevelHigh) modes_[index] |= 1UL << j; + } + } + } + template + int16_t restore(Apply apply) const { + return !configured_ ? RADIOLIB_ERR_NONE : apply(enable_, modes_); + } +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index d825df80e..c6b8d0fa2 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -2,6 +2,15 @@ #define RADIOLIB_STATIC_ONLY 1 #include "RadioLibWrappers.h" #include "RadioInterruptEvents.h" +#include +#if defined(ESP32_PLATFORM) +#include +#elif defined(NRF52_PLATFORM) +#include +#elif defined(STM32_PLATFORM) +#include +extern "C" caddr_t _sbrk(int increment); +#endif #define STATE_IDLE 0 #define STATE_RX 1 @@ -223,6 +232,10 @@ uint32_t RadioLibWrapper::getRngSeed() { } bool RadioLibWrapper::setTxPower(int8_t dbm) { + // A CW power-meter sweep may re-key the carrier. During Morse that would + // turn an inter-symbol gap into RF and corrupt the text, so keep the whole + // message exclusive (including cleanup retries). + if ((_morse_owned || _morse_rx_owned) && _cw_active) return false; if (_cw_active) { if (_cw_stopping) return false; // Permit meter sweeps through the board's normal power/PA calibration. @@ -992,18 +1005,23 @@ void RadioLibWrapper::checkReceiveMode(uint32_t now) { uint32_t RadioLibWrapper::carrierWaveRemainingMillis() const { if (!_cw_active || _cw_stopping) return 0; + if (_morse_owned) return morseTxRemainingMillis(); + if (_morse_rx_owned) return morseRxRemainingMillis(); const int32_t remaining = static_cast(_cw_until - millis()); return remaining > 0 ? static_cast(remaining) : 0; } mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uint32_t duration_ms) { using Result = mesh::RadioParamApplyResult; + // The shared emergency stop also covers chips exposing Morse without the + // older profile-CW CLI capability. + if (!duration_ms && (_morse_owned || _morse_rx_owned)) return stopCarrierWave() ? Result::APPLIED : Result::FAILED; if (!supportsCarrierWave()) return Result::FAILED; 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 (_cw_active) { - if (_cw_stopping || profile != _cw_profile + if (_morse_owned || _morse_rx_owned || _cw_stopping || profile != _cw_profile || _cw_generation != _profiles.generation[profile]) return Result::BUSY; _cw_until = static_cast(millis()) + duration_ms; return Result::APPLIED; @@ -1028,6 +1046,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin _wd_observe_until = 0; _cw_profile = profile; _cw_generation = _profiles.generation[profile]; + _morse_owned = false; _cw_active = true; _cw_stopping = false; _board->setRadioTestActive(true); // service the deadline even without USB @@ -1041,14 +1060,321 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin return Result::APPLIED; } +bool RadioLibWrapper::supportsMorseTx() const { + return supportsCarrierWave(); +} + +mesh::MorseTxResult RadioLibWrapper::startMorseTx(float frequency_mhz, + uint8_t wpm, const char* text) { + using Result = mesh::MorseTxResult; + if (!supportsMorseTx()) return Result::Unsupported; + if (_cw_active) return Result::Busy; + 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; + // 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; + for (uint8_t profile = 0; profile < 2; ++profile) { + const auto& p = _profiles.params(profile); + if (mesh::morseFrequencyOverlaps(frequency_mhz, p.freq, p.bw)) return Result::Overlap; + } + const uint8_t resume = beginReconfigure(); + if (resume > 1) return Result::Busy; + _cw_restore_profile = _active_profile; + if (_radio->standby() != RADIOLIB_ERR_NONE + || _radio->clearIrqFlags(UINT32_MAX) != RADIOLIB_ERR_NONE) { + endReconfigure(resume); + return Result::Failed; + } + + // Publish ownership before the first hardware mutation; even a failed SPI + // command can have reached the chip, requiring a confirmed stop/restore. + _radio->clearPacketReceivedAction(); + _cw_active = _morse_owned = true; + _cw_stopping = false; + _cw_profile = _active_profile; + _morse_generation[0] = _profiles.generation[0]; + _morse_generation[1] = _profiles.generation[1]; + _morse_frequency = frequency_mhz; + _morse_wpm = wpm; + _rx_ps_armed = false; + _rx_hold_continuous = false; + _nf_calib_active = false; + _wd_observe_until = 0; + state = STATE_IDLE; + _board->setRadioTestActive(true); + if (prepareMorseCarrier(frequency_mhz) != RADIOLIB_ERR_NONE) { + stopCarrierWave(); + return Result::Failed; + } + _board->onBeforeTransmit(); + _cw_board_tx = true; + if (enterMorseCarrier() != RADIOLIB_ERR_NONE) { + stopCarrierWave(); + return Result::Failed; + } + // Start timing when the hardware command has finished, not before the + // one-time oscillator/modem setup. Validation above guarantees success. + _morse.start(text, wpm, static_cast(millis())); + return Result::Started; +} + +mesh::MorseTxResult RadioLibWrapper::stopMorseTx() { + if (!supportsMorseTx()) return mesh::MorseTxResult::Unsupported; + if (!_morse_owned) return mesh::MorseTxResult::Stopped; + return stopCarrierWave() ? mesh::MorseTxResult::Stopped : mesh::MorseTxResult::Failed; +} + +bool RadioLibWrapper::serviceMorseTx() { + if (!_morse_owned || !_cw_active) return false; + const uint32_t now = millis(); + if (_cw_stopping) { + if (static_cast(now - _cw_retry_at) >= 0) stopCarrierWave(); + return true; + } + if (_morse_generation[0] != _profiles.generation[0] + || _morse_generation[1] != _profiles.generation[1]) { + stopCarrierWave(); + return true; + } + // The public profile model is also used by board-specific integrations. + // Fail closed if one changes a tuple directly without bumping generation. + for (uint8_t profile = 0; profile < 2; ++profile) { + const auto& p = _profiles.params(profile); + if (mesh::morseFrequencyOverlaps(_morse_frequency, p.freq, p.bw)) { + stopCarrierWave(); + return true; + } + } + const auto edge = _morse.advance(now); + if (edge == mesh::MorseTx::Edge::Complete || edge == mesh::MorseTx::Edge::Expired) { + stopCarrierWave(); + } else if (edge == mesh::MorseTx::Edge::KeyUp) { + const int16_t status = leaveMorseCarrier(); + if (_cw_board_tx) { _board->onAfterTransmit(); _cw_board_tx = false; } + if (status != RADIOLIB_ERR_NONE) stopCarrierWave(); + else _morse.edgeApplied(static_cast(millis())); + } else if (edge == mesh::MorseTx::Edge::KeyDown) { + _board->onBeforeTransmit(); + _cw_board_tx = true; + if (enterMorseCarrier() != RADIOLIB_ERR_NONE) stopCarrierWave(); + else _morse.edgeApplied(static_cast(millis())); + } + return true; +} + +#if MESH_ENABLE_MORSE_RX +bool RadioLibWrapper::canAllocateMorseReceiver() const { + // This is a real, opt-in runtime reservation, never hidden in the static + // RAM baseline or silently borrowed from an independently running OTA. + size_t allocation = _morse_rx ? 0U : sizeof(mesh::MorseRx); +#if defined(ESP32_PLATFORM) + size_t largest_allocation = allocation; +#endif +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (!_morse_trace) { + allocation += sizeof(mesh::MorseRssiTrace); +#if defined(ESP32_PLATFORM) + if (sizeof(mesh::MorseRssiTrace) > largest_allocation) + largest_allocation = sizeof(mesh::MorseRssiTrace); +#endif + } +#endif + // A retained transcript/trace is reused in place after preparation succeeds. + // Require only allocations still missing, while keeping the same reserve. + const size_t required = allocation + 4096U; +#if defined(ESP32_PLATFORM) + return ESP.getFreeHeap() >= required + && (!largest_allocation + || heap_caps_get_largest_free_block(MALLOC_CAP_8BIT) >= largest_allocation); +#elif defined(NRF52_PLATFORM) + const int available = dbgHeapFree(); + return available > 0 && static_cast(available) >= required; +#elif defined(RP2040_PLATFORM) + const int available = rp2040.getFreeHeap(); + return available > 0 && static_cast(available) >= required; +#elif defined(STM32_PLATFORM) + uint8_t stack_marker; + const caddr_t heap_end = _sbrk(0); + if (heap_end == (caddr_t)-1) return false; + const uintptr_t stack_address = reinterpret_cast(&stack_marker); + const uintptr_t heap_address = reinterpret_cast(heap_end); + return stack_address > heap_address && stack_address - heap_address >= required; +#else + return false; // an unmeasured allocator must not consume the safety reserve +#endif +} + +mesh::MorseRxResult RadioLibWrapper::startMorseRx(float frequency_mhz, + uint8_t wpm, uint32_t duration_ms) { + using Result = mesh::MorseRxResult; + if (!supportsMorseRx()) return Result::Unsupported; + if (_cw_active) return Result::Busy; + const Result valid = mesh::MorseRx::validate(wpm, duration_ms); + if (valid != Result::Started) return valid; + if (!_params_valid || !isfinite(frequency_mhz) || !validateMorseFrequency(frequency_mhz)) + return Result::InvalidFrequency; + // Passive reception cannot interfere with either packet profile. Listening + // on its centre is useful; only a transmitting Morse carrier needs spacing. + if (!canAllocateMorseReceiver()) return Result::Failed; + mesh::MorseRx* receiver = _morse_rx; + const bool allocated = receiver == nullptr; + if (allocated) receiver = new (std::nothrow) mesh::MorseRx; + if (!receiver) return Result::Failed; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + mesh::MorseRssiTrace* trace = _morse_trace; + const bool trace_allocated = trace == nullptr; + if (trace_allocated) trace = new (std::nothrow) mesh::MorseRssiTrace; + if (!trace) { if (allocated) delete receiver; return Result::Failed; } +#endif + // Allocation/admission occurs before any hardware change. Keep a previous + // transcript untouched until the replacement receiver is proven running. + const uint8_t resume = beginReconfigure(); + if (resume > 1) { + if (allocated) delete receiver; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (trace_allocated) delete trace; +#endif + return Result::Busy; + } + _cw_restore_profile = _active_profile; + if (_radio->standby() != RADIOLIB_ERR_NONE + || _radio->clearIrqFlags(UINT32_MAX) != RADIOLIB_ERR_NONE) { + if (allocated) delete receiver; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (trace_allocated) delete trace; +#endif + endReconfigure(resume); + return Result::Failed; + } + _radio->clearPacketReceivedAction(); + _cw_active = _morse_rx_owned = true; + _morse_owned = _cw_stopping = false; + _cw_profile = _active_profile; + _morse_generation[0] = _profiles.generation[0]; + _morse_generation[1] = _profiles.generation[1]; + _rx_ps_armed = _rx_hold_continuous = _nf_calib_active = false; + _wd_observe_until = 0; + _morse_rx_read_errors = 0; + state = STATE_IDLE; + _board->setRadioTestActive(true); + if (prepareMorseReceiver(frequency_mhz) != RADIOLIB_ERR_NONE + || (_rx_boosted_gain_valid && !applyRxBoostedGainMode(_cur_rx_boosted_gain)) + || startMorseReceiver() != RADIOLIB_ERR_NONE) { + if (allocated) delete receiver; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (trace_allocated) delete trace; +#endif + stopCarrierWave(); + return Result::Failed; + } + const float initial_rssi = readMorseRssi(); + if (!isfinite(initial_rssi) || initial_rssi < -180.0f || initial_rssi > 20.0f) { + if (allocated) delete receiver; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (trace_allocated) delete trace; +#endif + stopCarrierWave(); + return Result::Failed; + } + const uint32_t now = millis(); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + receiver->setTrace(trace); + _morse_trace = trace; +#endif + receiver->start(wpm, duration_ms, now); + receiver->sample(initial_rssi, now); + _morse_rx = receiver; + _morse_rx_frequency = frequency_mhz; + _morse_rx_next_sample = now + mesh::MorseRx::SampleIntervalMillis; + ++_morse_capture_generation; + return Result::Started; +} + +mesh::MorseRxResult RadioLibWrapper::stopMorseRx() { + if (!supportsMorseRx()) return mesh::MorseRxResult::Unsupported; + if (!_morse_rx_owned) return mesh::MorseRxResult::Stopped; + return stopCarrierWave() ? mesh::MorseRxResult::Stopped : mesh::MorseRxResult::Failed; +} + +size_t RadioLibWrapper::readMorseCapture(size_t offset, char* out, size_t capacity) const { + if (_morse_rx) return _morse_rx->read(offset, out, capacity); + if (out && capacity) out[0] = 0; + return 0; +} + +void RadioLibWrapper::clearMorseCapture() { + if (isMorseRxActive()) return; + delete _morse_rx; + _morse_rx = nullptr; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + delete _morse_trace; + _morse_trace = nullptr; +#endif + ++_morse_capture_generation; +} +#endif + +bool RadioLibWrapper::serviceMorseRx() { +#if !MESH_ENABLE_MORSE_RX + return false; +#else + if (!_morse_rx_owned || !_cw_active) return false; + const uint32_t now = millis(); + if (_cw_stopping) { + if (static_cast(now - _cw_retry_at) >= 0) stopCarrierWave(); + return true; + } + if (!_morse_rx || !_morse_rx->service(now) + || _morse_generation[0] != _profiles.generation[0] + || _morse_generation[1] != _profiles.generation[1]) { + stopCarrierWave(); + return true; + } + if (static_cast(now - _morse_rx_next_sample) >= 0) { + const float rssi = readMorseRssi(); + const uint32_t sampled_at = millis(); + _morse_rx_next_sample = sampled_at + mesh::MorseRx::SampleIntervalMillis; + if (!isfinite(rssi) || rssi < -180.0f || rssi > 20.0f) { + if (++_morse_rx_read_errors >= 8) stopCarrierWave(); + } else { + _morse_rx_read_errors = 0; + _morse_rx->sample(rssi, sampled_at); + if (!_morse_rx->active()) stopCarrierWave(); + } + } + return true; +#endif +} + bool RadioLibWrapper::stopCarrierWave() { + _morse.stop(); +#if MESH_ENABLE_MORSE_RX + if (_morse_rx_owned && _morse_rx) _morse_rx->stop(static_cast(millis())); +#endif _cw_stopping = true; // Deassert external TX even if SPI fails. SX127x must also rebuild its LoRa // modem after FSK direct mode before normal packet work can resume. const int16_t status = _radio->finishTransmit(); + // Some RadioLib finishTransmit() implementations return after a failed IRQ + // clear, before their standby command. Always attempt the RF-off command. + const int16_t standby_status = _radio->standby(); if (_cw_board_tx) { _board->onAfterTransmit(); _cw_board_tx = false; } - const bool restored = status == RADIOLIB_ERR_NONE - ? restoreCarrierWaveModem() : restoreAfterDeepInit(); + bool restored; +#if MESH_ENABLE_MORSE_RX + if (_morse_rx_fsk_mode) { + // applyParams only changes a LoRa tuple; it cannot rebuild a modem switched + // to GFSK. This includes failed partial preparation and cleanup retries. + restored = restoreAfterDeepInit(); + if (restored) _morse_rx_fsk_mode = false; + } else { +#else + { +#endif + restored = status == RADIOLIB_ERR_NONE || standby_status == RADIOLIB_ERR_NONE + ? restoreCarrierWaveModem() : restoreAfterDeepInit(); + } if (!restored) { _cw_retry_at = static_cast(millis()) + 100; return false; // hold normal work until stopping the carrier is confirmed @@ -1067,11 +1393,23 @@ bool RadioLibWrapper::stopCarrierWave() { recalibrateNoiseFloor(); startRecv(); if (!isInRecvMode() && restoreAfterDeepInit()) startRecv(); + if (!tuned || !isInRecvMode()) { + // A stopped carrier is not sufficient to hand ownership back if the + // configured packet modem/profile could not be restored. Keep the guard + // and watchdog wake veto until the bounded retry repairs reception. + _cw_active = _cw_stopping = true; + _cw_retry_at = static_cast(millis()) + 100; + return false; + } + _morse_owned = false; + _morse_rx_owned = false; _board->setRadioTestActive(false); - return tuned && isInRecvMode(); + return true; } bool RadioLibWrapper::serviceCarrierWave() { + if (serviceMorseRx()) return true; + if (serviceMorseTx()) return true; if (_cw_active) { const uint32_t now = millis(); if ((_cw_stopping && static_cast(now - _cw_retry_at) >= 0) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 8b540dae1..c9e7db8c6 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -109,8 +109,28 @@ protected: bool _cw_board_tx = false; uint8_t _cw_profile = 0, _cw_restore_profile = 0; uint32_t _cw_until = 0, _cw_retry_at = 0, _cw_generation = 0; + mesh::MorseTx _morse; + bool _morse_owned = false; + float _morse_frequency = mesh::MorseTx::DefaultFrequencyMHz; + uint8_t _morse_wpm = mesh::MorseTx::DefaultWpm; + uint32_t _morse_generation[2] = {}; + // Allocate the bounded capture only on explicit RX admission. Permanently + // reserving 4096 characters would break small nRF52 Companion RAM budgets. +#if MESH_ENABLE_MORSE_RX + mesh::MorseRx* _morse_rx = nullptr; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + mesh::MorseRssiTrace* _morse_trace = nullptr; +#endif + float _morse_rx_frequency = mesh::MorseTx::DefaultFrequencyMHz; + uint32_t _morse_rx_next_sample = 0, _morse_capture_generation = 0; +#endif + bool _morse_rx_owned = false; + bool _morse_rx_fsk_mode = false; + uint8_t _morse_rx_read_errors = 0; bool stopCarrierWave(); bool serviceCarrierWave(); + bool serviceMorseTx(); + bool serviceMorseRx(); mesh::RadioParamApplyResult tuneProfile(uint8_t profile); void serviceProfileScan(); @@ -213,6 +233,32 @@ protected: } virtual bool applyRxBoostedGainMode(bool) { return false; } virtual int16_t enterCarrierWave() { return _radio->transmitDirect(); } + virtual bool validateMorseFrequency(float frequency_mhz) const { + return isfinite(frequency_mhz) && frequency_mhz >= 150.0f && frequency_mhz <= 960.0f; + } + // Configure the oscillator/modem once, before the first mark. Subsequent + // marks only re-enter direct TX; in particular SX127x must not rebuild FSK + // between dots. The cached LoRa tuple is deliberately left untouched. + virtual int16_t prepareMorseCarrier(float frequency_mhz) { + if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; + int16_t result = _radio->setFrequency(frequency_mhz); + if (result == RADIOLIB_ERR_NONE && _dbm_valid) result = applyCachedTxPower(_cur_dbm); + return result; + } + virtual int16_t enterMorseCarrier() { return _radio->transmitDirect(); } + virtual int16_t leaveMorseCarrier() { return _radio->standby(); } + virtual float morseReceiverBandwidthKHz() const { return 0.0f; } + virtual int16_t prepareMorseReceiver(float frequency_mhz) { + (void)frequency_mhz; + // A native, fully restorable continuous-RSSI modem hook is required. + // LoRa's symbol detector is not a proven CW envelope receiver. + return RADIOLIB_ERR_UNKNOWN; + } + virtual int16_t startMorseReceiver() { return _radio->startReceive(); } + virtual float readMorseRssi() { return getCurrentRSSI(); } +#if MESH_ENABLE_MORSE_RX + virtual bool canAllocateMorseReceiver() const; +#endif // Native-CW radios keep their modem. Reapply cached power in case a meter // sweep failed halfway through; SX127x also rebuilds its LoRa configuration. virtual bool restoreCarrierWaveModem() { @@ -254,10 +300,17 @@ protected: _cad_scan_timeout_override_ms = 0; } - // Concrete wrappers detach their callbacks before base destruction. Keep - // this destructor trivial so compact targets do not need a base vtable; a - // protected lifetime also rejects deletion through a borrowed base pointer. - ~RadioLibWrapper() = default; + // Concrete wrappers detach their callbacks before base destruction. A + // protected nonvirtual lifetime rejects deletion through a borrowed base + // pointer while releasing any retained, explicitly allocated RX capture. + ~RadioLibWrapper() { +#if MESH_ENABLE_MORSE_RX + delete _morse_rx; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + delete _morse_trace; +#endif +#endif + } public: void begin() override; @@ -265,6 +318,44 @@ public: bool isCarrierWaveActive() const override { return _cw_active; } uint8_t carrierWaveProfile() const override { return _cw_profile; } uint32_t carrierWaveRemainingMillis() const override; + bool supportsMorseTx() const override; + mesh::MorseTxResult startMorseTx(float frequency_mhz, uint8_t wpm, const char* text) override; + mesh::MorseTxResult stopMorseTx() override; + bool isMorseTxActive() const override { return _morse_owned && _cw_active; } + float morseTxFrequency() const override { return _morse_frequency; } + uint8_t morseTxWpm() const override { return _morse_wpm; } + uint32_t morseTxRemainingMillis() const override { return _morse.remainingMillis(millis()); } +#if MESH_ENABLE_MORSE_RX + bool supportsMorseRx() const override { return false; } + mesh::MorseRxResult startMorseRx(float frequency_mhz, uint8_t wpm, uint32_t duration_ms) override; + mesh::MorseRxResult stopMorseRx() override; + bool isMorseRxActive() const override { return _morse_rx_owned && _cw_active; } + bool morseRxRecovering() const override { return isMorseRxActive() && _cw_stopping; } + bool morseRxLearning() const override { return isMorseRxActive() && _morse_rx && _morse_rx->learning(millis()); } + float morseRxFrequency() const override { return _morse_rx_frequency; } + float morseRxBandwidthKHz() const override { return morseReceiverBandwidthKHz(); } + uint32_t morseRxRemainingMillis() const override { return _morse_rx ? _morse_rx->remainingMillis(millis()) : 0; } + uint32_t morseCaptureGeneration() const override { return _morse_capture_generation; } + bool morseRxDiagnostics(mesh::MorseRxDiagnostics& out) const override { + if (!_morse_rx) { out = {}; return false; } + _morse_rx->diagnostics(out); + out.read_errors = _morse_rx_read_errors; + return true; + } + size_t morseCaptureLength() const override { return _morse_rx ? _morse_rx->length() : 0; } + bool morseCaptureTruncated() const override { return _morse_rx && _morse_rx->truncated(); } + const char* morseCaptureText() const override { return _morse_rx ? _morse_rx->text() : ""; } + size_t readMorseCapture(size_t offset, char* out, size_t capacity) const override; + void clearMorseCapture() override; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + size_t morseTraceLength() const override { return _morse_trace ? _morse_trace->length() : 0; } + uint32_t morseTraceTotal() const override { return _morse_trace ? _morse_trace->total() : 0; } + bool morseTraceTruncated() const override { return _morse_trace && _morse_trace->truncated(); } + bool readMorseTrace(size_t offset, mesh::MorseRssiTraceEntry& out) const override { + return _morse_trace && _morse_trace->read(offset, out); + } +#endif +#endif mesh::RadioProfiles* profiles() override { return &_profiles; } const mesh::RadioProfiles* profiles() const override { return &_profiles; } bool validateProfile(const mesh::RadioProfileParams& params) const override; diff --git a/src/helpers/radiolib/SX126xMorseRamp.h b/src/helpers/radiolib/SX126xMorseRamp.h new file mode 100644 index 000000000..6a7803928 --- /dev/null +++ b/src/helpers/radiolib/SX126xMorseRamp.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +inline uint8_t sx126xMorsePaRamp() { +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_PA_RAMP_US) + constexpr unsigned requested_us = MESH_MORSE_HIL_PA_RAMP_US; + static_assert(requested_us == 10 || requested_us == 20 || requested_us == 40 + || requested_us == 80 || requested_us == 200 || requested_us == 800 + || requested_us == 1700 || requested_us == 3400, + "HIL SX126x PA ramp must be 10/20/40/80/200/800/1700/3400 microseconds"); + constexpr uint8_t requested = requested_us == 10 ? RADIOLIB_SX126X_PA_RAMP_10U + : requested_us == 20 ? RADIOLIB_SX126X_PA_RAMP_20U + : requested_us == 40 ? RADIOLIB_SX126X_PA_RAMP_40U + : requested_us == 80 ? RADIOLIB_SX126X_PA_RAMP_80U + : requested_us == 200 ? RADIOLIB_SX126X_PA_RAMP_200U + : requested_us == 800 ? RADIOLIB_SX126X_PA_RAMP_800U + : requested_us == 1700 ? RADIOLIB_SX126X_PA_RAMP_1700U + : RADIOLIB_SX126X_PA_RAMP_3400U; +#else + constexpr uint8_t requested = RADIOLIB_SX126X_PA_RAMP_3400U; +#endif +#ifdef SX126X_PA_RAMP_TIME + // The hardware ramp encodings increase with duration. A lab override must + // never defeat a board's slower external-PA requirement. + constexpr uint8_t board_minimum = static_cast(SX126X_PA_RAMP_TIME); + return requested < board_minimum ? board_minimum : requested; +#else + return requested; +#endif +} + +// Set only the envelope ramp, using RadioLib's cached chip drive power. This +// preserves external-PA board calibration instead of treating user dBm as a +// raw PA drive level. A CW transmitter has no receive bandwidth to select. +inline int16_t setSX126xMorseRamp(SX126x* radio) { + const int16_t status = radio->setPaRampTime(sx126xMorsePaRamp()); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_WARM_TX) && MESH_MORSE_HIL_WARM_TX + // Establish XOSC before the first mark too. Do not borrow or overwrite the + // scanner's standbyXOSC policy: normal final cleanup restores it unchanged. + return status == RADIOLIB_ERR_NONE ? radio->standby(RADIOLIB_SX126X_STANDBY_XOSC) : status; +#else + return status; +#endif +} + +inline int16_t leaveSX126xMorseCarrier(SX126x* radio) { +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_WARM_TX) && MESH_MORSE_HIL_WARM_TX + return radio->standby(RADIOLIB_SX126X_STANDBY_XOSC); +#else + return radio->standby(); +#endif +} + +inline int16_t restoreSX126xPacketRamp(SX126x* radio) { +#ifdef SX126X_PA_RAMP_TIME + return radio->setPaRampTime(static_cast(SX126X_PA_RAMP_TIME)); +#else + return radio->setPaRampTime(RADIOLIB_SX126X_PA_RAMP_200U); +#endif +} diff --git a/src/helpers/radiolib/SX126xMorseReceiver.h b/src/helpers/radiolib/SX126xMorseReceiver.h new file mode 100644 index 000000000..b8c7b765b --- /dev/null +++ b/src/helpers/radiolib/SX126xMorseReceiver.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include "../MorseRx.h" + +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_PACKET_GATING) +static_assert(MESH_MORSE_HIL_PACKET_GATING == 0 || MESH_MORSE_HIL_PACKET_GATING == 1, + "HIL GFSK packet gating must be 0 or 1"); +#endif + +// Real GFSK 4.8 kHz receive filter, not a LoRa symbol/preamble detector. +// Keep the board's actual reference supply and regulator policy across the +// native one-time modem reset. No RF is transmitted by this preparation. +template +int16_t prepareSX126xMorseReceiver(RadioT* radio, float frequency_mhz) { + const float tcxo_voltage = radio->tcxoVoltage; + const bool use_ldo = radio->useRegulatorLDO; + const bool standby_xosc = radio->standbyXOSC; + const bool boosted_gain = radio->rxBoostedGainMode; + radio->standbyXOSC = false; // TCXO/DIO3 is not restored until beginFSK + const int16_t begin_status = radio->beginFSK( + frequency_mhz, mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 4.8f, 2, 16, tcxo_voltage, use_ldo); + radio->standbyXOSC = standby_xosc; + if (begin_status != RADIOLIB_ERR_NONE) return begin_status; + int16_t status; +#ifdef SX126X_CURRENT_LIMIT + status = radio->setCurrentLimit(SX126X_CURRENT_LIMIT); + if (status != RADIOLIB_ERR_NONE) return status; +#endif +#ifdef SX126X_DIO2_AS_RF_SWITCH + status = radio->setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); + if (status != RADIOLIB_ERR_NONE) return status; +#endif +#if defined(SX126X_RXEN) || defined(SX126X_TXEN) + radio->setRfSwitchPins( +#ifdef SX126X_RXEN + SX126X_RXEN, +#else + RADIOLIB_NC, +#endif +#ifdef SX126X_TXEN + SX126X_TXEN +#else + RADIOLIB_NC +#endif + ); +#endif +#if !(defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_PACKET_GATING) && MESH_MORSE_HIL_PACKET_GATING) + // The default no-lock packet tuple is retained unchanged. The isolated HIL + // comparison instead keeps beginFSK's ordinary preamble/sync/CRC settings; + // Semtech's spectral-scan example reads instant RSSI before packet lock. + uint8_t unused_sync = 0; + status = radio->setSyncWord(&unused_sync, 0); + if (status != RADIOLIB_ERR_NONE) return status; + status = radio->setCRC(0); + if (status != RADIOLIB_ERR_NONE) return status; +#endif + // No Morse data uses the FSK packet parser. Avoid random noise bytes making + // arbitrarily short variable-length frames while sampling continuous power. + status = radio->fixedPacketLengthMode(255); + if (status != RADIOLIB_ERR_NONE) return status; + status = radio->setMorseFastRssi(); + if (status != RADIOLIB_ERR_NONE) return status; + // Boards can set the native gain directly at initialization, without an + // exposed CLI/cache setting (notably integrated STM32WL targets). Preserve + // that actual policy too; a valid wrapper cache is applied afterwards. + status = radio->setRxBoostedGainMode(boosted_gain); + if (status != RADIOLIB_ERR_NONE) return status; + // The wrapper restores the cached gain while still in standby, then enters + // RX through its dedicated start hook. No mid-RX gain/AGC mutation. + return RADIOLIB_ERR_NONE; +} diff --git a/src/helpers/radiolib/SX126xMorseRssi.h b/src/helpers/radiolib/SX126xMorseRssi.h new file mode 100644 index 000000000..637eafeae --- /dev/null +++ b/src/helpers/radiolib/SX126xMorseRssi.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +inline int16_t setSX126xMorseRssiWindow(SX126x* radio) { +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_RSSI_WINDOW) + static_assert(MESH_MORSE_HIL_RSSI_WINDOW >= 0 && MESH_MORSE_HIL_RSSI_WINDOW <= 7, + "SX126x RSSI averaging exponent must fit bits4:2"); + constexpr uint8_t window = MESH_MORSE_HIL_RSSI_WINDOW; +#else + constexpr uint8_t window = 0; +#endif + // SPIsetRegValue takes an already positioned register value, not a logical + // field value. Window exponent2 is0x08 in RSSI_AVG_WINDOW[4:2], not0x02. + return radio->getMod()->SPIsetRegValue(RADIOLIB_SX126X_REG_RSSI_AVG_WINDOW, + static_cast(window << 2), 4, 2); +} diff --git a/test/fixtures/advert_cache/test.cpp b/test/fixtures/advert_cache/test.cpp index b04cc3915..301ffc970 100644 --- a/test/fixtures/advert_cache/test.cpp +++ b/test/fixtures/advert_cache/test.cpp @@ -8,6 +8,9 @@ namespace mesh { struct Utils { dest[2*length]=0; } }; } +// Advert jobs are tested with Morse idle; actual Morse deferral is covered by +// test_morse_loop_policy.py and the production role-loop extraction fixtures. +namespace mesh { template bool morseTimingActive(const Radio*) { return false; } } struct ContactInfo {}; class MyMesh; using DataStoreHost = MyMesh; @@ -62,6 +65,7 @@ class MyMesh { public: DataStore* _store; Clock* _ms; + RadioDriver* _radio=&radio_driver; bool _advert_write_next=true; unsigned long dirty_contacts_expiry=0; uint8_t dirty_contacts_failures=0; diff --git a/test/fixtures/companion_lost_reply.cpp b/test/fixtures/companion_lost_reply.cpp index 87ccf96e5..5487a9c29 100644 --- a/test/fixtures/companion_lost_reply.cpp +++ b/test/fixtures/companion_lost_reply.cpp @@ -103,6 +103,12 @@ struct WebConfigServer { static constexpr uint32_t CAP_LOST_REPLY = 1UL << 18; } class MyMesh { public: + struct NoMorseReply { + uint32_t generation() const { return 0; } + void beginCommand(const uint8_t*) {} + void endCommand() {} + } _morse_reply; + bool _morse_reply_admission = false; struct Prefs { uint8_t lost_reply = 0; } _prefs; Clock clock, *_ms = &clock; Manager manager, *_mgr = &manager; diff --git a/test/fixtures/morse_chip_hooks.cpp b/test/fixtures/morse_chip_hooks.cpp new file mode 100644 index 000000000..c793c0a74 --- /dev/null +++ b/test/fixtures/morse_chip_hooks.cpp @@ -0,0 +1,170 @@ +using CustomSX1262 = Chip; +using CustomSX1268 = Chip; +using CustomLLCC68 = Chip; +using CustomSTM32WLx = Chip; +using CustomLR1110 = Chip; +using CustomLR1121 = Chip; +using CustomLR2021 = Chip; +using SX126x = Chip; +#include +#include + +struct SX1262Driver : RadioLibWrapper { @SX1262@ }; +struct SX1268Driver : RadioLibWrapper { @SX1268@ }; +struct LLCC68Driver : RadioLibWrapper { @LLCC68@ }; +struct STM32WLxDriver : RadioLibWrapper { @STM32WLx@ }; +struct LR1110Driver : RadioLibWrapper { @LR1110@ }; +struct LR1121Driver : RadioLibWrapper { @LR1121@ }; +struct LR2021Driver : RadioLibWrapper { @LR2021@ }; + +struct SX1276LowLevel { + RoutedModule module; + RoutedModule* mod = &module; + @SX1276_RAMP@ +}; + +struct MappedSX1262Driver : SX1262Driver { + int16_t applyCachedTxPower(int8_t dbm) override { +#ifdef SX126X_PA_RAMP_TIME + return chip.setTxParams(dbm - 28, SX126X_PA_RAMP_TIME); +#else + return chip.setOutputPower(dbm - 28); +#endif + } +}; + +template static void sx_hooks(float carrier, float packet) { + assert(sx126xMorsePaRamp()==EXPECTED_MORSE_RAMP); + Driver w; w.supported = false; + w._profiles.primary.freq = w.frequency = w._cur_freq = packet; + w._profiles.primary.bw = 125; + w._profiles.secondary.params.freq = packet + 10; + const auto primary = w._profiles.primary; + assert(w.startMorseTx(carrier, 25, "SOS") == mesh::MorseTxResult::Started); + assert(w.chip.frequency == carrier && w.chip.ramp_time == EXPECTED_MORSE_RAMP && w.chip.power == 17); + assert(w.chip.ramp_calls == 1 && w.chip.starts == 1 && w.board.awake); + while (w.isMorseTxActive() && !w._cw_stopping) { + g_mock_millis = w._morse.nextDeadline(); w.serviceCarrierWave(); + } + assert(!w._cw_active && !w.chip.carrier && !w.board.awake && w.isInRecvMode()); + assert(w.chip.starts == 9 && w.chip.ramp_calls == 2); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_WARM_TX) && MESH_MORSE_HIL_WARM_TX + assert(w.chip.warm_standby_calls==9 && w.chip.last_standby_mode==RADIOLIB_SX126X_STANDBY_XOSC); +#else + assert(!w.chip.warm_standby_calls); +#endif + assert(!w.chip.standbyXOSC); // never overwrite scanner policy for a temporary CW experiment + assert(w.chip.frequency == packet && w._profiles.primary == primary); + assert(w.board.before == 9 && w.board.after == 9); + for (int fault : {0, 1, 2}) { + Driver bad; + bad._profiles.primary.freq = bad.frequency = bad._cur_freq = packet; + bad._profiles.secondary.params.freq = packet + 10; + if (fault == 0) bad.chip.ramp_result = -705; + if (fault == 1) bad.chip.frequency_result = -705; + if (fault == 2) bad.chip.power_result = -705; + assert(bad.startMorseTx(carrier, 25, "E") == mesh::MorseTxResult::Failed); + assert(!bad.chip.starts && !bad.board.before); + assert(bad._cw_active && bad._cw_stopping && bad.board.awake); + bad.chip.ramp_result = bad.chip.frequency_result = bad.chip.power_result = 0; + advance(100); bad.serviceCarrierWave(); + assert(!bad._cw_active && !bad.chip.carrier && !bad.board.awake && bad.isInRecvMode()); + } +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_WARM_TX) && MESH_MORSE_HIL_WARM_TX + { + Driver bad; bad._profiles.primary.freq=packet; bad._cur_freq=packet; + bad._profiles.secondary.params.freq=packet+10; bad.chip.warm_standby_result=-705; + assert(bad.startMorseTx(carrier,25,"EE")==mesh::MorseTxResult::Failed); + assert(!bad.chip.starts && !bad.chip.carrier && !bad._cw_active && !bad.board.awake); + } + { + Driver bad; bad._profiles.primary.freq=packet; bad._cur_freq=packet; + bad._profiles.secondary.params.freq=packet+10; + assert(bad.startMorseTx(carrier,25,"EE")==mesh::MorseTxResult::Started); + bad.chip.warm_standby_result=-705; advance(48); bad.serviceCarrierWave(); + assert(!bad.chip.carrier && !bad._cw_active && !bad.board.awake && bad.isInRecvMode()); + assert(bad.chip.starts==1 && bad.board.before==1 && bad.board.after==1); + } +#endif +} + +template static void lr11_hooks() { + Driver w; w.supported = false; w._cur_dbm = 21; + assert(w.startMorseTx(902.1f, 25, "SOS") == mesh::MorseTxResult::Started); + assert(w.chip.ramp_time == 304 && w.chip.ramp_power_calls == 1 && w.chip.power == 21); + assert(!w.chip.rfo && w.chip.frequency == 902.1f); + while (w.isMorseTxActive()) { g_mock_millis = w._morse.nextDeadline(); w.serviceCarrierWave(); } + assert(w.chip.ramp_time != 304 && w.chip.ramp_power_calls == 1); + assert(w.chip.frequency == 909.5f && w.chip.power == 21 && !w.chip.carrier && w.isInRecvMode()); + // The public three-argument API must receive 304, not an already-encoded + // uint8_t value or truncated 48. RadioLib owns its LR11xx ramp encoding. + for (int fault : {0, 1, 2}) { + Driver bad; + if (fault == 0) bad.board.frequency_ok = false; + if (fault == 1) bad.chip.frequency_result = -705; + if (fault == 2) bad.chip.power_result = -705; + assert(bad.startMorseTx(902.1f, 25, "E") == mesh::MorseTxResult::Failed); + assert(!bad.chip.starts && !bad.board.before); + bad.board.frequency_ok = true; bad.chip.frequency_result = bad.chip.power_result = 0; + if (bad._cw_active) { advance(100); bad.serviceCarrierWave(); } + assert(!bad._cw_active && !bad.board.awake && !bad.chip.carrier && bad.isInRecvMode()); + } +} + +int main() { + sx_hooks(902.1f, 909.5f); + sx_hooks(433.5f, 433.0f); + sx_hooks(902.1f, 909.5f); + sx_hooks(902.1f, 909.5f); + { + SX1268Driver w; + assert(w.startMorseTx(902.1f, 25, "E") == mesh::MorseTxResult::InvalidFrequency); + assert(!w.chip.starts && !w.chip.standby_calls); + } + { + MappedSX1262Driver w; w._cur_dbm = 30; + assert(w.startMorseTx(902.1f, 25, "E") == mesh::MorseTxResult::Started); + assert(w.chip.power == 2 && w.chip.ramp_time == EXPECTED_MORSE_RAMP); // ramp retains mapped drive power + assert(w.stopMorseTx() == mesh::MorseTxResult::Stopped && w.chip.power == 2); +#ifdef SX126X_PA_RAMP_TIME + assert(w.chip.ramp_time == SX126X_PA_RAMP_TIME); +#else + assert(w.chip.ramp_time == 4); +#endif + } + lr11_hooks(); lr11_hooks(); + for (float frequency : {902.1f, 2400.0f}) { + LR2021Driver w; w._cur_dbm = 22; + assert(w.startMorseTx(frequency, 25, "SOS") == mesh::MorseTxResult::Started); + const bool high = mesh::lr2021::isHighBand(frequency); + assert(w.chip.ramp_time == 304 && w.chip.frequency == frequency); + assert(w.chip.power == (high ? 12 : 22)); + assert(w.chip.routed_module.switch_mode == (high ? LR2021::MODE_TX_HF : LR2021::MODE_TX)); + assert(w.chip.cw_test_mode == RADIOLIB_LR2021_TX_TEST_MODE_CW); + while (w.isMorseTxActive()) { g_mock_millis = w._morse.nextDeadline(); w.serviceCarrierWave(); } + assert(w.chip.frequency == 909.5f && w.chip.power == 22 && w.chip.ramp_time != 304); + assert(w.chip.routed_module.switch_calls == 9 && !w.chip.carrier && w.isInRecvMode()); + } + for (float frequency : {149.0f, 1100.0f, 1899.0f, 2501.0f, NAN, INFINITY}) { + LR2021Driver w; + assert(w.startMorseTx(frequency, 25, "E") == mesh::MorseTxResult::InvalidFrequency); + assert(!w.chip.starts); + } + { + LR2021Driver w; + assert(w.startMorseTx(2400.0f, 25, "E") == mesh::MorseTxResult::Started); + w.board.frequency_ok = false; + assert(w.stopMorseTx() == mesh::MorseTxResult::Failed); + assert(w._cw_active && w._cw_stopping && w.board.awake && !w.chip.carrier); + w.board.frequency_ok = true; advance(100); w.serviceCarrierWave(); + assert(!w._cw_active && !w.board.awake && w.chip.frequency == 909.5f && w.isInRecvMode()); + } + { + SX1276LowLevel low; + assert(low.setMorsePaRamp() == 0); + assert(low.module.register_address == RADIOLIB_SX127X_REG_PA_RAMP); + assert(low.module.register_value == 0 && low.module.register_msb == 3 && low.module.register_lsb == 0); + low.module.register_result = -705; + assert(low.setMorsePaRamp() == -705); + } +} diff --git a/test/fixtures/morse_cli.cpp b/test/fixtures/morse_cli.cpp new file mode 100644 index 000000000..6c5b48945 --- /dev/null +++ b/test/fixtures/morse_cli.cpp @@ -0,0 +1,223 @@ +#include +#include +#include +#include +#include + +namespace mesh { +static MorseSettings current; +static bool save_ok = true; +static bool usable = true; +static unsigned saves = 0; +const MorseSettings& morseSettings() { return current; } +bool morseSettingsUsable() { return usable; } +bool saveMorseSettings(const MorseSettings& candidate) { + ++saves; + if (!save_ok || !validMorseSettings(candidate)) { usable = false; return false; } + current = candidate; + usable = true; + return true; +} +} + +struct TestRadio : mesh::Radio { + bool supported = true, active = false, cw = false, receiving = true; + bool rx_supported = false, rx_active = false, rx_recovering = false, capture_truncated = false; + bool force_rx_result = false; + mesh::MorseRxResult forced_rx = mesh::MorseRxResult::Failed; + unsigned rx_starts = 0, rx_stops = 0, clears = 0; + uint32_t rx_duration = 0; + std::string capture; + bool have_rx_diagnostics = false; + mesh::MorseRxDiagnostics rx_diagnostics; + bool force_result = false; + mesh::MorseTxResult forced = mesh::MorseTxResult::Failed; + unsigned starts = 0, stops = 0; + float frequency = 0; + uint8_t speed = 0; + std::string sent; + uint32_t remaining = 1234; + bool supportsMorseTx() const override { return supported; } + bool isMorseTxActive() const override { return active; } + bool isCarrierWaveActive() const override { return cw || active || rx_active; } + float morseTxFrequency() const override { return frequency; } + uint8_t morseTxWpm() const override { return speed; } + uint32_t morseTxRemainingMillis() const override { return remaining; } + mesh::MorseTxResult startMorseTx(float freq, uint8_t wpm, const char* text) override { + ++starts; frequency = freq; speed = wpm; sent = text; + if (force_result) return forced; + if (active || cw || rx_active) return mesh::MorseTxResult::Busy; + const auto result = mesh::MorseTx::validate(text, wpm); + if (result == mesh::MorseTxResult::Started) active = true; + return result; + } + mesh::MorseTxResult stopMorseTx() override { + ++stops; + if (force_result) return forced; + active = false; + return mesh::MorseTxResult::Stopped; + } + bool supportsMorseRx() const override { return rx_supported; } + bool isMorseRxActive() const override { return rx_active; } + bool morseRxRecovering() const override { return rx_recovering; } + float morseRxFrequency() const override { return frequency; } + float morseRxBandwidthKHz() const override { return 7.81f; } + uint32_t morseRxRemainingMillis() const override { return rx_recovering ? 0 : rx_duration; } + size_t morseCaptureLength() const override { return capture.size(); } + bool morseCaptureTruncated() const override { return capture_truncated; } + bool morseRxDiagnostics(mesh::MorseRxDiagnostics& out) const override { + out = rx_diagnostics; return have_rx_diagnostics; + } + const char* morseCaptureText() const override { return capture.c_str(); } + size_t readMorseCapture(size_t offset, char* out, size_t capacity) const override { + if (!out || !capacity) return 0; + if (offset >= capture.size()) { out[0] = 0; return 0; } + const size_t count = std::min(capture.size() - offset, capacity - 1); + memcpy(out, capture.data() + offset, count); out[count] = 0; return count; + } + void clearMorseCapture() override { ++clears; capture.clear(); capture_truncated = false; } + mesh::MorseRxResult startMorseRx(float freq, uint8_t wpm, uint32_t duration) override { + ++rx_starts; + if (force_rx_result) return forced_rx; + if (active || cw || rx_active) return mesh::MorseRxResult::Busy; + const auto valid = mesh::MorseRx::validate(wpm, duration); + if (valid != mesh::MorseRxResult::Started) return valid; + frequency = freq; speed = wpm; rx_duration = duration; + rx_active = true; rx_recovering = false; capture.clear(); capture_truncated = false; + return mesh::MorseRxResult::Started; + } + mesh::MorseRxResult stopMorseRx() override { + ++rx_stops; + if (force_rx_result) { rx_recovering = true; return forced_rx; } + rx_active = rx_recovering = false; + return mesh::MorseRxResult::Stopped; + } + bool isInRecvMode() const override { return receiving && !active && !rx_active; } + int recvRaw(uint8_t*, int) override { return 0; } + uint32_t getEstAirtimeFor(int) override { return 1; } + float packetScore(float, int) override { return 0; } + bool startSendRaw(const uint8_t*, int) override { return false; } + bool isSendComplete() override { return false; } + void onSendFinished() override {} +}; + +int main() { + TestRadio radio; + char reply[180]; + const auto run = [&](const char* command) { + strcpy(reply, "unchanged"); + assert(mesh::handleMorseCommand(&radio, command, reply, sizeof(reply))); + return std::string(reply); + }; + assert(run("get morse") == "> off; 902.100 MHz; 25 WPM; TX only"); + assert(run("get morse.freq") == "> 902.100"); + assert(run("get morse.wpm") == "> 25"); + assert(!radio.starts && !radio.stops && !mesh::saves); + assert(run("morse send SOS").find("902.100 MHz at 25 WPM") != std::string::npos); + assert(radio.starts == 1 && radio.active && radio.sent == "SOS"); + assert(radio.frequency == 902.1f && radio.speed == 25); + assert(run("get morse").find("1.234 seconds left") != std::string::npos); + radio.remaining = 0; + assert(run("get morse").find("recovery pending") != std::string::npos); + assert(run("set morse.wpm 30").find("busy") != std::string::npos); + assert(run("set morse.freq 903.3").find("busy") != std::string::npos); + assert(!mesh::saves && mesh::current.wpm == 25); + assert(run("morse stop").find("receive resumed") != std::string::npos); + assert(radio.stops == 1 && !radio.active); + radio.cw = true; + assert(run("morse stop") == "OK - Morse not running"); + assert(radio.stops == 1 && radio.cw); // never kill an unrelated CW meter test + assert(run("set morse.wpm 30").find("busy") != std::string::npos); + radio.cw = false; + const auto maximum_speed=std::string("set morse.wpm ")+std::to_string(mesh::MorseMaxWpm); + assert(run(maximum_speed.c_str()).find("OK - morse.wpm")==0); + for (const char* speed : {"1", "60", "25", "+25", "\t25\t"}) { + const auto command = std::string("set morse.wpm ") + speed; + assert(run(command.c_str()).find("OK - morse.wpm") == 0); + } + assert(mesh::current.wpm == 25 && radio.starts == 1); + const auto above_maximum=std::string("set morse.wpm ")+std::to_string(unsigned(mesh::MorseMaxWpm)+1); + assert(run(above_maximum.c_str()).find("Error:")==0 && mesh::current.wpm==25); + for (const char* speed : {"", "0", "256", "-1", "25x", "25.0", "2e1", + "4294967296", "18446744073709551615", "1 2", "nan", "inf", "\n25"}) { + const auto saves = mesh::saves; + const auto command = std::string("set morse.wpm ") + speed; + assert(run(command.c_str()).find("Error:") == 0); + assert(mesh::saves == saves && mesh::current.wpm == 25 && radio.starts == 1); + } + for (const char* frequency : {"100", "1000", "902.100", "902.1004", "+902.1", "\t902.1\t"}) { + const auto command = std::string("set morse.freq ") + frequency; + assert(run(command.c_str()).find("OK - morse.freq") == 0); + } + assert(mesh::current.frequency_khz == 902100 && radio.starts == 1); + for (const char* frequency : {"", "99.999", "1000.001", "-902.1", "nan", "inf", + "902.1MHz", "9.021e2", "4294967296", "18446744073709551615", "902.1 2", "\n902.1"}) { + const auto saves = mesh::saves; + const auto command = std::string("set morse.freq ") + frequency; + assert(run(command.c_str()).find("Error:") == 0); + assert(mesh::saves == saves && mesh::current.frequency_khz == 902100); + } + mesh::save_ok = false; + assert(run("set morse.wpm 30").find("could not be saved") != std::string::npos); + assert(run("set morse.freq 903.1").find("could not be saved") != std::string::npos); + assert(mesh::current.wpm == 25 && mesh::current.frequency_khz == 902100); + mesh::save_ok = true; + assert(run("set morse.freq 902.1").find("OK - morse.freq") == 0 && mesh::usable); + for (const char* invalid : {"get morse x", "get morse send E", "set morse", "set morse send E", + "morse send", "morse stop E", "morse yes E", "get morse.wpm 25", "get morse.freq 902.1", + "morse.wpm 25", "morse.freq 902.1"}) { + const auto starts = radio.starts, stops = radio.stops, saves = mesh::saves; + assert(run(invalid).find("Error:") == 0); + assert(radio.starts == starts && radio.stops == stops && mesh::saves == saves); + } + for (const char* other : {"hello", "chat SOS", "cw on", "morseX send E", "morse.freqx 902.1", "mt.channel 1"}) { + strcpy(reply, "unchanged"); + assert(!mesh::handleMorseCommand(&radio, other, reply, sizeof(reply))); + assert(!strcmp(reply, "unchanged")); + } + radio.force_result = true; + for (auto result : {mesh::MorseTxResult::Busy, mesh::MorseTxResult::Unsupported, + mesh::MorseTxResult::InvalidFrequency, mesh::MorseTxResult::Overlap, + mesh::MorseTxResult::InvalidText, mesh::MorseTxResult::InvalidSpeed, + mesh::MorseTxResult::TooLong, mesh::MorseTxResult::Failed}) { + radio.forced = result; + assert(run("morse send E").find("Error:") == 0); + } + radio.force_result = false; + assert(run("morse send #").find("supported Morse ASCII") != std::string::npos); + assert(run(("morse send " + std::string(81, 'E')).c_str()).find("80 characters") != std::string::npos); + assert(run("morse send\tsos").find("OK - Morse TX") == 0 && radio.sent == "sos"); + radio.receiving = false; + assert(run("morse stop").find("receive resumed") == std::string::npos); + radio.receiving = true; + { + mesh::usable = false; + const auto starts = radio.starts, stops = radio.stops, saves = mesh::saves; + assert(run("morse send E").find("saved Morse settings unavailable") != std::string::npos); + assert(run("get morse").find("Error:") == 0); + assert(run("get morse.freq").find("Error:") == 0); + assert(run("get morse.wpm").find("Error:") == 0); + assert(run("set morse.wpm 25").find("verify and save morse.freq") != std::string::npos); + assert(radio.starts == starts && mesh::saves == saves); + assert(run("morse stop") == "OK - Morse not running" && radio.stops == stops); + assert(run("set morse.freq 902.100").find("OK - morse.freq") == 0 && mesh::usable); + assert(run("morse send E").find("OK - Morse TX") == 0); + run("morse stop"); + } + radio.supported = false; + const auto saves = mesh::saves, starts = radio.starts; + assert(run("set morse.wpm 25").find("unsupported") != std::string::npos); + assert(run("morse send E").find("unsupported") != std::string::npos); + assert(mesh::saves == saves && radio.starts == starts); + assert(mesh::handleMorseCommand(nullptr, "get morse", reply, sizeof(reply))); + assert(!strncmp(reply, "Error:", 6)); + assert(!mesh::handleMorseCommand(&radio, nullptr, reply, sizeof(reply))); + assert(!mesh::handleMorseCommand(&radio, "get morse", nullptr, sizeof(reply))); + for (size_t capacity = 0; capacity < 30; ++capacity) { + char small[32]; memset(small, '~', sizeof(small)); + const bool handled = mesh::handleMorseCommand(&radio, "get morse", small, capacity); + assert(handled == (capacity != 0)); + if (capacity) assert(small[capacity - 1] == 0 || strlen(small) < capacity); + assert(small[capacity] == '~'); + } +} diff --git a/test/fixtures/morse_driver.cpp b/test/fixtures/morse_driver.cpp new file mode 100644 index 000000000..eb2001894 --- /dev/null +++ b/test/fixtures/morse_driver.cpp @@ -0,0 +1,269 @@ +using MResult = mesh::MorseTxResult; +using Edge = mesh::MorseTx::Edge; + +template static void complete_message(Driver& w) { + unsigned edges = 0; + while (w.isMorseTxActive() && !w._cw_stopping) { + g_mock_millis = w._morse.nextDeadline(); + assert(w.serviceCarrierWave()); + assert(++edges < 1000); + } +} + +static void fresh_receive(const RadioLibWrapper& w) { + assert(!w._cw_active && !w._morse_owned && !w._cw_stopping); + assert(!w.chip.carrier && !w.board.awake && w.chip.callback); + assert(w.isInRecvMode() && w._rx_ps_armed); + assert(w._profile_refresh_required && w.calibrations); +} + +int main() { + { + RadioLibWrapper w; + const auto primary = w._profiles.primary; + const auto secondary = w._profiles.secondary.params; + assert(w.startMorseTx(902.100f, 25, "SOS") == MResult::Started); + assert(w.isMorseTxActive() && w.isCarrierWaveActive()); + assert(w.chip.carrier && !w.chip.callback && w.board.awake); + assert(w.morseTxWpm() == 25 && w.morseTxFrequency() == 902.100f); + assert(w.chip.frequency == 902.100f && w._cur_freq == 909.5f); + assert(w.morseTxRemainingMillis() == 1296 && w.carrierWaveRemainingMillis() == 1296); + char reply[160]; + assert(mesh::handleCarrierWaveCommand(&w, "get cw2", reply, sizeof(reply))); + assert(!strcmp(reply, "> Morse TX owns radio; use get morse")); + assert(!w.isInRecvMode() && !w._rx_ps_armed && !w._nf_calib_active); + assert(w._profiles.primary == primary && w._profiles.secondary.params == secondary); + assert(!w.setTxPower(10)); // power sweeps must not key through Morse gaps + assert(w.setCarrierWave(0, 100) == mesh::RadioParamApplyResult::BUSY); + assert(w.startMorseTx(903, 25, "E") == MResult::Busy); + advance(47); assert(w.serviceCarrierWave() && w.chip.carrier); + advance(1); assert(w.serviceCarrierWave() && !w.chip.carrier); + assert(w.isMorseTxActive() && w.board.awake && !w.isInRecvMode()); + assert(w.board.before == 1 && w.board.after == 1 && !w.setTxPower(10)); + complete_message(w); + fresh_receive(w); + assert(w.chip.starts == 9 && w.board.before == 9 && w.board.after == 9); + assert(w._cur_dbm == 17 && w.frequency == 909.5f); + assert(w._profiles.primary == primary && w._profiles.secondary.params == secondary); + assert(!w.serviceCarrierWave()); + } + // Slow/fast bounds, explicit cancellation while keyed and during a gap, + // rollover and arbitrarily delayed service never synthesize a chirp burst. + for (uint8_t speed : {1, 25, 60}) { + RadioLibWrapper w; + g_mock_millis = 0xfffffff0; + assert(w.startMorseTx(902.1f, speed, "EE") == MResult::Started); + const auto first_deadline = w._morse.nextDeadline(); + g_mock_millis = first_deadline; + w.serviceCarrierWave(); + assert(!w.chip.carrier && w.board.after == 1); + const auto starts = w.chip.starts; + for (int i = 0; i < 20; ++i) w.serviceCarrierWave(); + assert(w.chip.starts == starts); + assert(w.stopMorseTx() == MResult::Stopped); + fresh_receive(w); + assert(w.board.before == w.board.after); + assert(w.startMorseTx(902.1f, speed, "T") == MResult::Started); + assert(w.stopMorseTx() == MResult::Stopped); + fresh_receive(w); + } + { + RadioLibWrapper w; + w.startMorseTx(902.1f, 25, "SOS"); + advance(10000); w.serviceCarrierWave(); + assert(!w.chip.carrier && w.chip.starts == 1 && w.board.after == 1); + assert(w._morse.nextDeadline() == g_mock_millis + 48); + w.serviceCarrierWave(); assert(w.chip.starts == 1); + g_mock_millis = w._morse.hardDeadline(); w.serviceCarrierWave(); + fresh_receive(w); + assert(w.chip.starts == 1 && w.board.before == w.board.after); + } + // Every rejection is before oscillator, receive, external-PA or modem changes. + for (int reason = 0; reason < 15; ++reason) { + RadioLibWrapper w; + float frequency = 902.1f; + uint8_t speed = 25; + const char* text = "E"; + MResult wanted = MResult::Busy; + if (reason == 0) w.state = STATE_TX_WAIT; + if (reason == 1) w.events.record(); + if (reason == 2) w.busy = true; + if (reason == 3) w.packet = true; + if (reason == 4) { w.supported = false; wanted = MResult::Unsupported; } + if (reason == 5) { frequency = NAN; wanted = MResult::InvalidFrequency; } + if (reason == 6) { frequency = INFINITY; wanted = MResult::InvalidFrequency; } + if (reason == 7) { frequency = 99; wanted = MResult::InvalidFrequency; } + if (reason == 8) { frequency = 1001; wanted = MResult::InvalidFrequency; } + if (reason == 9) { w._params_valid = false; wanted = MResult::InvalidFrequency; } + if (reason == 10) { speed = 0; wanted = MResult::InvalidSpeed; } + if (reason == 11) { speed = uint8_t(mesh::MorseTx::MaxWpm + 1); wanted = MResult::InvalidSpeed; } + if (reason == 12) { text = "#"; wanted = MResult::InvalidText; } + if (reason == 13) { w._profiles.primary.freq = 902.1f; wanted = MResult::Overlap; } + if (reason == 14) { w._profiles.secondary.params.freq = 902.35f; wanted = MResult::Overlap; } + assert(w.startMorseTx(frequency, speed, text) == wanted); + assert(!w._cw_active && !w.chip.starts && !w.chip.standby_calls && !w.board.before); + assert(!w.tunes && !w.board.frequency_prepares); + } + { + RadioLibWrapper w; + w._profiles.secondary.params.freq = 902.1f; + w._profiles.secondary.mode = mesh::RadioProfileMode::Off; + assert(w.startMorseTx(902.1f, 25, "E") == MResult::Overlap); + assert(!w.chip.starts); // saved radio2 is reserved even while inactive + } + for (float frequency : {150.0f, 960.0f}) { + RadioLibWrapper w; + w._profiles.primary.bw = 0; w._profiles.primary.sf = 0; + w._profiles.primary.protocol = mesh::RadioProfileProtocol::Meshtext; + assert(w.startMorseTx(frequency, 25, "E") == MResult::Started); + assert(w.stopMorseTx() == MResult::Stopped); + fresh_receive(w); // unmodulated TX validates band, not cached LoRa SF/BW/protocol + } + for (float frequency : {137.0f, 1020.0f}) { + CustomSX1276Wrapper w; + assert(w.startMorseTx(frequency, 25, "E") == MResult::Started); + assert(w.stopMorseTx() == MResult::Stopped); fresh_receive(w); + } + for (uint8_t changed_profile : {0, 1}) { + RadioLibWrapper w; w.startMorseTx(902.1f, 25, "SOS"); + ++w._profiles.generation[changed_profile]; + w.serviceCarrierWave(); fresh_receive(w); + assert(w.chip.starts == 1); + } + { + RadioLibWrapper w; w.startMorseTx(902.1f, 25, "SOS"); + w._profiles.primary.freq = 902.1f; // board integrations may change tuple directly + w.serviceCarrierWave(); fresh_receive(w); + assert(w.chip.starts == 1 && w.frequency == 902.1f); + } + { + RadioLibWrapper w; + w.chip.tx_latency = 7; w.chip.standby_latency = 8; + assert(w.startMorseTx(902.1f, 60, "EE") == MResult::Started); + assert(w._morse.nextDeadline() == g_mock_millis + 20); + g_mock_millis = w._morse.nextDeadline(); w.serviceCarrierWave(); + assert(!w.chip.carrier && w._morse.nextDeadline() == g_mock_millis + 60); + g_mock_millis = w._morse.nextDeadline(); w.serviceCarrierWave(); + assert(w.chip.carrier && w._morse.nextDeadline() == g_mock_millis + 20); + complete_message(w); fresh_receive(w); + assert(w.chip.starts == 2); + } + { + RadioLibWrapper w; w._active_profile = 1; w.frequency = w._cur_freq = 911.5f; + w.startMorseTx(902.1f, 25, "E"); complete_message(w); fresh_receive(w); + assert(w._active_profile == 1 && w.frequency == 911.5f); + } + // An unrelated carrier is neither re-keyed nor stopped by Morse operations. + { + RadioLibWrapper w; + w.setCarrierWave(0, 1000); + assert(w.startMorseTx(902.1f, 25, "E") == MResult::Busy); + assert(w.stopMorseTx() == MResult::Stopped && w.chip.carrier && w.chip.starts == 1); + assert(w.setCarrierWave(0, 0) == mesh::RadioParamApplyResult::APPLIED); + } + for (int reason = 0; reason < 5; ++reason) { + RadioLibWrapper w; + if (reason == 0) w.board.frequency_ok = false; + if (reason == 1) w.chip.frequency_result = -705; + if (reason == 2) w.chip.power_result = -705; + if (reason == 3) w.chip.standby_result = -705; + if (reason == 4) w.chip.irq_result = -705; + assert(w.startMorseTx(902.1f, 25, "E") == MResult::Failed); + assert(!w.chip.starts && !w.board.before); + if (w._cw_active) assert(w._cw_stopping && w.board.awake); + w.board.frequency_ok = true; + w.chip.frequency_result = w.chip.power_result = w.chip.standby_result = w.chip.irq_result = 0; + if (w._cw_active) { advance(100); w.serviceCarrierWave(); } + assert(!w._cw_active && !w.board.awake && w.isInRecvMode()); + } + // Native and FSK chips share fail-closed stop/recovery ownership. Both SPI + // stop operations may fail, but external transmit is still deasserted. + { + RadioLibWrapper w; + w.startMorseTx(902.1f, 25, "SOS"); + w.chip.stop_result = w.chip.standby_result = -705; + assert(w.stopMorseTx() == MResult::Failed); + assert(w._cw_active && w._cw_stopping && w.board.awake && w.board.after == 1); + assert(!w.setTxPower(10) && w.startMorseTx(903, 25, "E") == MResult::Busy); + const auto stops = w.chip.stops; + advance(99); w.serviceCarrierWave(); assert(w.chip.stops == stops); + w.reset_ok = true; + advance(1); w.serviceCarrierWave(); fresh_receive(w); + assert(w.board.after == 1); + } + for (int reason : {0, 1, 2}) { + RadioLibWrapper w; + w.startMorseTx(902.1f, 25, "EE"); + if (reason == 0) w.chip.standby_result = -705; + advance(48); w.serviceCarrierWave(); + if (reason == 0) { + assert(!w._cw_active && !w.board.awake && !w.chip.carrier); + } else { + w.chip.start_result = -705; + if (reason == 2) w.chip.stop_result = w.chip.standby_result = -705; + advance(144); w.serviceCarrierWave(); + if (reason == 2) assert(w._cw_stopping && w._cw_active && w.board.awake); + w.chip.start_result = w.chip.stop_result = w.chip.standby_result = 0; + if (w._cw_active) { advance(100); w.serviceCarrierWave(); } + fresh_receive(w); + } + assert(w.board.before == w.board.after); + } + for (int reason : {0, 1}) { + RadioLibWrapper w; w.startMorseTx(902.1f, 25, "E"); + if (reason == 0) w.fail_tune = true; + else w.fail_rx_starts = 3; + assert(w.stopMorseTx() == MResult::Failed); + assert(w._cw_active && w._cw_stopping && w.board.awake && !w.chip.carrier); + w.fail_tune = false; w.fail_rx_starts = 0; + advance(100); w.serviceCarrierWave(); fresh_receive(w); + } + { + RadioLibWrapper w; w.startMorseTx(902.1f, 25, "E"); + w.register_failures = 1; + assert(w.stopMorseTx() == MResult::Failed); + assert(w._cw_active && w._cw_stopping && w.board.awake && !w.chip.carrier); + assert(!w.isInRecvMode()); + advance(100); w.serviceCarrierWave(); fresh_receive(w); + } + // SX1276 initializes FSK exactly once for an entire message. PA mapping and + // LoRa restoration remain production virtual hooks, not fixture equivalents. + { + CustomSX1276Wrapper w; w._cur_dbm = 14; + assert(w.startMorseTx(902.1f, 25, "SOS") == MResult::Started); + assert(w.chip.fsk && w.chip.fsk_starts == 1 && w.chip.frequency == 902.1f); + assert(w.chip.deviation == 0 && !w.chip.ook && w.chip.power == 14); + complete_message(w); fresh_receive(w); + assert(w.chip.fsk_starts == 1 && w.chip.starts == 9 && !w.chip.fsk); + assert(w.chip.morse_ramp_calls == 1); + assert(w.resets == 1 && w.chip.power == 14); + } + { + DacCarrier w; w._cur_dbm = 30; + assert(w.startMorseTx(902.1f, 25, "SOS") == MResult::Started); + assert(w.chip.power == 2 && w.gain == 130 && w.chip.carrier); + complete_message(w); fresh_receive(w); + assert(w.chip.fsk_starts == 1 && w.chip.starts == 9 && !w.chip.fsk); + assert(w.chip.power == 2 && w.gain == 130); + } + for (int reason : {0, 1, 2, 3}) { + CustomSX1276Wrapper w; + if (reason == 0) w.chip.fsk_result = -705; + if (reason == 1) w.chip.limit_result = -705; + if (reason == 2) w.chip.power_result = -705; + if (reason == 3) w.chip.ramp_result = -705; + assert(w.startMorseTx(902.1f, 25, "SOS") == MResult::Failed); + assert(!w.chip.starts && !w.chip.carrier && !w.board.before); + w.chip.power_result = 0; + if (w._cw_active) { advance(100); w.serviceCarrierWave(); } + fresh_receive(w); + } + { + CustomSX1276Wrapper w; w.startMorseTx(902.1f, 25, "SOS"); + w.reset_ok = false; + assert(w.stopMorseTx() == MResult::Failed); + assert(w._cw_active && w._cw_stopping && w.board.awake && !w.chip.carrier && w.chip.fsk); + w.reset_ok = true; advance(100); w.serviceCarrierWave(); fresh_receive(w); + assert(!w.chip.fsk && w.board.before == w.board.after); + } +} diff --git a/test/fixtures/morse_encoder.cpp b/test/fixtures/morse_encoder.cpp new file mode 100644 index 000000000..7754217d8 --- /dev/null +++ b/test/fixtures/morse_encoder.cpp @@ -0,0 +1,141 @@ +#include +#include +#include +#include +#include +#include + +using mesh::MorseTx; +using Result = mesh::MorseTxResult; +using Edge = MorseTx::Edge; + +static std::string unpack(uint8_t code) { + std::string result; + const unsigned count = MorseTx::symbolCount(code); + for (unsigned bit = count; bit; --bit) + result += (code & (1U << (bit - 1))) ? '-' : '.'; + return result; +} + +static void alphabet() { + const char* letters[] = {".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", + "..", ".---", "-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.", + "...", "-", "..-", "...-", ".--", "-..-", "-.--", "--.."}; + const char* digits[] = {"-----", ".----", "..---", "...--", "....-", ".....", + "-....", "--...", "---..", "----."}; + for (int i = 0; i < 26; ++i) { + assert(unpack(MorseTx::codeFor('A' + i)) == letters[i]); + assert(MorseTx::codeFor('a' + i) == MorseTx::codeFor('A' + i)); + } + for (int i = 0; i < 10; ++i) assert(unpack(MorseTx::codeFor('0' + i)) == digits[i]); + const char punctuation[] = ".,?'!/()&:;=+-_\"$@"; + const char* patterns[] = {".-.-.-", "--..--", "..--..", ".----.", "-.-.--", "-..-.", + "-.--.", "-.--.-", ".-...", "---...", "-.-.-.", "-...-", ".-.-.", "-....-", + "..--.-", ".-..-.", "...-..-", ".--.-."}; + static_assert(sizeof(punctuation) - 1 == sizeof(patterns) / sizeof(*patterns), "test table"); + for (size_t i = 0; i < sizeof(punctuation) - 1; ++i) + assert(unpack(MorseTx::codeFor(punctuation[i])) == patterns[i]); + for (unsigned i = 0; i < 256; ++i) { + const char c = static_cast(i); + const bool known = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || (c && std::strchr(punctuation, c)); + assert(bool(MorseTx::codeFor(c)) == known); + } +} + +static void timing(const char* message, const std::vector& phases, + uint32_t start) { + MorseTx timer; + uint32_t duration = 0; + assert(MorseTx::validate(message, 25, &duration) == Result::Started); + uint32_t total = 0; + for (auto phase : phases) total += phase; + assert(duration == total); + assert(timer.start(message, 25, start) == Result::Started); + assert(timer.active() && timer.keyed() && timer.remainingMillis(start) == total); + uint32_t now = start; + for (size_t phase = 0; phase < phases.size(); ++phase) { + assert(timer.nextDeadline() == now + phases[phase]); + assert(timer.advance(now + phases[phase] - 1) == Edge::None); + assert(timer.keyed() == (phase % 2 == 0)); + now += phases[phase]; + const auto edge = timer.advance(now); + if (phase + 1 == phases.size()) { + assert(edge == Edge::Complete && !timer.active() && !timer.keyed()); + } else { + assert(edge == (phase % 2 ? Edge::KeyDown : Edge::KeyUp)); + assert(timer.remainingMillis(now) == total - uint32_t(now - start)); + } + } + assert(timer.remainingMillis(now) == 0 && timer.advance(now) == Edge::None); +} + +static void validation_and_limits() { + assert(MorseTx::DefaultWpm == 25 && MorseTx::DefaultFrequencyMHz == 902.100f); + assert(MorseTx::unitMillis(1) == 1200 && MorseTx::unitMillis(25) == 48); + assert(MorseTx::unitMillis(60) == 20); + for (unsigned speed = 1; speed <= MorseTx::MaxWpm; ++speed) { + uint32_t duration = 0; + assert(MorseTx::validate("T", speed, &duration) == Result::Started); + assert(duration == 3U * ((1200U + speed / 2U) / speed)); + } + for (uint8_t speed : {uint8_t(0), uint8_t(MorseTx::MaxWpm + 1)}) + assert(MorseTx::validate("E", speed) == Result::InvalidSpeed); + for (const char* invalid : {"", " ", "#", "line\nline", "tab\t", "\x80", "\xff"}) + assert(MorseTx::validate(invalid, 25) == Result::InvalidText); + assert(MorseTx::validate(nullptr, 25) == Result::InvalidText); + assert(MorseTx::validate(std::string(80, 'E').c_str(), 25) == Result::Started); + assert(MorseTx::validate(std::string(81, 'E').c_str(), 25) == Result::TooLong); + uint32_t duration = 0; + assert(MorseTx::validate(std::string(38, 'E').c_str(), 1, &duration) == Result::Started); + assert(duration == 178800); + assert(MorseTx::validate(std::string(39, 'E').c_str(), 1) == Result::TooLong); + assert(MorseTx::validate(std::string(80, 'T').c_str(), 1) == Result::TooLong); + // A caller does not receive a duration for a rejected transmission. + duration = 0x12345678; + assert(MorseTx::validate("#", 25, &duration) == Result::InvalidText); + assert(duration == 0x12345678); + MorseTx timer; + for (const char* invalid : {"", "#", "\xff"}) { + assert(timer.start(invalid, 25, 0) == Result::InvalidText); + assert(!timer.active() && !timer.keyed()); + } +} + +static void slow_service_and_cancel() { + MorseTx timer; + assert(timer.start("SOS", 25, 100) == Result::Started); + assert(timer.advance(10000) == Edge::KeyUp); // no missed-edge catch-up burst + assert(!timer.keyed() && timer.nextDeadline() == 10048); + for (int i = 0; i < 100; ++i) assert(timer.advance(10000) == Edge::None); + assert(timer.advance(10048) == Edge::KeyDown); + timer.stop(); + assert(!timer.active() && !timer.keyed() && !timer.remainingMillis(0)); + assert(timer.advance(20000) == Edge::None); + assert(timer.start("T", 1, 0xfffffff0) == Result::Started); + assert(timer.hardDeadline() == uint32_t(0xfffffff0 + 180000)); + assert(timer.advance(timer.hardDeadline() - 1) == Edge::Complete); + assert(timer.start("SOS", 25, 0xfffffff0) == Result::Started); + assert(timer.advance(timer.hardDeadline()) == Edge::Expired); + assert(!timer.active() && !timer.keyed() && timer.advance(0) == Edge::None); +} + +static void frequency_guard() { + assert(!mesh::morseFrequencyOverlaps(902.100f, 910.525f, 500)); + assert(mesh::morseFrequencyOverlaps(902.100f, 902.100f, 500)); + assert(mesh::morseFrequencyOverlaps(902.100f, 902.350f, 500)); + assert(!mesh::morseFrequencyOverlaps(902.100f, 902.375f, 500)); + assert(!mesh::morseFrequencyOverlaps(902.100f, 0, 500)); + assert(!mesh::morseFrequencyOverlaps(902.100f, 902.100f, 0)); + assert(!mesh::morseFrequencyOverlaps(NAN, 902.100f, 500)); + assert(!mesh::morseFrequencyOverlaps(INFINITY, 902.100f, 500)); +} + +int main() { + alphabet(); + validation_and_limits(); + timing("SOS", {48,48,48,48,48,144,144,48,144,48,144,144,48,48,48,48,48}, 100); + timing(" a B ", {48,48,144,336,144,48,48,48,48,48,48}, 0xfffffff0); + slow_service_and_cancel(); + frequency_guard(); +} diff --git a/test/fixtures/morse_fem_guard.cpp b/test/fixtures/morse_fem_guard.cpp new file mode 100644 index 000000000..32595329d --- /dev/null +++ b/test/fixtures/morse_fem_guard.cpp @@ -0,0 +1,96 @@ +#include +#include +#include +#include +#include + +struct Radio { + bool morse = false, morse_rx = false; + unsigned calibrations = 0; + bool isMorseTxActive() const { return morse; } + bool isMorseRxActive() const { return morse_rx; } + void recalibrateNoiseFloor() { ++calibrations; } +}; +struct Board { + bool rx = true, tx = false, supported = true, apply_ok = true; + unsigned rx_calls = 0, tx_calls = 0; + bool canControlLoRaFemLna() const { return supported; } + bool canControlLoRaFemPaGain() const { return supported; } + bool isLoRaFemLnaEnabled() const { return rx; } + bool isLoRaFemPaGainEnabled() const { return tx; } + bool setLoRaFemLnaEnabled(bool value) { ++rx_calls; if (apply_ok) rx = value; return apply_ok; } + bool setLoRaFemPaGainEnabled(bool value) { ++tx_calls; if (apply_ok) tx = value; return apply_ok; } +} board; +struct Prefs { + uint8_t radio_fem_rxgain = 1, radio_fem_rxgain_override = 0, radio_fem_txgain = 0; +}; +struct MyMesh { + Prefs _prefs; + Radio radio; + Radio* _radio = &radio; + bool _radio_available = true, save_ok = true; + unsigned saves = 0; + MyMesh() { board = Board(); } + bool savePrefs() { ++saves; return save_ok; } + bool applyAndSaveFemRxGain(bool); + bool applyAndSaveFemTxGain(bool); + Radio* getProfileRadio() const { return _radio; } + void recalibrateNoiseFloor() { if (_radio) _radio->recalibrateNoiseFloor(); } +}; +@COMPANION_METHODS@ + +struct CommonCLI { + MyMesh* _callbacks; + Board* _board = &board; + Prefs* _prefs; + unsigned saves = 0; + explicit CommonCLI(MyMesh& owner) : _callbacks(&owner), _prefs(&owner._prefs) {} + void savePrefs() { ++saves; } + void handleSetCmd(const char* config, char* reply) { + @COMMON_BRANCHES@ + } +}; + +int main() { + for (bool receiving : {false, true}) { + for (bool requested : {false, true}) { + MyMesh owner; owner.radio.morse = !receiving; owner.radio.morse_rx = receiving; + assert(!owner.applyAndSaveFemRxGain(requested)); + assert(!owner.applyAndSaveFemTxGain(requested)); + assert(!board.rx_calls && !board.tx_calls && !owner.saves && !owner.radio.calibrations); + assert(owner._prefs.radio_fem_rxgain == 1 && owner._prefs.radio_fem_rxgain_override == 0); + assert(owner._prefs.radio_fem_txgain == 0 && board.rx && !board.tx); + CommonCLI cli(owner); char reply[180]; + for (const char* command : {"radio.fem.rxgain on", "radio.fem.rxgain off", + "radio.fem.txgain on", "radio.fem.txgain off"}) { + cli.handleSetCmd(command, reply); + assert(!strncmp(reply, "Error: stop Morse", 17)); + assert(!board.rx_calls && !board.tx_calls && !owner.saves && !cli.saves); + assert(owner._prefs.radio_fem_rxgain == 1 && owner._prefs.radio_fem_rxgain_override == 0); + assert(owner._prefs.radio_fem_txgain == 0 && board.rx && !board.tx); + } + } + } + { + MyMesh owner; + assert(owner.applyAndSaveFemRxGain(false) && owner.applyAndSaveFemTxGain(true)); + assert(!board.rx && board.tx && owner._prefs.radio_fem_rxgain == 0); + assert(owner._prefs.radio_fem_rxgain_override == 1 && owner._prefs.radio_fem_txgain == 1); + assert(owner.saves == 2 && owner.radio.calibrations == 1); + } + { + MyMesh owner; owner.save_ok = false; + assert(!owner.applyAndSaveFemRxGain(false) && !owner.applyAndSaveFemTxGain(true)); + assert(board.rx && !board.tx && owner._prefs.radio_fem_rxgain == 1); + assert(owner._prefs.radio_fem_rxgain_override == 0 && owner._prefs.radio_fem_txgain == 0); + assert(owner.saves == 2 && owner.radio.calibrations == 2); + } + { + MyMesh owner; CommonCLI cli(owner); char reply[180]; + cli.handleSetCmd("radio.fem.rxgain off", reply); + assert(!strncmp(reply, "OK - ", 5) && !board.rx && owner._prefs.radio_fem_rxgain == 0); + cli.handleSetCmd("radio.fem.txgain on", reply); + assert(!strncmp(reply, "OK - ", 5) && board.tx && owner._prefs.radio_fem_txgain == 1); + assert(cli.saves == 2 && owner.radio.calibrations == 1); + } +} diff --git a/test/fixtures/morse_main_loop.cpp b/test/fixtures/morse_main_loop.cpp new file mode 100644 index 000000000..ecb3a1115 --- /dev/null +++ b/test/fixtures/morse_main_loop.cpp @@ -0,0 +1,115 @@ +#include +#include +static unsigned usb_services=0,command_services=0,transport_services=0,delays=0; +struct Stream { + int available() const { return 0; } + char read() const { return 0; } + void print(char) {} + void print(const char*) {} + void println(const char*) {} + void printf(const char*,...) {} +} console; +char command[180]={}; +uint32_t millis() { return 100000; } +void delay(unsigned) { ++delays; } +bool usbLoggingRecoverySafe() { return true; } +namespace mesh { +void serviceUsbLoggingPort() { ++usb_services; } +void serviceUsbTerminalPort() { ++usb_services; } +bool isUsbLoggingEnabled() { return false; } +bool isUsbLoggingWatchdogArmed() { return false; } +bool serviceUsbLoggingWatchdog(bool(*)()) { ++usb_services; return false; } +Stream& usbConsolePort() { return console; } +void noteUsbLoggingStatsCommand(const char*) { ++usb_services; } +namespace wireless { +struct Control { void service(uint32_t) { ++transport_services; } bool pending() { return false; } } controller; +Control& control() { return controller; } +} +} +struct Board { + unsigned calls=0,watchdogs=0,sleeps=0,fans=0,reboots=0; + void loop() { ++calls; } + void feedWatchdog(bool=true) { ++watchdogs; } + void sleep(uint32_t) { ++sleeps; } + bool isUsbDataConnected() { return false; } + bool isUsbHostConnected() { return false; } + void reboot() { ++reboots; } + void updateFanControl() { ++fans; } +} board; +struct Task { + unsigned calls=0,deadline=200; + void loop() { ++calls; deadline+=100; } + void tick() { loop(); } + void feed() { loop(); } +} sensors,ui_task,rtc_clock,interface_manager,external_watchdog; +bool display_ready=true; +struct Mesh { + struct Prefs { bool powersaving_enabled=true,system_watchdog_enabled=true; uint32_t reboot_interval=0; } prefs; + unsigned calls=0; + void loop() { ++calls; } + Prefs* getNodePrefs() { return &prefs; } + uint32_t getPowerSaveSleepSeconds(unsigned) { return 1; } + bool millisHasNowPassed(uint32_t) { return true; } + bool isTrackerModeEnabled() { return false; } + bool hasPendingWork() { return false; } + void handleCommand(uint32_t,char*,char* reply) { reply[0]=0; ++command_services; } + void handleUsbCommand(char*,char* reply) { reply[0]=0; ++command_services; } +} the_mesh; +TestRadio radio_driver; +void serviceCommandInterfaces() { ++command_services; } +void serviceUsbLoggingOnlySession() { ++usb_services; } +void serviceUsbTerminalHostSessionReset() { ++usb_services; } +void serviceUsbAsciiSessionDefault() { ++usb_services; } +void serviceUsbLoggingReplyBarrier() { ++usb_services; } +void serviceUsbLoggingOwnership(bool) { ++usb_services; } +void expireUsbBinaryStartupProbeBeforeDispatch() { ++usb_services; } +void serviceUsbTerminal() { ++usb_services; } +void syncUsbTerminalInputEditing() { ++usb_services; } +void serviceDeferredCompanionBluetooth() { ++transport_services; } +void serviceCompanionBluetoothIdentity() { ++transport_services; } +void serviceCompanionBluetoothControl() { ++transport_services; } +void serviceCompanionBluetoothName() { ++transport_services; } +@LOOP@ +int main() { + assert(!mesh::morseTimingActive(nullptr)); + assert(!mesh::morseTimingActive(&radio_driver)); + loop(); + assert(sensors.calls==1 && ui_task.calls==1 && board.sleeps==1); + radio_driver.cw=true; // Ordinary carrier-wave diagnostics keep old scheduling. + loop(); + assert(sensors.calls==2 && ui_task.calls==2 && board.sleeps==2); + for(unsigned mode=0;mode<3;++mode) { + radio_driver.active=mode==0; + radio_driver.rx_active=mode!=0; + radio_driver.rx_recovering=mode==2; + assert(mesh::morseTimingActive(&radio_driver)); + const auto sensor_deadline=sensors.deadline; + const auto usb_before=usb_services; + loop(); + // UI button/touch polling stays live; actual expensive redraw work is + // deferred inside the UI implementation, not by skipping input service. + assert(sensors.calls==2 && ui_task.calls==mode+3 && board.sleeps==2 && !delays); + assert(sensors.deadline==sensor_deadline); + assert(usb_services>usb_before && the_mesh.calls==mode+3 && board.watchdogs==mode+3); + assert(rtc_clock.calls==mode+3 && external_watchdog.calls>=mode+3); +#if !ROLE_SENSOR + assert(board.fans==mode+3); +#endif + } + radio_driver.active=radio_driver.rx_active=radio_driver.rx_recovering=false; + loop(); + assert(sensors.calls==3 && ui_task.calls==6 && board.sleeps==3 && !delays); + assert(the_mesh.calls==6 && rtc_clock.calls==6 && board.watchdogs==6); +#if !ROLE_SENSOR + assert(board.fans==6); +#endif +#if ROLE_COMPANION + assert(interface_manager.calls==6 && transport_services==30 && board.calls==6); +#elif ROLE_REPEATER + assert(command_services==6 && transport_services==6); +#elif ROLE_ROOM + assert(transport_services==6 && board.calls==6); +#else + assert(board.calls==6); +#endif +} diff --git a/test/fixtures/morse_reply_companion.cpp b/test/fixtures/morse_reply_companion.cpp new file mode 100644 index 000000000..23e572d07 --- /dev/null +++ b/test/fixtures/morse_reply_companion.cpp @@ -0,0 +1,60 @@ +namespace mesh { struct Packet { bool radio_reply=false; }; } +struct ContactInfo { + struct { uint8_t pub_key[32]={}; } id; + bool allowed=true; + bool isRemoteCLIAllowed() const { return allowed; } +}; +static uint32_t millis() { return 100; } +#define PUB_KEY_SIZE 32 +#define TXT_TYPE_CLI_COMMAND 1 +struct MyMesh { + mesh::MorseReplyHandoff _morse_reply; + bool _morse_reply_admission=false; + TestRadio radio; + unsigned active=0, forwarded=0; + uint32_t handled_timestamp=0; + MyMesh() { radio.rx_supported=true; } + void markConnectionActive(const ContactInfo&) { ++active; } + void queueMessage(const ContactInfo&,uint8_t,mesh::Packet*,uint32_t,const void*,size_t,const char*) { ++forwarded; } + bool handleCommand(const char* text,uint32_t timestamp,char* reply) { + handled_timestamp=timestamp; + return mesh::handleMorseReplyCommand(_morse_reply,&radio,text,reply,160,millis()) + || mesh::handleMorseCommand(&radio,text,reply,160); + } + void onCLICommandRecv(const ContactInfo&,mesh::Packet*,uint32_t,const char*,char*); + void onCLIReplyPrepared(const ContactInfo&,mesh::Packet*); + void onCLIReplyQueued(const ContactInfo&,mesh::Packet*,bool); +}; +@METHODS@ +int main() { + ContactInfo actor, other; actor.id.pub_key[0]=1; other.id.pub_key[0]=2; + mesh::Packet reply_packet, copy; + char reply[160]={}; + for (unsigned failure=0; failure<3; ++failure) { + MyMesh node; + node.onCLICommandRecv(actor,nullptr,0,"morse listen 1",reply); + assert(node.handled_timestamp==1 && node._morse_reply_admission && node._morse_reply.pending()); + assert(node.active==1 && !node.radio.rx_starts); + node.onCLIReplyPrepared(other,&reply_packet); + assert(!node._morse_reply.ownsReply(&reply_packet)); + if (failure==0) { + node.onCLIReplyPrepared(actor,&reply_packet); + assert(reply_packet.radio_reply && node._morse_reply.ownsReply(&reply_packet)); + node._morse_reply.copy(&reply_packet,©); + node.onCLIReplyQueued(actor,&reply_packet,true); + assert(!node._morse_reply_admission); + node._morse_reply.complete(&reply_packet); + assert(!node._morse_reply.service(&node.radio,100,true,true,902100,25)); + node._morse_reply.complete(©); + assert(node._morse_reply.service(&node.radio,100,true,true,902100,25)); + assert(node.radio.rx_starts==1 && node.radio.rx_duration==60000); + } else { + node.onCLIReplyPrepared(actor,failure==1 ? nullptr : &reply_packet); + node.onCLIReplyQueued(actor,failure==1 ? nullptr : &reply_packet,false); + assert(!node._morse_reply_admission && !node._morse_reply.pending() && !node.radio.rx_starts); + } + } + MyMesh denied; actor.allowed=false; + denied.onCLICommandRecv(actor,nullptr,0,"morse listen 1",reply); + assert(denied.forwarded==1 && !denied.handled_timestamp && !denied._morse_reply.pending()); +} diff --git a/test/fixtures/morse_reply_handoff.cpp b/test/fixtures/morse_reply_handoff.cpp new file mode 100644 index 000000000..5913dbfc4 --- /dev/null +++ b/test/fixtures/morse_reply_handoff.cpp @@ -0,0 +1,190 @@ +using Handoff = mesh::MorseReplyHandoff; +using RXResult = mesh::MorseRxResult; +static uint8_t actor[32] = {1, 2, 3, 4}; + +static void stage(Handoff& handoff, uint32_t now = 100) { + handoff.beginCommand(actor); + assert(handoff.remoteCommand()); + assert(handoff.stage(902100, 25, 60000, now)); + handoff.endCommand(); + assert(handoff.pending() && !handoff.remoteCommand()); + assert(!memcmp(handoff.actor(), actor, 32)); +} +static void queued(Handoff& h, const void* packet, const void* copy = nullptr) { + h.prepareReply(packet); + if (copy) h.copy(packet, copy); + assert(h.ownsReply(packet) && (!copy || h.ownsReply(copy))); + h.armReply(packet, true); +} +static bool service(Handoff& h, TestRadio& radio, uint32_t now = 100, + bool authorized = true, bool usable = true, + uint32_t frequency = 902100, uint8_t wpm = 25) { + return h.service(&radio, now, authorized, usable, frequency, wpm); +} + +static void exact_phy_barrier() { + for (unsigned success = 0; success < 4; ++success) { + Handoff handoff; TestRadio radio; radio.rx_supported = true; + int packet, copy, unrelated; + stage(handoff); queued(handoff, &packet, ©); + handoff.complete(&unrelated); handoff.fail(&unrelated); + assert(!service(handoff, radio) && radio.rx_starts == 0 && handoff.pending()); + if (success & 1) handoff.complete(&packet); else handoff.fail(&packet); + assert(!handoff.ownsReply(&packet) && handoff.ownsReply(©)); // pool slot may now be reused + assert(!service(handoff, radio) && radio.rx_starts == 0 && handoff.pending()); + if (success & 2) handoff.complete(©); else handoff.fail(©); + assert(service(handoff, radio) == (success != 0)); + assert(!handoff.pending() && radio.rx_starts == unsigned(success != 0)); + assert(!handoff.ownsReply(&packet) && !handoff.ownsReply(©)); + handoff.complete(&packet); handoff.complete(©); + assert(!service(handoff, radio) && radio.rx_starts == unsigned(success != 0)); + } + { + Handoff h; TestRadio radio; int packet; + assert(!h.stage(902100, 25, 60000, 100)); // timestamps do not authorize remote staging + h.beginCommand(nullptr); assert(!h.stage(902100, 25, 60000, 100)); + stage(h); + const auto generation = h.generation(); + h.beginCommand(actor); assert(!h.stage(902100, 25, 60000, 100)); h.endCommand(); + assert(h.generation() == generation); + h.prepareReply(&packet); + h.fail(&packet); h.complete(&packet); // admission callbacks before arm prove nothing + assert(h.pending() && !service(h, radio) && !radio.rx_starts); + h.armReply(&packet, false); assert(!h.pending()); + } + { + Handoff h; TestRadio radio; int packet; + stage(h); h.prepareReply(&packet); h.armReply(nullptr, true); + assert(!h.pending() && !service(h, radio) && !radio.rx_starts); + } +} + +static void revocation_settings_cancel_and_replays() { + for (int reason = 0; reason < 5; ++reason) { + Handoff h; TestRadio radio; int packet; + stage(h); queued(h, &packet); h.complete(&packet); + if (reason == 4) h.cancel(); + assert(!service(h, radio, 100, reason != 0, reason != 1, + reason == 2 ? 902101 : 902100, reason == 3 ? 26 : 25)); + assert(!h.pending() && !radio.rx_starts); + } + { + Handoff h; TestRadio radio; int old_packet, new_packet; + stage(h); queued(h, &old_packet); const auto previous = h.generation(); h.cancel(); + stage(h); queued(h, &new_packet); + assert(h.generation() != previous && !h.contains(&old_packet)); + h.complete(&old_packet); h.fail(&old_packet); + assert(!service(h, radio) && !radio.rx_starts); + h.complete(&new_packet); assert(service(h, radio) && radio.rx_starts == 1); + } + { + Handoff h; uint8_t original[32]; memcpy(original, actor, 32); + stage(h); actor[31] ^= 1; + assert(!memcmp(h.actor(), original, 32)); // full identity is owned, not an ACL index/pointer + actor[31] ^= 1; h.cancel(); + } +} + +static void deadlines_busy_retry_and_hard_failure() { + for (uint32_t now : {100U, 0xfffffff0U}) { + Handoff h; TestRadio radio; int packet; + stage(h, now); queued(h, &packet); + assert(!service(h, radio, now + Handoff::ReplyDeadlineMillis - 1) && h.pending()); + assert(!service(h, radio, now + Handoff::ReplyDeadlineMillis) && !h.pending()); + h.complete(&packet); assert(!service(h, radio, now + Handoff::ReplyDeadlineMillis + 1) && !radio.rx_starts); + } + for (auto outcome : {RXResult::Unsupported, RXResult::InvalidFrequency, + RXResult::InvalidSpeed, RXResult::InvalidDuration, RXResult::Failed}) { + Handoff h; TestRadio radio; int packet; + stage(h); queued(h, &packet); h.complete(&packet); + radio.force_rx_result = true; radio.forced_rx = outcome; + assert(!service(h, radio) && !h.pending() && radio.rx_starts == 1); + assert(!service(h, radio, 200) && radio.rx_starts == 1); + } + for (uint32_t now : {100U, 0xfffffff0U, UINT32_MAX - 99U, UINT32_MAX - 9999U}) { + Handoff h; TestRadio radio; int packet; + stage(h, now); queued(h, &packet); h.complete(&packet); + radio.force_rx_result = true; radio.forced_rx = RXResult::Busy; + assert(!service(h, radio, now) && h.pending() && radio.rx_starts == 1); + assert(!service(h, radio, now) && radio.rx_starts == 1); + assert(!service(h, radio, now + 99) && radio.rx_starts == 1); + assert(!service(h, radio, now + 100) && radio.rx_starts == 2); + radio.force_rx_result = false; + assert(service(h, radio, now + 200) && !h.pending() && radio.rx_starts == 3); + assert(!service(h, radio, now + 300) && radio.rx_starts == 3); + } + { + const uint32_t now = UINT32_MAX - 9999U; // exactly zero wrapped start deadline + Handoff h; TestRadio radio; int packet; + stage(h, now); queued(h, &packet); h.complete(&packet); + radio.force_rx_result = true; radio.forced_rx = RXResult::Busy; + assert(!service(h, radio, now)); + assert(!service(h, radio, now + Handoff::StartDeadlineMillis - 1) && h.pending()); + assert(!service(h, radio, now + Handoff::StartDeadlineMillis) && !h.pending()); + } + { + Handoff h; TestRadio radio; int packet; + stage(h); queued(h, &packet); h.complete(&packet); + radio.force_rx_result = true; radio.forced_rx = RXResult::Busy; + assert(!service(h, radio) && h.pending()); + assert(!service(h, radio, 101, false) && !h.pending()); // revoke permission during Busy retry + assert(radio.rx_starts == 1); + } +} + +static void actual_shared_remote_parser() { + Handoff h; TestRadio radio; radio.rx_supported = true; + char reply[160]; + const auto command = [&](const char* text, bool remote) { + if (remote) h.beginCommand(actor); + const bool intercepted = mesh::handleMorseReplyCommand(h, &radio, text, reply, sizeof(reply), 100); + h.endCommand(); + if (!intercepted) assert(mesh::handleMorseCommand(&radio, text, reply, sizeof(reply))); + return std::string(reply); + }; + assert(command("morse listen 1", false).find("OK - Morse RX") == 0); + assert(radio.rx_active && radio.rx_starts == 1 && !h.pending()); + command("morse stop", false); + assert(command("morse listen 1", true).find("starts after this reply transmits") != std::string::npos); + assert(h.pending() && !radio.rx_active && radio.rx_starts == 1); + const auto generation = h.generation(); + assert(command("morse listen 1", true).find("handoff pending") != std::string::npos && h.generation() == generation); + assert(command("get morse", false).find("waiting for command reply") != std::string::npos); + assert(command("set morse.freq 903", false).find("handoff pending") != std::string::npos); + assert(command("cw on", false).find("handoff pending") != std::string::npos); + assert(command("morse send E", false).find("handoff pending") != std::string::npos); + assert(command("morse read", false).find("> 0000/0000") == 0); + assert(command("morse stop", false).find("pending Morse RX cancelled") != std::string::npos && !h.pending()); + assert(radio.rx_starts == 1); + for (const char* spelling : {"morse\tlisten 1", "morse listen\t1", "morse listen +.5\t", + "morse\t\tlisten .0166667"}) { + assert(command(spelling, true).find("starts after this reply transmits") != std::string::npos); + assert(h.pending() && radio.rx_starts == 1 && !radio.rx_active); + assert(command("morse\tstop\t ", false).find("pending Morse RX cancelled") != std::string::npos); + assert(!h.pending()); + } + for (bool remote : {false, true}) { + for (const char* stop : {"morse stop ", "morse stop ", "morse\tstop", "morse\t\tstop\t "}) { + assert(command("morse listen 1", true).find("starts after this reply transmits") != std::string::npos); + assert(command(stop, remote).find("pending Morse RX cancelled") != std::string::npos); + assert(!h.pending() && !radio.rx_active && radio.rx_starts == 1); + } + } + for (const char* invalid : {"morse listen", "morse listen 0", "morse listen 6", + "morse listen 1x", "morse listen nan", "morse listen 1e0", "morse listen .01"}) { + assert(command(invalid, true).find("Error:") == 0 && !h.pending() && radio.rx_starts == 1); + } + mesh::usable = false; + assert(command("morse listen 1", true).find("saved Morse settings unavailable") != std::string::npos && !h.pending()); + mesh::usable = true; + radio.rx_supported = false; + assert(command("morse listen 1", true).find("unsupported") != std::string::npos && !h.pending()); + assert(!mesh::handleMorseReplyCommand(h, &radio, nullptr, reply, sizeof(reply), 100)); + assert(!mesh::handleMorseReplyCommand(h, &radio, "get morse", nullptr, 160, 100)); + assert(!mesh::handleMorseReplyCommand(h, &radio, "get morse", reply, 0, 100)); +} + +int main() { + exact_phy_barrier(); revocation_settings_cancel_and_replays(); + deadlines_busy_retry_and_hard_failure(); actual_shared_remote_parser(); +} diff --git a/test/fixtures/morse_reply_loop.cpp b/test/fixtures/morse_reply_loop.cpp new file mode 100644 index 000000000..5876dc4a3 --- /dev/null +++ b/test/fixtures/morse_reply_loop.cpp @@ -0,0 +1,60 @@ +#include +static uint32_t now_ms = 100; +uint32_t millis() { return now_ms; } +#define PUB_KEY_SIZE 32 +struct ClientInfo { + struct { uint8_t pub_key[32] = {}; } id; + bool admin = true; + bool isAdmin() const { return admin; } +}; +struct ACL { + std::vector clients; + size_t requested_key_size = 0; + const ClientInfo* getClient(const uint8_t* key, size_t size) { + requested_key_size = size; + assert(size == 32); + for (const auto& client : clients) if (!memcmp(client.id.pub_key, key, size)) return &client; + return nullptr; + } +}; +struct Callbacks { TestRadio* radio; mesh::Radio* getProfileRadio() { return radio; } }; +struct CommonCLI { + mesh::MorseReplyHandoff _morse_reply; + ACL* _acl; + Callbacks* _callbacks; + struct Profiles { unsigned loops = 0; void loop() { ++loops; } } _radio_profiles; + unsigned management_loops = 0; + CommonCLI(ACL& acl, Callbacks& callbacks) : _acl(&acl), _callbacks(&callbacks) {} + void loopManagement() { ++management_loops; } + void loop(); +}; +@LOOP@ + +int main() { + const uint8_t key[32] = {1, 2, 3, 4}; + for (int scenario = 0; scenario < 5; ++scenario) { + TestRadio radio; radio.rx_supported = true; + ACL acl; ClientInfo actor; memcpy(actor.id.pub_key, key, 32); acl.clients.push_back(actor); + Callbacks callbacks{&radio}; CommonCLI cli(acl, callbacks); + int packet; + cli._morse_reply.beginCommand(key); + assert(cli._morse_reply.stage(902100, 25, 60000, now_ms)); + cli._morse_reply.endCommand(); cli._morse_reply.prepareReply(&packet); + cli._morse_reply.armReply(&packet, true); + cli.loop(); assert(radio.rx_starts == 0); // shared loop must still wait for PHY completion + cli._morse_reply.complete(&packet); + if (scenario == 0) acl.clients[0].admin = false; + if (scenario == 1) { acl.clients[0].id.pub_key[31] = 1; } // reused slot, same short key + if (scenario == 2) { + acl.clients[0].id.pub_key[31] = 1; acl.clients.push_back(actor); // original full identity moved + } + if (scenario == 3) mesh::usable = false; + if (scenario == 4) mesh::current.frequency_khz = 902101; + cli.loop(); + const bool allowed = scenario == 2; + assert(radio.rx_starts == unsigned(allowed) && !cli._morse_reply.pending()); + assert(acl.requested_key_size == 32 && cli._radio_profiles.loops == unsigned(allowed ? 1 : 2)); + assert(cli.management_loops == unsigned(allowed ? 1 : 2)); + mesh::usable = true; mesh::current = mesh::MorseSettings(); + } +} diff --git a/test/fixtures/morse_reply_prefix.cpp b/test/fixtures/morse_reply_prefix.cpp new file mode 100644 index 000000000..e7c801e91 --- /dev/null +++ b/test/fixtures/morse_reply_prefix.cpp @@ -0,0 +1,21 @@ +struct Callbacks { + TestRadio* radio; + mesh::Radio* getProfileRadio() { return radio; } +}; +struct PrefixedCLI { + Callbacks* _callbacks; + void handle(const char* command, char* reply) { @COMMON_MORSE_CALL@ } +}; +int main() { + TestRadio radio; radio.rx_supported = true; + radio.capture = std::string(mesh::MorseRx::CaptureCapacity, 'E'); + Callbacks callbacks{&radio}; PrefixedCLI cli{&callbacks}; + char reply[161]; memset(reply, '~', sizeof(reply)); memcpy(reply, "AB:", 3); + cli.handle("morse read", reply + 3); + assert(!memcmp(reply, "AB:", 3) && reply[160] == '~'); + assert(memchr(reply + 3, 0, 157)); + unsigned offset = 0, total = 0, next = 0; + assert(sscanf(reply + 3, "> %u/%u next=%u", &offset, &total, &next) == 3); + assert(offset == 0 && total == mesh::MorseRx::CaptureCapacity && next > 0 && next < 125); + assert(strlen(reply) < 160); +} diff --git a/test/fixtures/morse_reply_role_callbacks.cpp b/test/fixtures/morse_reply_role_callbacks.cpp new file mode 100644 index 000000000..a5ad3a5d6 --- /dev/null +++ b/test/fixtures/morse_reply_role_callbacks.cpp @@ -0,0 +1,22 @@ +int main() { + const uint8_t actor[32] = {1, 2, 3, 4}; + for (unsigned successes = 0; successes < 4; ++successes) { + @ROLE@ node; TestRadio radio; radio.rx_supported = true; + mesh::Packet first, second, unrelated; + auto& handoff = node._cli.morseReply(); + handoff.beginCommand(actor); assert(handoff.stage(902100, 25, 60000, now_ms)); + handoff.endCommand(); handoff.prepareReply(&first); + node.onRadioProfileCopyQueued(&second, &first, 0); handoff.armReply(&first, true); + node.onSendComplete(&unrelated); + assert(handoff.pending() && handoff.contains(&first) && handoff.contains(&second)); + if (successes & 1) node.onSendComplete(&first); else node.onSendFail(&first); + assert(!handoff.service(&radio, now_ms, true, true, 902100, 25)); + assert(!radio.rx_starts && !handoff.ownsReply(&first) && handoff.ownsReply(&second)); + if (successes & 2) node.onSendComplete(&second); else node.onSendFail(&second); + assert(handoff.service(&radio, now_ms, true, true, 902100, 25) == (successes != 0)); + assert(radio.rx_starts == unsigned(successes != 0) && !handoff.pending()); + node.onSendComplete(&first); node.onSendFail(&second); + assert(!handoff.service(&radio, now_ms, true, true, 902100, 25)); + assert(radio.rx_starts == unsigned(successes != 0)); + } +} diff --git a/test/fixtures/morse_rx_admission.cpp b/test/fixtures/morse_rx_admission.cpp new file mode 100644 index 000000000..3e77daad7 --- /dev/null +++ b/test/fixtures/morse_rx_admission.cpp @@ -0,0 +1,78 @@ +#include +#include +#include +#include +#include +#include + +static size_t total_free = 0, largest_free = 0; +static int reported_free = 0; +static int heap_mode = 0; +static unsigned largest_queries=0; +struct ESPMock { size_t getFreeHeap() const { return total_free; } } ESP; +struct RP2040Mock { int getFreeHeap() const { return reported_free; } } rp2040; +static constexpr int MALLOC_CAP_8BIT = 1; +size_t heap_caps_get_largest_free_block(int) { ++largest_queries; return largest_free; } +int dbgHeapFree() { return reported_free; } +caddr_t _sbrk(int) { + if (heap_mode == 0) return reinterpret_cast(-1); + if (heap_mode == 1) return reinterpret_cast(UINTPTR_MAX); + if (heap_mode == 2) { + return static_cast(__builtin_frame_address(0)); // simulated heap already at stack + } + return reinterpret_cast(1); +} +struct RadioLibWrapper { + mesh::MorseRx* _morse_rx=nullptr; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + mesh::MorseRssiTrace* _morse_trace=nullptr; +#endif + bool canAllocateMorseReceiver() const; +}; +@ADMISSION@ + +int main() { + RadioLibWrapper driver; + mesh::MorseRx retained_rx; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + mesh::MorseRssiTrace retained_trace; + const unsigned states=4; +#else + const unsigned states=2; +#endif + for(unsigned existing=0;existingmissing_trace ? missing_rx : missing_trace; + const size_t required = allocation + 4096U; +#if defined(ESP32_PLATFORM) + total_free = required - 1; largest_free = contiguous; + assert(!driver.canAllocateMorseReceiver()); + total_free = required; + if(contiguous) { + largest_free = contiguous - 1; + assert(!driver.canAllocateMorseReceiver()); + } + largest_queries=0; + largest_free = contiguous; + assert(driver.canAllocateMorseReceiver()); + assert(largest_queries==unsigned(contiguous!=0)); +#elif defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) + for (int count : {-1, 0, int(required) - 1}) { + reported_free = count; assert(!driver.canAllocateMorseReceiver()); + } + reported_free = int(required); assert(driver.canAllocateMorseReceiver()); +#elif defined(STM32_PLATFORM) + for (int mode : {0, 1, 2}) { heap_mode = mode; assert(!driver.canAllocateMorseReceiver()); } + heap_mode = 3; assert(driver.canAllocateMorseReceiver()); +#else + assert(!driver.canAllocateMorseReceiver()); // unknown allocator is never optimistic +#endif + } +} diff --git a/test/fixtures/morse_rx_chip_preparation.cpp b/test/fixtures/morse_rx_chip_preparation.cpp new file mode 100644 index 000000000..e0df21323 --- /dev/null +++ b/test/fixtures/morse_rx_chip_preparation.cpp @@ -0,0 +1,55 @@ +using CustomSX1262=Chip; +using CustomSX1268=Chip; +using CustomLLCC68=Chip; +using CustomSTM32WLx=Chip; +using CustomLR1110=Chip; +using CustomLR1121=Chip; +using CustomLR2021=Chip; +using LR1110=Chip; +using LR1121=Chip; +using LR2021=Chip; +struct SX1262Receive : RadioLibWrapper { @SX1262@ }; +struct SX1268Receive : RadioLibWrapper { @SX1268@ }; +struct LLCC68Receive : RadioLibWrapper { @LLCC68@ }; +struct STM32WLxReceive : RadioLibWrapper { @STM32WLx@ }; +struct LR1110Receive : RadioLibWrapper { @LR1110@ }; +struct LR1121Receive : RadioLibWrapper { @LR1121@ }; +struct LR2021Receive : RadioLibWrapper { @LR2021@ }; +struct SX1276Receive : RadioLibWrapper { @SX1276@ }; +template static void receive(float bandwidth,bool hard_reset) { + for (int fault=0; fault<5; ++fault) { + T radio; radio.reset_ok=true; + radio.fail_morse_params=false; + if (fault==1) radio.board.frequency_ok=false; + if (fault==2) radio.chip.fsk_result=-705; + if (fault==3) radio.board.reset_prepare_ok=false; + if (fault==4) radio.board.reset_finish_ok=false; + const bool failure=fault==1 || fault==2 || (hard_reset && fault>=3); + assert(radio.supportsMorseRx() && radio.morseReceiverBandwidthKHz()==bandwidth); + assert(radio.startMorseRx(902.1f,25,1000)==(failure ? mesh::MorseRxResult::Failed : mesh::MorseRxResult::Started)); + assert(!radio.chip.starts && !radio.board.before && !radio.board.after); + if (!failure) { + assert(radio._morse_rx_fsk_mode && radio.chip.fsk && radio.isMorseRxActive()); + radio.reset_ok=false; + assert(radio.stopMorseRx()==mesh::MorseRxResult::Failed && radio._morse_rx_fsk_mode); + assert(radio.isMorseRxActive() && radio.board.awake); + radio.reset_ok=true; advance(100); radio.serviceCarrierWave(); + assert(!radio._morse_rx_fsk_mode && !radio.chip.fsk && !radio._cw_active && !radio.board.awake); + } else { + assert(!radio._morse_rx_fsk_mode && !radio.chip.fsk && !radio._cw_active && !radio.board.awake); + assert(!radio.morseCaptureLength()); + } + if (fault==1 || (fault==3 && hard_reset)) assert(!radio.chip.fsk_starts); + if (hard_reset && !failure) assert(radio.board.reset_prepares==1 && radio.board.reset_finishes==1); + } + T unsupported; unsupported.deep_supported=false; + assert(!unsupported.supportsMorseRx()); + assert(unsupported.startMorseRx(902.1f,25,1000)==mesh::MorseRxResult::Unsupported); + assert(!unsupported.chip.fsk_starts && !unsupported.chip.standby_calls); +} +int main() { + receive(4.8f,true); receive(4.8f,true); + receive(4.8f,true); receive(4.8f,false); + receive(4.8f,true); receive(4.8f,true); + receive(4.8f,true); receive(2.6f,true); +} diff --git a/test/fixtures/morse_rx_chip_rssi.cpp b/test/fixtures/morse_rx_chip_rssi.cpp new file mode 100644 index 000000000..c63d39c3f --- /dev/null +++ b/test/fixtures/morse_rx_chip_rssi.cpp @@ -0,0 +1,97 @@ +#include +#include +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_SX126X_CMD_GET_RSSI_INST 0x15 +#define RADIOLIB_SX127X_FSK_OOK 0 +#define RADIOLIB_SX1278_CHIP_VERSION 0x12 +#define RADIOLIB_SX1278_CHIP_VERSION_ALT 0x13 +#define RADIOLIB_SX1278_CHIP_VERSION_RFM9X 0x11 +#define RADIOLIB_SX127X_REG_RSSI_VALUE_FSK 0x24 +struct MockModule { + int result = 0; + uint8_t raw = 220, command = 0; + size_t length = 0; + unsigned reads = 0; + int16_t SPIreadStream(uint8_t cmd, uint8_t* out, size_t size) { + ++reads; command = cmd; length = size; + *out = raw; return result; + } + int16_t SPIgetRegValue(uint8_t address) { + ++reads; assert(address==RADIOLIB_SX127X_REG_RSSI_VALUE_FSK); + return result ? result : raw; + } +}; +struct Chip { + MockModule module; + float rssi = -110; + int rssi_result = 0; + unsigned rssi_reads = 0; + MockModule* getMod() { return &module; } + int16_t getRssiInst(float* out) { ++rssi_reads; *out = rssi; return rssi_result; } + @SX1262_GETTER@ +}; +using LR11x0 = Chip; +using LR2021 = Chip; +using CustomSX1262 = Chip; +using CustomSX1268 = Chip; +using CustomLLCC68 = Chip; +using CustomSTM32WLx = Chip; +struct SX1276Chip : Chip { + MockModule* mod=&module; + uint8_t version=0x12, modem=0; + uint8_t getChipVersion() { return version; } + uint8_t getActiveModem() { return modem; } + @SX1276_GETTER@ +}; +using CustomSX1276 = SX1276Chip; +struct Base { + Chip chip; Chip* _radio = &chip; + virtual float readMorseRssi() { return 0; } + @DEFAULT_BANDWIDTH@ +}; +struct SX1262Probe : Base { @SX1262@ }; +struct SX1268Probe : Base { @SX1268@ }; +struct LLCC68Probe : Base { @LLCC68@ }; +struct STM32WLxProbe : Base { @STM32WLx@ }; +struct LR1110Probe : Base { @LR1110@ }; +struct LR1121Probe : Base { @LR1121@ }; +struct LR2021Probe : Base { @LR2021@ }; +struct SX1276Probe { + SX1276Chip chip; SX1276Chip* _radio=&chip; + virtual float readMorseRssi() { return 0; } + virtual float morseReceiverBandwidthKHz() const { return 0; } +}; +struct SX1276Actual : SX1276Probe { @SX1276@ }; + +template static void native(float bandwidth) { + Probe probe; + assert(probe.morseReceiverBandwidthKHz() == bandwidth); + assert(probe.readMorseRssi() == -110); + assert(probe.chip.module.reads == 1 && probe.chip.module.length == 1); + assert(probe.chip.module.command == RADIOLIB_SX126X_CMD_GET_RSSI_INST); + probe.chip.module.result = -705; probe.chip.module.raw = 0; + assert(std::isnan(probe.readMorseRssi())); // failed SPI must not look like a 0dBm carrier +} +template static void lr(float bandwidth) { + Probe probe; + assert(probe.morseReceiverBandwidthKHz() == bandwidth && probe.readMorseRssi() == -110); + assert(probe.chip.rssi_reads == 1); + probe.chip.rssi_result = -705; probe.chip.rssi = 0; + assert(std::isnan(probe.readMorseRssi())); +} +int main() { + native(4.8f); native(4.8f); + native(4.8f); native(4.8f); + lr(4.8f); lr(4.8f); lr(4.8f); + SX1276Actual sx; + assert(sx.morseReceiverBandwidthKHz()==2.6f && sx.readMorseRssi()==-110); + sx.chip.modem=1; assert(std::isnan(sx.readMorseRssi())); + sx.chip.modem=0; sx.chip.version=0xff; assert(std::isnan(sx.readMorseRssi())); + for (uint8_t version : {uint8_t(0x12),uint8_t(0x13),uint8_t(0x11)}) { + sx.chip.version=version; assert(sx.readMorseRssi()==-110); + } + sx.chip.module.result=-705; assert(std::isnan(sx.readMorseRssi())); +} diff --git a/test/fixtures/morse_rx_cli.cpp b/test/fixtures/morse_rx_cli.cpp new file mode 100644 index 000000000..510e02ad9 --- /dev/null +++ b/test/fixtures/morse_rx_cli.cpp @@ -0,0 +1,135 @@ +int main() { + TestRadio radio; radio.rx_supported = true; + char reply[160]; + const auto run = [&](const char* command) { + memset(reply, '~', sizeof(reply)); + assert(mesh::handleMorseCommand(&radio, command, reply, sizeof(reply))); + assert(memchr(reply, 0, sizeof(reply))); + return std::string(reply); + }; + assert(run("get morse").find("captured=0") != std::string::npos); + assert(run("morse listen 5").find("OK - Morse RX") == 0); + assert(radio.rx_active && !radio.active && radio.rx_starts == 1 && !radio.starts); + assert(radio.frequency == 902.1f && radio.speed == 25 && radio.rx_duration == 300000); + assert(run("get morse").find("BW 7.81 kHz; 300.000 seconds left") != std::string::npos); + radio.capture = "CQ TEST SOS"; + const auto saves = mesh::saves; + assert(run("set morse.freq 903.1").find("busy") != std::string::npos); + assert(run("set morse.wpm 30").find("busy") != std::string::npos); + assert(mesh::saves == saves); + assert(run("morse clear").find("Error: stop Morse") == 0 && radio.capture == "CQ TEST SOS"); + assert(run("morse send E").find("busy") != std::string::npos && !radio.active && radio.rx_active); + assert(run("morse read") == "> 0000/0011 next=END truncated=0 CQ TEST SOS"); + assert(run("morse read 3") == "> 0003/0011 next=END truncated=0 TEST SOS"); + assert(run("morse read 11") == "> 0011/0011 next=END truncated=0 "); + assert(run("morse stats") == "> no Morse RX statistics"); + radio.have_rx_diagnostics = true; + radio.rx_diagnostics.samples = 500; + radio.rx_diagnostics.noise_dbm = -110; + radio.rx_diagnostics.minimum_dbm = -112; + radio.rx_diagnostics.maximum_dbm = -80; + radio.rx_diagnostics.last_dbm = -109; + radio.rx_diagnostics.unit_ms = 48; + radio.rx_diagnostics.read_errors = 2; + assert(run("morse stats") == "> samples=500; noise=-110.0; min=-112.0; max=-80.0; last=-109.0 dBm; unit=48 ms; gap=0 ms; bad=2"); + const auto saved_stats=radio.rx_diagnostics; + radio.rx_diagnostics.max_sample_gap_ms=0xffffffff; + radio.rx_diagnostics.samples=0xffffffff; + radio.rx_diagnostics.unit_ms=65535; + radio.rx_diagnostics.read_errors=255; + char reflected[161]; memset(reflected,'~',sizeof(reflected)); + memcpy(reflected,"AB|",3); + assert(mesh::handleMorseCommand(&radio,"morse stats",reflected+3,157)); + assert(reflected[160]=='~' && !memcmp(reflected,"AB|",3)); + assert(memchr(reflected+3,0,157) && std::string(reflected+3).find("gap=4294967295 ms")!=std::string::npos); + radio.rx_diagnostics=saved_stats; + assert(run("morse stats E").find("Error:") == 0); + assert(run("morse stop").find("capture retained; receive resumed") != std::string::npos); + assert(!radio.rx_active && radio.rx_stops == 1 && radio.capture == "CQ TEST SOS"); + assert(run("get morse").find("captured=11") != std::string::npos); + assert(run("morse read").find("CQ TEST SOS") != std::string::npos); + + // A bounded LoRa CLI reply is a stable page, not a silently truncated message. + radio.capture.clear(); + for (unsigned i = 0; i < mesh::MorseRx::CaptureCapacity; ++i) radio.capture += char('A' + i % 26); + radio.capture_truncated = true; + std::string reconstructed; + uint32_t offset = 0; + unsigned pages = 0; + while (offset < radio.capture.size()) { + const auto command = std::string("morse read ") + std::to_string(offset); + const auto page = run(command.c_str()); + unsigned returned = 0, total = 0, truncated = 0; + char next[5]; int payload = 0; + assert(sscanf(page.c_str(), "> %u/%u next=%4s truncated=%u %n", + &returned, &total, next, &truncated, &payload) == 4); + assert(returned == offset && total == mesh::MorseRx::CaptureCapacity && truncated == 1 && payload > 0); + const auto text = page.substr(size_t(payload)); + assert(!text.empty() && page.size() < sizeof(reply)); + reconstructed += text; + if (!strcmp(next, "END")) offset = total; + else { + const auto following = uint32_t(std::strtoul(next, nullptr, 10)); + assert(following == returned + text.size() && following > returned); + offset = following; + } + assert(++pages < 40); + } + assert(reconstructed == radio.capture && pages > 1); + for (const char* argument : {"-1", "4097", "1x", "1.0", "1e1", "4294967296", "99999999999999999999", "1 2"}) { + const auto command = std::string("morse read ") + argument; + assert(run(command.c_str()).find("Error:") == 0); + } + assert(run("morse read +0").find("> 0000/4096") == 0); + mesh::usable = false; + assert(run("morse read").find("> 0000/4096") == 0); // capture remains accessible on settings error + assert(run("morse stats").find("samples=500") != std::string::npos); + const auto sessions = radio.rx_starts; + assert(run("morse listen 1").find("saved Morse settings unavailable") != std::string::npos); + assert(radio.rx_starts == sessions); + assert(run("morse clear") == "OK - Morse capture cleared" && radio.capture.empty() && !radio.capture_truncated); + mesh::usable = true; + for (const char* duration : {"", "0", "-1", "5.001", "nan", "inf", "1e0", "1junk", "1 2", "0.01", "4294967296", "99999999999999999999"}) { + const auto starts = radio.rx_starts; + const auto command = std::string("morse listen ") + duration; + assert(run(command.c_str()).find("Error:") == 0 && radio.rx_starts == starts); + } + for (const char* duration : {"1", "5", ".5", ".0166667", "+2", "\t1\t"}) { + const auto command = std::string("morse listen ") + duration; + assert(run(command.c_str()).find("OK - Morse RX") == 0); + assert(radio.rx_duration >= 1000 && radio.rx_duration <= 300000 && !radio.active); + run("morse stop"); + } + for (const char* invalid : {"get morse listen 1", "set morse listen 1", "morse clear 1", "morse stop 1"}) { + const auto starts = radio.rx_starts, stops = radio.rx_stops; + assert(run(invalid).find("Error:") == 0 && radio.rx_starts == starts && radio.rx_stops == stops); + } + radio.force_rx_result = true; + for (auto result : {mesh::MorseRxResult::Busy, mesh::MorseRxResult::Unsupported, + mesh::MorseRxResult::InvalidFrequency, mesh::MorseRxResult::Overlap, + mesh::MorseRxResult::InvalidSpeed, mesh::MorseRxResult::InvalidDuration, + mesh::MorseRxResult::Failed}) { + radio.forced_rx = result; + assert(run("morse listen 1").find("Error:") == 0); + } + radio.force_rx_result = false; + run("morse listen 1"); radio.capture = "LAST COPY"; + radio.force_rx_result = true; radio.forced_rx = mesh::MorseRxResult::Failed; + assert(run("morse stop").find("recovery pending; capture retained") != std::string::npos); + assert(run("get morse").find("packet-radio recovery pending") != std::string::npos); + assert(run("morse read").find("LAST COPY") != std::string::npos); + radio.force_rx_result = false; run("morse stop"); + radio.supported = false; // RX-only capability must not require a TX implementation + assert(run("morse listen 1").find("OK - Morse RX") == 0); + assert(run("morse send E").find("TX unsupported") != std::string::npos); + run("morse stop"); + radio.rx_supported = false; + assert(run("morse listen 1").find("unsupported") != std::string::npos); + + radio.supported = radio.rx_supported = true; radio.capture = std::string(mesh::MorseRx::CaptureCapacity, 'E'); + for (size_t capacity = 1; capacity <= 160; ++capacity) { + char bounded[162]; memset(bounded, '~', sizeof(bounded)); + assert(mesh::handleMorseCommand(&radio, "morse read 0", bounded, capacity)); + assert(memchr(bounded, 0, capacity) && bounded[capacity] == '~'); + } +} diff --git a/test/fixtures/morse_rx_decay_shoulder.cpp b/test/fixtures/morse_rx_decay_shoulder.cpp new file mode 100644 index 000000000..85b1c0759 --- /dev/null +++ b/test/fixtures/morse_rx_decay_shoulder.cpp @@ -0,0 +1,38 @@ +// Diagnostic for an OFF debounce committing before the RSSI decay settles. +#include +#include +#include +#include +#include +static void receive(uint8_t speed,uint32_t cadence) { + mesh::MorseRx rx; uint32_t now=100; + const auto unit=mesh::MorseTx::unitMillis(speed); + rx.start(speed,300000,now); + const auto level=[&](float value,uint32_t duration) { + for (uint32_t elapsed=0; elapsed +#include +#include +#include +#include +#include +#include + +using mesh::MorseRx; + +struct Receiver { + MorseRx decoder; + uint32_t now; + uint16_t unit; + explicit Receiver(uint8_t wpm = 25, uint32_t start = 100, uint32_t duration = 300000) + : now(start), unit(mesh::MorseTx::unitMillis(wpm)) { + assert(decoder.start(wpm, duration, now) == mesh::MorseRxResult::Started); + } + void level(float rssi, uint32_t duration) { + for (uint32_t elapsed = 0; elapsed < duration; ++elapsed) + decoder.sample(rssi, now++); + } + void quiet(uint32_t duration) { level(-110, duration); } + void warmup() { quiet(MorseRx::NoiseLearningMillis + 20); } + void pattern(const char* marks) { + for (size_t i = 0; marks[i]; ++i) { + level(-80, uint32_t(unit) * (marks[i] == '-' ? 3 : 1)); + if (marks[i + 1]) quiet(unit); + } + } + void letter_gap() { quiet(uint32_t(unit) * 3); } + void word_gap() { quiet(uint32_t(unit) * 7); } + std::string text() const { + std::string captured(decoder.text(), decoder.length()); + while (!captured.empty() && captured.back() == ' ') captured.pop_back(); + return captured; + } +}; + +static void speed_and_gaps() { + for (uint8_t speed : {1, 5, 12, 25, 40, 60}) { + Receiver rx(speed); rx.warmup(); + rx.pattern("..."); rx.letter_gap(); + rx.pattern("---"); rx.letter_gap(); + rx.pattern("..."); rx.word_gap(); + assert(rx.text() == "SOS" && !rx.decoder.keyed() && rx.decoder.active()); + rx.pattern("."); rx.word_gap(); + assert(rx.text() == "SOS E"); + rx.decoder.stop(rx.now); + assert(!rx.decoder.active() && rx.text() == "SOS E"); + const auto snapshot = rx.text(); + rx.level(-80, 10000); rx.quiet(10000); + assert(rx.text() == snapshot); // retained transcript is not a live idle decoder + rx.decoder.clear(); assert(rx.decoder.length() == 0 && !rx.decoder.truncated()); + } +} + +static void alphabet_and_punctuation() { + const char* marks[] = {".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", + "..", ".---", "-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.", + "...", "-", "..-", "...-", ".--", "-..-", "-.--", "--..", + "-----", ".----", "..---", "...--", "....-", ".....", "-....", "--...", "---..", "----.", + ".-.-.-", "--..--", "..--..", ".----.", "-.-.--", "-..-.", "-.--.", "-.--.-", ".-...", + "---...", "-.-.-.", "-...-", ".-.-.", "-....-", "..--.-", ".-..-.", "...-..-", ".--.-."}; + const char expected[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,?'!/()&:;=+-_\"$@"; + static_assert(sizeof(expected) - 1 == sizeof(marks) / sizeof(*marks), "test table"); + Receiver rx(25); rx.warmup(); + for (const char* pattern : marks) { rx.pattern(pattern); rx.letter_gap(); } + rx.word_gap(); + assert(rx.text() == expected); +} + +static void falling_edge_lag_does_not_merge_letters() { + for (unsigned lag : {4, 8, 12}) { + Receiver rx(25); rx.warmup(); + const char* text="MORSE TEST 25"; + for (size_t letter=0; text[letter]; ++letter) { + if (text[letter]==' ') continue; + const auto code=mesh::MorseTx::codeFor(text[letter]); + for (uint8_t bit=mesh::MorseTx::symbolCount(code); bit; --bit) { + const unsigned units=code & (1U << (bit-1)) ? 3 : 1; + // A narrow RSSI filter holds the falling envelope high for a fixed + // delay, lengthening marks while shortening their following gap. + rx.level(-80, units*rx.unit+lag); + const unsigned gap=bit>1 ? 1 : (text[letter+1]==' ' || !text[letter+1] ? 7 : 3); + rx.quiet(gap*rx.unit-lag); + } + } + rx.decoder.stop(rx.now); + assert(rx.text()==text); + } +} + +static void noise_and_keying_envelope() { + Receiver rx; rx.warmup(); + for (unsigned i = 0; i < 10000; ++i) { + // Low floor jitter plus isolated <2ms RF spikes cannot produce letters. + const float signal = i % 317 == 0 ? -70 : -110 + int(i % 3) - 1; + rx.decoder.sample(signal, rx.now++); + } + rx.word_gap(); assert(rx.decoder.length() == 0); + // A slow PA rise/fall and one-millisecond dips must remain one mark. + for (int dot = 0; dot < 3; ++dot) { + rx.level(-108, 1); rx.level(-103, 1); rx.level(-96, 1); + for (unsigned i = 0; i < rx.unit - 6U; ++i) + rx.decoder.sample(i == 15 ? -110 : -80, rx.now++); + rx.level(-96, 1); rx.level(-103, 1); rx.level(-108, 1); + if (dot < 2) rx.quiet(rx.unit); + } + rx.word_gap(); assert(rx.text() == "S"); + Receiver silence; silence.warmup(); silence.quiet(20000); + silence.decoder.stop(silence.now); assert(silence.decoder.length() == 0); + for (unsigned duration : {10, 48 * 12}) { + Receiver bad; bad.warmup(); bad.level(-80, duration); bad.word_gap(); + bad.decoder.stop(bad.now); + assert(bad.text() == (duration == 10 ? "" : "?")); // never guess a valid dot/dash + } + Receiver diagnostic; diagnostic.warmup(); + mesh::MorseRxDiagnostics stats; + diagnostic.decoder.diagnostics(stats); + assert(stats.samples == MorseRx::NoiseLearningMillis + 20 && stats.noise_dbm == -110); + diagnostic.pattern("."); diagnostic.word_gap(); diagnostic.decoder.stop(diagnostic.now); + diagnostic.decoder.diagnostics(stats); + assert(stats.minimum_dbm == -110 && stats.maximum_dbm == -80 && stats.last_dbm == -110); + assert(stats.unit_ms == 48 && stats.read_errors == 0); + const auto samples = stats.samples; + diagnostic.level(NAN, 100); diagnostic.decoder.diagnostics(stats); + assert(stats.samples == samples); // retained statistics do not accrue after capture ends + for(uint32_t started : {100U,0xfffffff0U}) { + Receiver gap(25,started); gap.warmup(); + gap.decoder.diagnostics(stats); assert(stats.max_sample_gap_ms==1); + const uint32_t last_valid=gap.now-1; + const auto count=stats.samples; + gap.now+=200; const uint32_t invalid_at=gap.now; + gap.decoder.sample(NAN,gap.now++); + gap.decoder.diagnostics(stats); + assert(stats.samples==count && stats.max_sample_gap_ms==uint32_t(invalid_at-last_valid)); + gap.now+=20; const uint32_t resumed=gap.now; + gap.decoder.sample(-110,gap.now++); gap.decoder.diagnostics(stats); + assert(stats.samples==count+1 && stats.max_sample_gap_ms==uint32_t(resumed-last_valid)); + gap.quiet(20); gap.decoder.diagnostics(stats); + assert(stats.max_sample_gap_ms==uint32_t(resumed-last_valid)); + } +} + +static void stop_and_expiry() { + { + Receiver rx; rx.warmup(); rx.pattern("."); rx.quiet(14); + rx.decoder.stop(rx.now); assert(rx.text() == "E"); + } + { + Receiver rx; rx.warmup(); rx.pattern("."); rx.quiet(9); + rx.decoder.stop(rx.now); assert(rx.decoder.length()==0); // OFF debounce is still incomplete + } + { + Receiver rx; rx.warmup(); rx.pattern("."); rx.quiet(rx.unit); + rx.level(-80, rx.unit / 2); // stopped in the middle of a multi-mark character + rx.decoder.stop(rx.now); assert(rx.decoder.length() == 0 && !rx.decoder.keyed()); + } + { + Receiver rx(25, 0xfffffff0); rx.warmup(); + rx.pattern("..."); rx.letter_gap(); rx.pattern("---"); rx.letter_gap(); + rx.pattern("..."); rx.word_gap(); assert(rx.text() == "SOS"); + rx.now = uint32_t(0xfffffff0 + 300000); + assert(!rx.decoder.service(rx.now)); + assert(!rx.decoder.active() && !rx.decoder.keyed() && rx.text() == "SOS"); + } + { + Receiver rx(25, 100, 1000); rx.warmup(); rx.level(-80, 1000); + assert(!rx.decoder.service(rx.now) && !rx.decoder.active()); + assert(rx.decoder.length() == 0); // timeout cannot guess an unfinished held carrier + } + for (uint8_t speed : {uint8_t(0), uint8_t(mesh::MorseTx::MaxWpm + 1)}) { + MorseRx invalid; + assert(invalid.start(speed, 1000, 0) == mesh::MorseRxResult::InvalidSpeed); + assert(!invalid.active()); + } + for (uint32_t duration : {0U, 999U, 300001U, 0xffffffffU}) { + MorseRx invalid; + assert(invalid.start(25, duration, 0) == mesh::MorseRxResult::InvalidDuration); + assert(!invalid.active()); + } +} + +static void bounded_capture_and_restart() { + Receiver rx(60); rx.warmup(); + for (size_t character = 0; character < MorseRx::CaptureCapacity + 100; ++character) { + // Slightly fast dots remain within the bounded timing adaptation; 55ms + // gaps are full letters even before that adaptation settles. This fills + // 4096 bytes inside the hard five-minute session without a lab WPM override. + rx.level(-80, 15); rx.quiet(55); + } + assert(rx.decoder.truncated() && rx.decoder.length() == MorseRx::CaptureCapacity); + assert(!rx.decoder.active() && !rx.decoder.keyed()); + assert(rx.decoder.text()[rx.decoder.length()] == 0); + for (size_t i = 0; i < rx.decoder.length(); ++i) assert(rx.decoder.text()[i] == 'E'); + const auto before = rx.text(); + rx.decoder.stop(rx.now); + assert(rx.text() == before && rx.decoder.truncated()); + std::string reconstructed; + size_t offset = 0; + while (offset < rx.decoder.length()) { + char page[23]; + const size_t copied = rx.decoder.read(offset, page, sizeof(page)); + assert(copied > 0 && copied < sizeof(page) && page[copied] == 0); + reconstructed += page; offset += copied; + } + assert(reconstructed == std::string(rx.decoder.text(), rx.decoder.length())); + for (size_t offset : {rx.decoder.length(), size_t(0xffffffffU)}) { + char page[2] = {'X', 'Y'}; + assert(rx.decoder.read(offset, page, sizeof(page)) == 0 && page[0] == 0 && page[1] == 'Y'); + } + char small[2] = {'X', 'Y'}; + assert(rx.decoder.read(0, small, 0) == 0 && small[0] == 'X'); + assert(rx.decoder.read(0, small, 1) == 0 && small[0] == 0 && small[1] == 'Y'); + assert(rx.decoder.read(0, nullptr, 10) == 0); + assert(rx.decoder.start(0, 1000, rx.now) == mesh::MorseRxResult::InvalidSpeed); + assert(rx.text() == before && rx.decoder.truncated() && !rx.decoder.active()); + assert(rx.decoder.start(25, 300000, rx.now) == mesh::MorseRxResult::Started); + assert(rx.decoder.length() == 0 && !rx.decoder.truncated()); // only a new valid session clears +} + +int main() { + speed_and_gaps(); alphabet_and_punctuation(); noise_and_keying_envelope(); + falling_edge_lag_does_not_merge_letters(); stop_and_expiry(); bounded_capture_and_restart(); + std::printf("MorseRx size: %zu bytes\n", sizeof(MorseRx)); +} diff --git a/test/fixtures/morse_rx_driver.cpp b/test/fixtures/morse_rx_driver.cpp new file mode 100644 index 000000000..86e7db193 --- /dev/null +++ b/test/fixtures/morse_rx_driver.cpp @@ -0,0 +1,221 @@ +using RXResult = mesh::MorseRxResult; + +static void level(RadioLibWrapper& w, float rssi, uint32_t duration) { + w.rssi = rssi; + for (uint32_t elapsed = 0; elapsed < duration; elapsed += 2) { + advance(2); w.serviceCarrierWave(); + } +} +static void pattern(RadioLibWrapper& w, const char* code, uint32_t unit = 48) { + for (size_t symbol = 0; code[symbol]; ++symbol) { + level(w, -80, unit * (code[symbol] == '-' ? 3 : 1)); + if (code[symbol + 1]) level(w, -110, unit); + } +} +static void receive_e(RadioLibWrapper& w) { + level(w, -110, 520); pattern(w, "."); level(w, -110, 336); + assert(!strncmp(w.morseCaptureText(), "E", 1)); +} +static void packet_receive_restored(const RadioLibWrapper& w) { + assert(!w._cw_active && !w._morse_rx_owned && !w._cw_stopping && !w.board.awake); + assert(!w.chip.carrier && !w.board.before && !w.board.after && !w.chip.starts); + assert(w.isInRecvMode() && w._rx_ps_armed && w.chip.callback); + assert(w._profile_refresh_required && w.calibrations); +} + +struct FskReceiver : RadioLibWrapper { + FskReceiver() { reset_ok=true; } + int16_t prepareMorseReceiver(float frequency_mhz) override { + _morse_rx_fsk_mode=true; + chip.fsk=true; + return RadioLibWrapper::prepareMorseReceiver(frequency_mhz); + } +}; + +int main() { + for (bool gain : {false,true}) { + RadioLibWrapper w; w._rx_boosted_gain_valid=true; w._cur_rx_boosted_gain=gain; + assert(w.startMorseRx(902.1f,25,60000)==RXResult::Started); + assert(w.rx_gain_applies==1 && w.physical_rx_gain==gain && w.rssi_reads==1); + assert(w.rx_gain_order && w.rx_gain_order 0 && stats.minimum_dbm == -110 && stats.maximum_dbm == -80); + const auto captured = std::string(w.morseCaptureText()); + w.clearMorseCapture(); assert(w.morseCaptureText() == captured); + assert(w.stopMorseRx() == RXResult::Stopped); packet_receive_restored(w); + assert(w.morseCaptureText() == captured && w._profiles.primary == primary && w._profiles.secondary.params == secondary); + std::string pages; size_t offset = 0; + while (offset < w.morseCaptureLength()) { + char chunk[3]; const auto count = w.readMorseCapture(offset, chunk, sizeof(chunk)); + assert(count); pages += chunk; offset += count; + } + assert(pages == captured); + assert(w.stopMorseRx() == RXResult::Stopped && w.morseCaptureText() == captured); + const auto stopped_generation = w.morseCaptureGeneration(); + assert(w.startMorseRx(902.1f, 25, 1000) == RXResult::Started); + assert(w.morseCaptureLength() == 0 && w.morseCaptureGeneration() == stopped_generation + 1); + assert(w.stopMorseRx() == RXResult::Stopped); + const auto cleared_generation = w.morseCaptureGeneration(); + w.clearMorseCapture(); + assert(!w._morse_rx && w.morseCaptureLength() == 0 && w.morseCaptureGeneration() == cleared_generation + 1); + } + // RX admission fails before receive/PA mutation and never damages a retained capture. + for (int reason = 0; reason < 12; ++reason) { + RadioLibWrapper w; + assert(w.startMorseRx(902.1f, 25, 60000) == RXResult::Started); + receive_e(w); assert(w.stopMorseRx() == RXResult::Stopped); + const auto captured = std::string(w.morseCaptureText()); + const auto generation = w.morseCaptureGeneration(); + const auto raw_starts = w.chip.raw_rx_starts, standbys = w.chip.standby_calls; + float frequency = 902.1f; uint8_t speed = 25; uint32_t duration = 60000; + RXResult wanted = RXResult::Busy; + if (reason == 0) w.state = STATE_TX_WAIT; + if (reason == 1) w.events.record(); + if (reason == 2) w.busy = true; + if (reason == 3) w.packet = true; + if (reason == 4) { w.supported = false; wanted = RXResult::Unsupported; } + if (reason == 5) { frequency = NAN; wanted = RXResult::InvalidFrequency; } + if (reason == 6) { frequency = 99; wanted = RXResult::InvalidFrequency; } + if (reason == 7) { speed = 0; wanted = RXResult::InvalidSpeed; } + if (reason == 8) { speed = uint8_t(mesh::MorseTx::MaxWpm + 1); wanted = RXResult::InvalidSpeed; } + if (reason == 9) { duration = 999; wanted = RXResult::InvalidDuration; } + if (reason == 10) { duration = 300001; wanted = RXResult::InvalidDuration; } + if (reason == 11) { w.receiver_allocation_ok = false; wanted = RXResult::Failed; } + assert(w.startMorseRx(frequency, speed, duration) == wanted); + assert(w.morseCaptureText() == captured && w.morseCaptureGeneration() == generation); + assert(w.chip.raw_rx_starts == raw_starts && w.chip.standby_calls == standbys && !w.board.awake); + } + for (uint8_t profile : {0, 1}) { + RadioLibWrapper w; + const float frequency = w._profiles.params(profile).freq; + if (profile) w._profiles.secondary.mode = mesh::RadioProfileMode::Off; + assert(w.startMorseRx(frequency, 25, 1000) == RXResult::Started); + assert(!w.chip.starts && !w.board.before && w.isMorseRxActive()); + char reply[160]; + assert(mesh::handleCarrierWaveCommand(&w, "get cw2", reply, sizeof(reply))); + assert(!strcmp(reply, "> Morse RX owns radio; use get morse")); + assert(w.stopMorseRx() == RXResult::Stopped); packet_receive_restored(w); + } + for (int fault = 0; fault < 7; ++fault) { + RadioLibWrapper w; w.startMorseRx(902.1f, 25, 60000); receive_e(w); w.stopMorseRx(); + const auto captured = std::string(w.morseCaptureText()); + const auto generation = w.morseCaptureGeneration(); + if (fault == 0) w.board.frequency_ok = false; + if (fault == 1) w.fail_morse_params = true; + if (fault == 2) w.chip.rx_result = -705; + if (fault == 3) w.rssi = NAN; + if (fault == 4) w.rssi = -181; + if (fault == 5) w.chip.frequency_result = -705; + if (fault == 6) w.chip.irq_result = -705; + assert(w.startMorseRx(902.1f, 25, 60000) == RXResult::Failed); + assert(w.morseCaptureText() == captured && w.morseCaptureGeneration() == generation); + assert(!w.chip.starts && !w.board.before && !w.board.after); + w.board.frequency_ok = true; w.fail_morse_params = false; + w.chip.rx_result = w.chip.frequency_result = w.chip.irq_result = 0; w.rssi = -110; + if (w._cw_active) { advance(100); w.serviceCarrierWave(); } + packet_receive_restored(w); + assert(w.morseCaptureText() == captured); + } + for (int fault : {0, 1, 2, 3}) { + RadioLibWrapper w; w.startMorseRx(902.1f, 25, 60000); receive_e(w); + const auto captured = std::string(w.morseCaptureText()); + if (fault == 0) w.fail_tune = true; + if (fault == 1) w.fail_rx_starts = 2; + if (fault == 2) w.register_failures = 1; + if (fault == 3) w.chip.standby_result = w.chip.stop_result = -705; + assert(w.stopMorseRx() == RXResult::Failed); + assert(w.isMorseRxActive() && w.morseRxRecovering() && w.board.awake); + assert(w.morseRxRemainingMillis() == 0 && !w.chip.starts && !w.board.before); + assert(w.morseCaptureText() == captured); + w.clearMorseCapture(); assert(w.morseCaptureText() == captured); + assert(w.startMorseTx(903, 25, "E") == mesh::MorseTxResult::Busy); + assert(w.startMorseRx(903, 25, 1000) == RXResult::Busy); + const auto stops = w.chip.stops; + advance(99); w.serviceCarrierWave(); assert(w.chip.stops == stops); + w.fail_tune = false; w.fail_rx_starts = 0; w.reset_ok = true; + w.chip.stop_result = w.chip.standby_result = 0; + advance(1); w.serviceCarrierWave(); packet_receive_restored(w); + assert(w.morseCaptureText() == captured); + } + for (uint8_t changed : {0, 1, 2}) { + RadioLibWrapper w; w.startMorseRx(902.1f, 25, 60000); receive_e(w); + if (changed < 2) ++w._profiles.generation[changed]; + else w._profiles.secondary.params.freq = 902.1f; + w.serviceCarrierWave(); + if (changed == 2) { assert(w.isMorseRxActive()); w.stopMorseRx(); } + packet_receive_restored(w); + assert(!strncmp(w.morseCaptureText(), "E", 1)); + } + { + RadioLibWrapper w; g_mock_millis = 0xfffffff0; + w.startMorseRx(902.1f, 25, 300000); receive_e(w); + g_mock_millis = uint32_t(0xfffffff0 + 300000); + w.serviceCarrierWave(); packet_receive_restored(w); + assert(!strncmp(w.morseCaptureText(), "E", 1)); + } + { + RadioLibWrapper w; w.startMorseRx(902.1f, 25, 60000); receive_e(w); + const auto reads = w.rssi_reads; + for (int call = 0; call < 100; ++call) w.serviceCarrierWave(); + assert(w.rssi_reads == reads); // repeated foreground calls never busy-spin SPI + level(w, NAN, 14); assert(w.isMorseRxActive() && w._morse_rx_read_errors == 7); + mesh::MorseRxDiagnostics stats; + assert(w.morseRxDiagnostics(stats) && stats.read_errors == 7); + level(w, -110, 2); assert(w._morse_rx_read_errors == 0); + level(w, NAN, 16); packet_receive_restored(w); + assert(!strncmp(w.morseCaptureText(), "E", 1)); + } +} diff --git a/test/fixtures/morse_rx_fast_off.cpp b/test/fixtures/morse_rx_fast_off.cpp new file mode 100644 index 000000000..b7ae07dd4 --- /dev/null +++ b/test/fixtures/morse_rx_fast_off.cpp @@ -0,0 +1,41 @@ +#include +#include +#include +#include +#include +struct Receiver { + mesh::MorseRx rx; uint32_t now=100; + Receiver(uint8_t wpm) { assert(rx.start(wpm,300000,now)==mesh::MorseRxResult::Started); level(-110,520); } + void level(float rssi,uint32_t duration) { for(uint32_t i=0;i +#include +#include +#include +#include +static void receive(const char* label,float quiet,float mark,bool fade,uint8_t wpm=25,uint32_t cadence=1,bool jitter=false,bool spikes=false) { + mesh::MorseRx rx; uint32_t now=100; + rx.start(wpm,300000,now); + const uint32_t unit=mesh::MorseTx::unitMillis(wpm); + const auto level=[&](float value,uint32_t duration) { + for (uint32_t elapsed=0; elapsed-104 ? value+int(now%7)-3 : value; + if (spikes && value==quiet && now%137==0) input=-60; + rx.sample(input,now); + } + } + }; + level(-127.5f,520); + const char* text="SOS TEST 25"; + unsigned marks=0; + for (size_t letter=0; text[letter]; ++letter) { + if (text[letter]==' ') continue; + const uint8_t code=mesh::MorseTx::codeFor(text[letter]); + for (uint8_t bit=mesh::MorseTx::symbolCount(code); bit; --bit) { + const unsigned units=code & (1U<<(bit-1)) ? 3 : 1; + const float signal=fade && marks++>8 ? -94.0f : mark; + level(signal,unit*units); + const unsigned gap=bit>1 ? 1 : (text[letter+1]==' ' || !text[letter+1] ? 7 : 3); + level(quiet,unit*gap); + } + } + mesh::MorseRxDiagnostics diagnostics; rx.diagnostics(diagnostics); + const bool stuck=rx.keyed(); rx.stop(now); +#ifdef MORSE_ESTIMATOR_REQUIRE_PASS + assert(!stuck && !strncmp(rx.text(),text,strlen(text))); + assert(rx.length()==strlen(text) || (rx.length()==strlen(text)+1 && rx.text()[strlen(text)]==' ')); +#endif + std::printf("%s (%u WPM, %lu ms polling): capture=\"%s\", stuck=%u, noise=%.1f, unit=%u\n", + label,unsigned(wpm),(unsigned long)cadence,rx.text(),unsigned(stuck),diagnostics.noise_dbm,unsigned(diagnostics.unit_ms)); +} +int main() { + receive("unchanged floor / strong",-127.5f,-34.5f,false); + receive("floor step / strong",-104.0f,-34.5f,false); + receive("floor step / moderate",-104.0f,-64.0f,false); + receive("floor step / weak6dB",-104.0f,-98.0f,false); + receive("30dB floor step / weak6dB",-97.5f,-91.5f,false); + receive("unchanged floor / fading",-127.5f,-34.5f,true); + receive("floor step / fading",-104.0f,-34.5f,true); +#ifdef MORSE_ESTIMATOR_EXTENDED + for (uint8_t speed : {1,25,60,120,240}) for (uint32_t cadence : {1,2}) { + receive("floor step / strong",-104.0f,-34.5f,false,speed,cadence); + receive("floor step / moderate",-104.0f,-64.0f,false,speed,cadence); + receive("floor step / weak6dB",-104.0f,-98.0f,false,speed,cadence); + receive("floor step / fading",-104.0f,-34.5f,true,speed,cadence); + receive("floor step / 3dB jitter",-104.0f,-34.5f,false,speed,cadence,true); + receive("unchanged floor / weak7dB",-127.5f,-120.5f,false,speed,cadence); + receive("floor step / 1ms quiet spikes",-104.0f,-34.5f,false,speed,cadence,false,true); + } +#endif +} diff --git a/test/fixtures/morse_rx_hil_driver.cpp b/test/fixtures/morse_rx_hil_driver.cpp new file mode 100644 index 000000000..a16b70a04 --- /dev/null +++ b/test/fixtures/morse_rx_hil_driver.cpp @@ -0,0 +1,32 @@ +int main() { + for (unsigned failure : {1,2}) { + RadioLibWrapper driver; nothrow_calls=0; fail_nothrow_call=failure; + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Failed); + assert(!driver._morse_rx && !driver._morse_trace); + assert(!driver.chip.standby_calls && !driver.board.before && !driver.chip.starts); + assert(!driver._cw_active && !driver.board.awake); + } + fail_nothrow_call=0; + RadioLibWrapper driver; g_mock_millis=0xfffffff0U; + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Started); + assert(driver._morse_trace && !driver.morseTraceLength()); + receive_e(driver); driver.stopMorseRx(); packet_receive_restored(driver); + assert(driver.morseTraceLength()==2 && driver.morseTraceTotal()==2); + mesh::MorseRssiTraceEntry original; + assert(driver.readMorseTrace(0,original) && original.millis<1000 && (original.flags&2)); + const auto capture=std::string(driver.morseCaptureText()); + for (int reason : {0,1,2}) { + if (reason==0) driver.receiver_allocation_ok=false; + if (reason==1) driver.board.frequency_ok=false; + if (reason==2) driver.rssi=NAN; + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Failed); + mesh::MorseRssiTraceEntry retained; + assert(driver.morseCaptureText()==capture && driver.morseTraceLength()==2); + assert(driver.readMorseTrace(0,retained) && retained.millis==original.millis && retained.flags==original.flags); + driver.receiver_allocation_ok=true; driver.board.frequency_ok=true; driver.rssi=-110; + } + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Started); + assert(driver.morseCaptureLength()==0 && driver.morseTraceLength()==0); + driver.stopMorseRx(); driver.clearMorseCapture(); + assert(!driver._morse_rx && !driver._morse_trace && !driver.morseTraceLength()); +} diff --git a/test/fixtures/morse_rx_mark_fade.cpp b/test/fixtures/morse_rx_mark_fade.cpp new file mode 100644 index 000000000..0c0b658eb --- /dev/null +++ b/test/fixtures/morse_rx_mark_fade.cpp @@ -0,0 +1,78 @@ +// Read-only estimator experiment: strong carrier variation versus a new floor. +#include +#include +#include +#include +#include + +static void trial(const char* label,float signal,float quiet,bool vary_mark,bool vary_quiet,uint8_t wpm,uint32_t cadence=2,float initial=-127.5f) { + mesh::MorseRx rx; uint32_t now=100, cuts=0, false_marks=0; + rx.start(wpm,300000,now); + const uint32_t unit=mesh::MorseTx::unitMillis(wpm); + const auto level=[&](float mean,uint32_t duration,bool expected_mark) { + for(uint32_t elapsed=0;elapsed=8) { + // Hold each fade long enough to be a real carrier-envelope change, + // rather than an isolated sub-debounce spike. + static const int excursion[]={0,-12,12,-6,6}; + input+=excursion[(elapsed/8)%5]; + } else if(!expected_mark && vary_quiet && mean==quiet) { + static const int excursion[]={-3,-2,-1,0,1,2,3,2,1,0,-1,-2}; + input+=excursion[(elapsed/2)%12]; + } + const bool before=rx.keyed(); rx.sample(input,now); + if(expected_mark && before && !rx.keyed() && elapsed>=8) ++cuts; + if(!expected_mark && !before && rx.keyed()) ++false_marks; + } + }; + level(initial,520,false); + const char* text="SOS TEST 25"; + for(size_t letter=0;text[letter];++letter) { + if(text[letter]==' ') continue; + const uint8_t code=mesh::MorseTx::codeFor(text[letter]); + for(uint8_t bit=mesh::MorseTx::symbolCount(code);bit;--bit) { + level(signal,unit*(code&(1U<<(bit-1))?3:1),true); + const unsigned gap=bit>1?1:(text[letter+1]==' '||!text[letter+1]?7:3); + level(quiet,unit*gap,false); + } + } + const bool stuck=rx.keyed(); rx.stop(now); + const bool correct=strcmp(rx.text(),text)==0 || (rx.length()==strlen(text)+1 && !strncmp(rx.text(),text,strlen(text))); +#ifdef MORSE_MARK_FADE_REQUIRE_PASS + if(signal-quiet>=6.0f) { + assert(correct && !stuck && !false_marks); + // The first provisional mark in this deliberately ambiguous33dB-SNR + // envelope can include one discarded sub-dot edge at1/60WPM. Pin that + // precise exception; no later mark or other waveform may silently cut. + if(cuts) assert(!strcmp(label,"stable33SNR/12dBfade") && cuts==1 && (wpm==1 || wpm==60)); + } +#endif + std::printf("%s %uWPM/%lums: required=%u correct=%u cuts=%lu false_marks=%lu stuck=%u text=\"%s\"\n", + label,unsigned(wpm),(unsigned long)cadence,unsigned(signal-quiet>=6.0f),unsigned(correct), + (unsigned long)cuts,(unsigned long)false_marks,unsigned(stuck),rx.text()); +} +int main() { + for(uint8_t wpm:{25,60}) { + trial("strong12dB/noisyfloor",-23,-103,true,true,wpm); + trial("strong12dB/floorstep",-23,-100,true,true,wpm); + trial("strongsteady/noisyfloor",-23,-103,false,true,wpm); + trial("weak6dB/floorstep",-94,-100,false,false,wpm); + } +#ifdef MORSE_HYBRID_MATRIX + for(uint8_t wpm:{1,25,60,120,240}) for(uint32_t cadence:{1,2}) { + trial("strong12dB/noisyfloor",-23,-103,true,true,wpm,cadence); + trial("strong12dB/floorstep30",-23,-97.5f,true,true,wpm,cadence); + trial("weak6dB/step23.5",-98,-104,false,false,wpm,cadence); + trial("weak6dB/step30",-91.5f,-97.5f,false,false,wpm,cadence); + trial("weak7dB/stable",-120.5f,-127.5f,false,false,wpm,cadence); + trial("stable33SNR/12dBfade",-23,-56,true,false,wpm,cadence,-56); + for(unsigned span:{30,35,39,40,41,45,50}) { + char label[48]; std::snprintf(label,sizeof(label),"initialSpan%u/step23.5",span); + trial(label,-127.5f+span,-104,false,false,wpm,cadence); + std::snprintf(label,sizeof(label),"initialSpan%u/step30",span); + trial(label,-127.5f+span,-97.5f,false,false,wpm,cadence); + } + } +#endif +} diff --git a/test/fixtures/morse_rx_native.cpp b/test/fixtures/morse_rx_native.cpp new file mode 100644 index 000000000..227ece498 --- /dev/null +++ b/test/fixtures/morse_rx_native.cpp @@ -0,0 +1,219 @@ +#include +#include +#include +#include +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_UNKNOWN -16 +#define RADIOLIB_NC 0xffffffffU +#define RFSWITCH_PIN_FLAG 0x80U +#define RADIOLIB_LRXXXX_DIOx_VAL(pin) ((pin)&0x7fU) +#define RADIOLIB_LR11X0_RX_TIMEOUT_INF 0xffffffU +#define RADIOLIB_LR2021_RX_TIMEOUT_INF 0xffffffU +#define RADIOLIB_LR2021_DIO_SLEEP_PULL_UP 1 +#define RADIOLIB_LR2021_DIO_SLEEP_PULL_AUTO 0 +#define RADIOLIB_LR2021_DIO_FUNCTION_RF_SWITCH 6 +#define RADIOLIB_SX126X_REG_RSSI_AVG_WINDOW 0x089b +#define SX127X_CURRENT_LIMIT 120 +#define SX126X_CURRENT_LIMIT 120 +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_RXEN 8 +#define SX126X_TXEN 9 +struct Hal { int GpioLevelHigh=1; }; +struct Module { + static constexpr size_t RFSWITCH_MAX_PINS=3; + struct RfSwitchMode_t { uint8_t mode; int values[RFSWITCH_MAX_PINS]; }; + Hal storage; Hal* hal=&storage; + int write_result=0; unsigned writes=0; + int16_t SPIsetRegValue(int address,int value,int msb,int lsb) { + constexpr int expected_window = +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_RSSI_WINDOW) + MESH_MORSE_HIL_RSSI_WINDOW << 2; +#else + 0; +#endif + assert(address==RADIOLIB_SX126X_REG_RSSI_AVG_WINDOW && value==expected_window && msb==4 && lsb==2); + ++writes; return write_result; + } +}; +struct LR11x0 { enum { MODE_STBY=1,MODE_RX=3,MODE_TX=4,MODE_END_OF_TABLE=0 }; }; +#include +#include +struct Native; +using SX126x=Native; +inline int16_t setSX126xMorseRssiWindow(SX126x*); +struct Native { + Module module; Module* mod=&module; + float tcxoVoltage=2.7f, frequency=0, bitrate=0,deviation=0,bandwidth=0; + bool useRegulatorLDO=true,standbyXOSC=true,seen_standby_xosc=true,seen_ldo=false; + bool rxBoostedGainMode=true; + int power=0,preamble=0,fail=0; bool receiving=false; + unsigned tx=0; int gain=-1,rssi_samples=-1; + uint8_t syncWordLength=16,preambleDetLength=4; + int crc_result=0,sync_result=0,gain_result=0; + unsigned crc_calls=0,sync_calls=0; + uint8_t enable=0,modes[7]={}; + std::vector calls; + int16_t call(int step) { calls.push_back(step); return step==fail ? -705 : 0; } + Module* getMod() { return mod; } + int16_t beginGFSK(float freq,float br,float dev,float bw,int8_t dbm,uint16_t pre,float tcxo) { + assert(tcxo==tcxoVoltage); + frequency=freq; bitrate=br; deviation=dev; bandwidth=bw; power=dbm; preamble=pre; + enable=0; memset(modes,0,sizeof(modes)); + rxBoostedGainMode=true; // native modem reset restores its own default + return call(1); + } + int16_t beginFSK(float freq,float br,float dev,float bw,int8_t dbm,uint16_t pre,bool ook) { + assert(!ook); return beginGFSK(freq,br,dev,bw,dbm,pre,tcxoVoltage); + } + int16_t beginFSK(float freq,float br,float dev,float bw,int8_t dbm,uint16_t pre,float tcxo,bool ldo) { + seen_standby_xosc=standbyXOSC; seen_ldo=ldo; + return beginGFSK(freq,br,dev,bw,dbm,pre,tcxo); + } + int16_t applyMeshCoreTcxoDelay() { return call(2); } + int16_t setRegulatorDCDC() { return call(3); } + int16_t setDioAsRfSwitch(uint8_t mask,uint8_t a,uint8_t b,uint8_t c,uint8_t d,uint8_t e,uint8_t f,uint8_t g) { + enable=mask; const uint8_t values[]={a,b,c,d,e,f,g}; memcpy(modes,values,sizeof(modes)); + return call(4); + } + int16_t setRxBoostedGainMode(uint8_t value) { + gain=value; rxBoostedGainMode=value; + const auto status=call(5); return gain_result ? gain_result : status; + } + int16_t startReceive(uint32_t timeout,uint32_t a,uint32_t b,size_t c) { + assert(timeout==0xffffffU && !a && !b && !c); + const auto result=call(6); receiving=result==0; return result; + } + int16_t setCurrentLimit(int value) { assert(value==120); return call(2); } + int16_t setDio2AsRfSwitch(bool enabled) { assert(enabled); return call(3); } + void setRfSwitchPins(int rx,int txpin) { assert(rx==8 && txpin==9); } + int16_t setSyncWord(uint8_t*,size_t length) { + ++sync_calls; + assert(!length); syncWordLength=preambleDetLength=0; + return sync_result ? sync_result : call(4); + } + int16_t setCRC(uint8_t value) { ++crc_calls; assert(!value); return crc_result ? crc_result : call(5); } + int16_t fixedPacketLengthMode(uint8_t length) { assert(length==255); return call(9); } + @FAST_RSSI@ + int16_t startReceive() { const auto result=call(7); receiving=result==0; return result; } + int16_t setRSSIConfig(uint8_t samples) { rssi_samples=samples; return call(6); } + int16_t receiveDirect() { const auto result=call(7); receiving=result==0; return result; } + int16_t setDioFunction(uint8_t dio,uint8_t function,uint8_t pull) { + assert(dio>=5 && function==6 && (pull==0 || pull==1)); return call(4); + } + int16_t setDioRfSwitchConfig(uint8_t dio,uint8_t mask) { + assert(dio>=5); enable|=1U << (dio-5); modes[dio-5]=mask; return call(4); + } +}; +#include +using LR1110=Native; using LR1121=Native; +struct LR2021 : Native { enum { MODE_RX=3,MODE_RX_HF=6,MODE_END_OF_TABLE=0 }; }; +using SX1276=Native; +struct LR1110Probe : LR1110 { + struct State { unsigned invalidations=0; void invalidate() { ++invalidations; } } _profileSwitch; + bool _profilePacketDirty=true,_rx_boosted=true; + LR11x0MorseRfSwitch _morse_rf_switch; + @LR1110@ +}; +struct LR1121Probe : LR1121 { + bool _rx_boosted=true; + LR11x0MorseRfSwitch _morse_rf_switch; + @LR1121@ +}; +struct LR2021Probe : LR2021 { + uint8_t gainModeLf=1,gainModeHf=3; + uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS]={0x80,0x81,RADIOLIB_NC}; + const Module::RfSwitchMode_t* _rf_switch_table=nullptr; + @LR2021_RESTORE@ + @LR2021@ +}; +struct SX1276Probe : SX1276 { @SX1276@ }; +static const uint32_t pins[]={0x80,0x81,7}; +static const Module::RfSwitchMode_t table[]={ + {LR11x0::MODE_TX,{0,1,1}}, {LR11x0::MODE_RX,{1,0,0}}, + {LR11x0::MODE_STBY,{0,0,0}}, {LR11x0::MODE_END_OF_TABLE,{0,0,0}}}; +template static void lr_native() { + for (int fault=0; fault<=5; ++fault) { + T radio; radio.fail=fault; + radio._morse_rf_switch.capture(radio.mod,pins,table); + assert(radio.beginMorseReceive(902.1f)==(fault ? -705 : 0)); + assert(radio.frequency==902.1f && radio.bitrate==mesh::MorseRx::ReceiverBitRateKbps && radio.deviation==0.6f); + assert(radio.bandwidth==4.8f && radio.power==2 && radio.preamble==16 && !radio.tx); + if (!fault) { + assert(!radio.receiving && radio.gain==1 && radio.enable==3); + assert(!radio.syncWordLength && !radio.preambleDetLength); + assert(radio.modes[LR11x0::MODE_RX-LR11x0::MODE_STBY]==1); + assert(radio.modes[LR11x0::MODE_TX-LR11x0::MODE_STBY]==2); + } else assert(!radio.receiving); + // LR1121 has no TCXO-delay phase; its step2 injection is skipped. + } +} +int main() { + // Every checked SPI setup failure prevents receive admission and no path TXs. + for (int fault : {0,1,3,4,5}) { + LR1121Probe radio; radio.fail=fault; + radio._morse_rf_switch.capture(radio.mod,pins,table); + assert(radio.beginMorseReceive(902.1f)==(fault ? -705 : 0)); + assert(radio.bitrate==mesh::MorseRx::ReceiverBitRateKbps); + assert(!radio.tx && !radio.receiving); + if (!fault) assert(radio.enable==3 && radio.modes[2]==1 && radio.modes[3]==2); + } + lr_native(); + LR1110Probe crc11; crc11.crc_result=-705; + assert(crc11.beginMorseReceive(902.1f)==-705 && !crc11.receiving); + LR1121Probe crc12; crc12.crc_result=-705; + assert(crc12.beginMorseReceive(902.1f)==-705 && !crc12.receiving); + LR1110Probe cache; cache.beginMorseReceive(902.1f); + assert(!cache._profilePacketDirty && cache._profileSwitch.invalidations==1); + for (float freq : {902.1f,2440.0f}) for (int fault : {0,1,4,5}) { + LR2021Probe radio; radio.fail=fault; radio._rf_switch_table=table; + assert(radio.beginMorseReceive(freq)==(fault ? -705 : 0)); + assert(radio.bitrate==mesh::MorseRx::ReceiverBitRateKbps); + assert(radio.bandwidth==4.8f && !radio.receiving && !radio.tx); + if (!fault) assert(radio.gain==(freq>1900 ? 3 : 1) && !radio.syncWordLength && !radio.preambleDetLength); + } + for (bool sync : {false,true}) { + LR2021Probe failed; + if (sync) failed.sync_result=-705; else failed.crc_result=-705; + assert(failed.beginMorseReceive(902.1f)==-705 && !failed.receiving); + } + for (int fault : {0,1,2,6}) { + SX1276Probe radio; radio.fail=fault; + assert(radio.beginMorseReceive(902.1f)==(fault ? -705 : 0)); + assert(radio.bitrate==mesh::MorseRx::ReceiverBitRateKbps); + assert(radio.bandwidth==2.6f && !radio.receiving && !radio.tx); + if (!fault) assert(radio.rssi_samples==0); + } + for (bool gain : {false,true}) for (int fault : {0,1,2,3,4,5,8,9}) { + Native radio; radio.fail=fault==8 ? 0 : fault; + radio.rxBoostedGainMode=gain; + if (fault==8) radio.module.write_result=-705; + constexpr bool packet_gating= +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL && defined(MESH_MORSE_HIL_PACKET_GATING) && MESH_MORSE_HIL_PACKET_GATING + true; +#else + false; +#endif + const bool failure=fault && !(packet_gating && fault==4); + assert(prepareSX126xMorseReceiver(&radio,902.1f)==(failure ? -705 : 0)); + assert(!radio.seen_standby_xosc && radio.standbyXOSC && radio.seen_ldo); + assert(radio.bandwidth==4.8f && radio.bitrate==mesh::MorseRx::ReceiverBitRateKbps && radio.deviation==0.6f && !radio.tx); + assert(!radio.receiving); + if (!failure) { + assert(radio.module.writes==1 && radio.rxBoostedGainMode==gain && radio.gain==int(gain)); + assert(radio.sync_calls==unsigned(!packet_gating) && radio.crc_calls==unsigned(!packet_gating)); + assert(radio.syncWordLength==(packet_gating?16:0) && radio.preambleDetLength==(packet_gating?4:0)); + assert(radio.calls.back()==5); // native gain restoration is last, still before wrapper SetRX + } + } + for(bool gain : {false,true}) { + Native radio; radio.rxBoostedGainMode=gain; radio.gain_result=-705; + assert(prepareSX126xMorseReceiver(&radio,902.1f)==-705); + assert(radio.gain==int(gain) && radio.module.writes==1 && radio.calls.back()==5); + assert(!radio.receiving && !radio.tx); + } + LR11x0MorseRfSwitch unconfigured; + assert(unconfigured.restore([](uint8_t,const uint8_t*)->int16_t { assert(false); return -705; })==0); +} diff --git a/test/fixtures/morse_rx_reuse_admission.cpp b/test/fixtures/morse_rx_reuse_admission.cpp new file mode 100644 index 000000000..0e9128439 --- /dev/null +++ b/test/fixtures/morse_rx_reuse_admission.cpp @@ -0,0 +1,40 @@ +int main() { + advance(0); + total_free=65536; largest_free=65536; + RadioLibWrapper driver; + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Started); + receive_e(driver); assert(driver.stopMorseRx()==RXResult::Stopped); + const auto capture=std::string(driver.morseCaptureText()); + auto* receiver=driver._morse_rx; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + auto* trace=driver._morse_trace; + const auto trace_length=driver.morseTraceLength(); + mesh::MorseRssiTraceEntry first; + assert(trace && trace_length && driver.readMorseTrace(0,first)); +#endif + const auto generation=driver.morseCaptureGeneration(),standbys=driver.chip.standby_calls; + total_free=4095; largest_free=0; largest_queries=0; + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Failed); + assert(driver.morseCaptureText()==capture && driver._morse_rx==receiver); + assert(driver.morseCaptureGeneration()==generation && driver.chip.standby_calls==standbys && !largest_queries); + total_free=4096; + driver.chip.rx_result=-705; driver.fail_tune=true; + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Failed); + assert(driver.morseCaptureText()==capture && driver._morse_rx==receiver && !largest_queries); + assert(driver._cw_active && driver._cw_stopping && driver.board.awake); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + mesh::MorseRssiTraceEntry retained; + assert(driver._morse_trace==trace && driver.morseTraceLength()==trace_length && driver.readMorseTrace(0,retained)); + assert(retained.millis==first.millis && retained.flags==first.flags); +#endif + driver.fail_tune=false; driver.chip.rx_result=0; + advance(100); driver.serviceCarrierWave(); packet_receive_restored(driver); + assert(driver.morseCaptureText()==capture && driver.morseCaptureGeneration()==generation); + assert(driver.startMorseRx(902.1f,25,60000)==RXResult::Started); + assert(driver._morse_rx==receiver && driver.morseCaptureGeneration()==generation+1 && !driver.morseCaptureLength()); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + assert(driver._morse_trace==trace && !driver.morseTraceLength()); +#endif + assert(!largest_queries); // existing buffers do not need any new contiguous block + assert(driver.stopMorseRx()==RXResult::Stopped); +} diff --git a/test/fixtures/morse_rx_sample_cadence.cpp b/test/fixtures/morse_rx_sample_cadence.cpp new file mode 100644 index 000000000..422cb3101 --- /dev/null +++ b/test/fixtures/morse_rx_sample_cadence.cpp @@ -0,0 +1,25 @@ +int main() { + static_assert(mesh::MorseRx::SampleIntervalMillis==EXPECTED_SAMPLE_MS,"HIL cadence must not leak into production"); + constexpr uint32_t interval=mesh::MorseRx::SampleIntervalMillis; + for(uint32_t started : {100U,0xfffffff8U}) { + RadioLibWrapper w; g_mock_millis=started; + assert(w.startMorseRx(902.1f,25,60000)==mesh::MorseRxResult::Started); + assert(w.rssi_reads==1 && w._morse_rx_next_sample==uint32_t(started+interval)); + advance(interval-1); w.serviceCarrierWave(); assert(w.rssi_reads==1); + advance(1); w.serviceCarrierWave(); assert(w.rssi_reads==2); + const auto reads=w.rssi_reads; + for(unsigned call=0;call<100;++call) w.serviceCarrierWave(); + assert(w.rssi_reads==reads); // never catch up/burst merely by foreground calls + w.rssi_latency=3; advance(interval); w.serviceCarrierWave(); + assert(w.rssi_reads==reads+1 && w._morse_rx_next_sample==uint32_t(g_mock_millis+interval)); + advance(interval-1); w.serviceCarrierWave(); assert(w.rssi_reads==reads+1); + advance(1); w.serviceCarrierWave(); assert(w.rssi_reads==reads+2); + w.rssi_latency=0; advance(100); w.serviceCarrierWave(); + const auto stalled=w.rssi_reads; + for(unsigned call=0;call<100;++call) w.serviceCarrierWave(); + assert(w.rssi_reads==stalled); + mesh::MorseRxDiagnostics stats; + assert(w.morseRxDiagnostics(stats) && stats.max_sample_gap_ms>=100); + assert(w.stopMorseRx()==mesh::MorseRxResult::Stopped && w.isInRecvMode()); + } +} diff --git a/test/fixtures/morse_rx_terminal.cpp b/test/fixtures/morse_rx_terminal.cpp new file mode 100644 index 000000000..d029e8598 --- /dev/null +++ b/test/fixtures/morse_rx_terminal.cpp @@ -0,0 +1,137 @@ +#include +#include +#include +#include +#include +#include + +struct Stream { + std::string output; + int available_write = 128; + int input_bytes = 0; + size_t write_limit = 128; + unsigned writes = 0; + int available() const { return input_bytes; } + int availableForWrite() const { return available_write; } + size_t write(const uint8_t* data, size_t amount) { + ++writes; + const size_t count = std::min(amount, write_limit); + output.append(reinterpret_cast(data), count); return count; + } +}; +namespace mesh { +Stream usb_terminal; +Stream& usbTerminalPort() { return usb_terminal; } +bool hasPendingUsbTerminalOutput() { return false; } +} +struct Radio { + bool receiving = true; + uint32_t generation = 1; + std::string capture; + bool isMorseRxActive() const { return receiving; } + uint32_t morseCaptureGeneration() const { return generation; } + size_t morseCaptureLength() const { return capture.size(); } + size_t readMorseCapture(size_t offset, char* out, size_t capacity) const { + const size_t count = offset < capture.size() ? std::min(capture.size() - offset, capacity - 1) : 0; + memcpy(out, capture.data() + std::min(offset, capture.size()), count); + out[count] = 0; return count; + } +}; +struct Clock { uint32_t now = 100; uint32_t getMillis() const { return now; } }; +struct MyMesh { + Radio radio; + Radio* _radio = &radio; + Clock clock; + Clock* _ms = &clock; + Stream* _terminal_output = nullptr; + bool waiting_for_input = false; + bool _terminal_morse_atomic = true, _terminal_input_editing = false, _terminal_mode = false; + bool _terminal_login_pending = false, _terminal_trace_pending = false; + struct Command { bool isPending() const { return false; } } _terminal_command; + Stream* _morse_terminal_owner = nullptr; + uint32_t _morse_terminal_generation = 0, _morse_terminal_next_at = 0; + uint16_t _morse_terminal_offset = 0; + uint8_t _morse_terminal_pending_chars = 0; + bool _morse_terminal_was_receiving = false; + bool hasTerminalOutput() const { return _terminal_output != nullptr; } + bool isTerminalWaitingForInput() const { return waiting_for_input; } + void serviceMorseTerminal(); +}; +@TERMINAL@ + +static void drain(MyMesh& m) { + unsigned loops = 0; + do { + m.serviceMorseTerminal(); m.clock.now += 250; + assert(++loops < 1000); + } while (m._morse_terminal_offset < m.radio.capture.size() || m._morse_terminal_pending_chars); +} +static size_t occurrences(const std::string& haystack, const std::string& needle) { + size_t count = 0, from = 0; + while ((from = haystack.find(needle, from)) != std::string::npos) { ++count; from += needle.size(); } + return count; +} +int main() { + { + MyMesh m; Stream terminal, binary; + m.radio.capture = "SOS"; + m.serviceMorseTerminal(); + assert(terminal.output.empty() && binary.output.empty()); // no fallback or binary frame writes + m._terminal_output = &terminal; + terminal.available_write = 0; m.serviceMorseTerminal(); + assert(terminal.output.empty() && m._morse_terminal_pending_chars == 3); + terminal.available_write = 128; + drain(m); + assert(terminal.output == "\r\n[Morse RX] SOS\r\n> " && m._morse_terminal_offset == 3); + const auto previous = terminal.output; + for (int i = 0; i < 100; ++i) m.serviceMorseTerminal(); + assert(terminal.output == previous); + m.radio.capture += " HELP"; m.radio.receiving = false; + drain(m); + assert(terminal.output == previous + "\r\n[Morse RX] HELP\r\n> "); + assert(binary.output.empty()); + } + { + MyMesh m; Stream terminal; m._terminal_output = &terminal; + m.radio.capture = "HELLO"; drain(m); + assert(occurrences(terminal.output, "HELLO") == 1); + m.waiting_for_input = true; m.radio.capture += " WORLD"; + const auto before = terminal.output; + for (int i = 0; i < 10; ++i) { m.clock.now += 100; m.serviceMorseTerminal(); } + assert(terminal.output == before && m._morse_terminal_offset == 5); + m.waiting_for_input = false; drain(m); + assert(occurrences(terminal.output, "HELLO") == 1 && occurrences(terminal.output, "WORLD") == 1); + } + { + MyMesh m; Stream terminal; m._terminal_output = &terminal; + terminal.available_write = 0; m.radio.capture = "OLD"; m.serviceMorseTerminal(); + assert(m._morse_terminal_pending_chars == 3); + ++m.radio.generation; m.radio.capture = "NEW"; terminal.available_write = 128; + drain(m); + assert(terminal.output == "\r\n[Morse RX] NEW\r\n> "); + } + { + MyMesh m; Stream first, second; m._terminal_output = &first; + first.write_limit = 3; m.radio.capture = "SESSION"; m.serviceMorseTerminal(); + const auto partial = first.output; + assert(partial == "\r\n["); + assert(!m._terminal_morse_atomic); // a raw stream violating admission fails closed + m._terminal_output = &second; m._terminal_morse_atomic = true; drain(m); + assert(first.output == partial && second.output == "\r\n[Morse RX] SESSION\r\n> "); + } + { + MyMesh m; Stream terminal; m._terminal_output = &terminal; + m.radio.capture = std::string(125, 'E'); + drain(m); + assert(occurrences(terminal.output, "[Morse RX]") == 3); + const auto before = terminal.output; + m._terminal_output = nullptr; m.serviceMorseTerminal(); + assert(terminal.output == before && !m._morse_terminal_owner && !m._morse_terminal_pending_chars); + } + { + MyMesh m; Stream terminal; m._terminal_output = &terminal; + m.clock.now = 0xfffffff0; m.radio.capture = "E"; drain(m); + m.radio.capture += "T"; drain(m); + assert(occurrences(terminal.output, "[Morse RX]") == 2); + } +} diff --git a/test/fixtures/morse_settings.cpp b/test/fixtures/morse_settings.cpp new file mode 100644 index 000000000..3c096f87e --- /dev/null +++ b/test/fixtures/morse_settings.cpp @@ -0,0 +1,208 @@ +#include +#include +#include +#include +#include +#include + +using mesh::MorseSettings; +static constexpr const char* Path = "/morse_prefs"; +static constexpr const char* Backup = "/morse_prefs.bak"; + +static MemoryFS filesystem() { + MemoryFS fs; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.rename_replaces = true; +#endif + return fs; +} + +static std::vector image(const MorseSettings& prefs) { + uint8_t bytes[mesh::MorseSettingsImageSize]; + mesh::encodeMorseSettings(prefs, bytes); + return {bytes, bytes + sizeof(bytes)}; +} + +static MorseSettings custom() { + MorseSettings prefs; + prefs.frequency_khz = 902321; prefs.wpm = 60; + return prefs; +} + +static void defaults() { + assert(mesh::morseSettings().frequency_khz == 902100 && mesh::morseSettings().wpm == 25); +} + +static void equals(const MorseSettings& actual, const MorseSettings& expected) { + assert(actual.frequency_khz == expected.frequency_khz && actual.wpm == expected.wpm); +} + +static void codec() { + MorseSettings prefs; + assert(mesh::MorseSettingsImageSize == 12 && prefs.frequencyMHz() == 902.100f); + const uint8_t expected[] = {@DEFAULT_IMAGE@}; + assert(image(prefs) == std::vector(expected, expected + sizeof(expected))); + const auto bytes = image(custom()); + MorseSettings decoded; + assert(mesh::decodeMorseSettings(bytes.data(), bytes.size(), decoded)); + equals(decoded, custom()); + for (size_t length = 0; length < mesh::MorseSettingsImageSize; ++length) { + assert(!mesh::decodeMorseSettings(bytes.data(), length, decoded)); + equals(decoded, custom()); + } + assert(!mesh::decodeMorseSettings(nullptr, bytes.size(), decoded)); + assert(!mesh::decodeMorseSettings(bytes.data(), bytes.size() + 1, decoded)); + for (size_t offset = 0; offset < bytes.size(); ++offset) { + for (unsigned bit = 0; bit < 8; ++bit) { + auto corrupt = bytes; corrupt[offset] ^= 1U << bit; + assert(!mesh::decodeMorseSettings(corrupt.data(), corrupt.size(), decoded)); + equals(decoded, custom()); + } + } + for (int reason = 0; reason < 4; ++reason) { + auto invalid = prefs; + if (reason == 0) invalid.wpm = 0; + if (reason == 1) invalid.wpm = uint8_t(mesh::MorseMaxWpm + 1); + if (reason == 2) invalid.frequency_khz = mesh::MorseMinFrequencyKHz - 1; + if (reason == 3) invalid.frequency_khz = mesh::MorseMaxFrequencyKHz + 1; + const auto checked = image(invalid); // valid checksum does not waive range checks + assert(!mesh::decodeMorseSettings(checked.data(), checked.size(), decoded)); + equals(decoded, custom()); + } + auto unsupported = bytes; unsupported[2] = 2; + const auto crc = mesh::morseSettingsCRC(unsupported.data(), 8); + for (unsigned i = 0; i < 4; ++i) unsupported[8 + i] = uint8_t(crc >> (8 * i)); + assert(!mesh::decodeMorseSettings(unsupported.data(), unsupported.size(), decoded)); +} + +static void load_and_roundtrip() { + assert(!mesh::loadMorseSettings(nullptr)); defaults(); assert(!mesh::morseSettingsUsable()); + assert(!mesh::saveMorseSettings(custom())); + auto fs = filesystem(); + fs.files["/identity"] = {1, 2, 3, 4}; + fs.files["/com_prefs"] = {5, 6, 7, 8}; + fs.files["/acl"] = {9, 10}; + const auto unrelated = fs.files; + assert(!mesh::loadMorseSettings(&fs)); defaults(); assert(mesh::morseSettingsUsable()); + for (uint8_t speed : {1, 25, 60}) { + for (uint32_t frequency : {100000, 902100, 1000000}) { + MorseSettings prefs; prefs.wpm = speed; prefs.frequency_khz = frequency; + assert(mesh::saveMorseSettings(prefs)); + assert(mesh::morseSettingsUsable()); + equals(mesh::morseSettings(), prefs); + assert(fs.files.at(Path) == image(prefs)); + assert(mesh::loadMorseSettings(&fs)); equals(mesh::morseSettings(), prefs); + for (const auto& entry : unrelated) assert(fs.files.at(entry.first) == entry.second); + assert(!fs.exists("/morse_prefs.tmp")); + } + } + fs.files.erase(Path); + assert(!mesh::loadMorseSettings(&fs)); defaults(); // never reuse previous loaded values + assert(mesh::saveMorseSettings(custom())); + for (int reason = 0; reason < 4; ++reason) { + auto invalid = custom(); + if (reason == 0) invalid.wpm = 0; + if (reason == 1) invalid.wpm = uint8_t(mesh::MorseMaxWpm + 1); + if (reason == 2) invalid.frequency_khz = 99999; + if (reason == 3) invalid.frequency_khz = 1000001; + const auto before = fs.files; + assert(!mesh::saveMorseSettings(invalid)); + equals(mesh::morseSettings(), custom()); assert(fs.files == before); + } +} + +static void malformed_and_read_failures() { + const auto good = image(custom()); + for (size_t length = 0; length < good.size(); ++length) { + auto fs = filesystem(); + fs.files[Path] = {good.begin(), good.begin() + length}; + assert(!mesh::loadMorseSettings(&fs)); defaults(); assert(!mesh::morseSettingsUsable()); + } + for (size_t offset = 0; offset < good.size(); ++offset) { + auto fs = filesystem(); fs.files[Path] = good; fs.files[Path][offset] ^= 1; + assert(!mesh::loadMorseSettings(&fs)); defaults(); assert(!mesh::morseSettingsUsable()); + } + for (int fault = 0; fault < 3; ++fault) { + for (int position = 0; position < 12; ++position) { + auto fs = filesystem(); fs.files[Path] = good; + if (fault == 0) fs.fail_read_open = true; + if (fault == 1) fs.fail_read_after = position; + if (fault == 2) fs.fail_presence = true; + assert(!mesh::loadMorseSettings(&fs)); defaults(); + assert(!mesh::morseSettingsUsable()); + const auto before = fs.files; + assert(!mesh::saveMorseSettings(MorseSettings())); // unreadable image never overwritten + assert(fs.files == before); + fs.fail_read_open = false; fs.fail_read_after = -1; fs.fail_presence = false; + assert(!mesh::morseSettingsUsable()); // elapsed time alone does not clear error + assert(fs.files == before); + assert(mesh::saveMorseSettings(MorseSettings())); // explicit verified retry now permitted + defaults(); assert(mesh::morseSettingsUsable()); + } + } +} + +static void backup_recovery() { + const auto good = image(custom()); + for (bool corrupt_primary : {false, true}) { + auto fs = filesystem(); fs.files[Backup] = good; + if (corrupt_primary) fs.files[Path] = {0, 1, 2}; + fs.files["/morse_prefs.tmp"] = image(MorseSettings()); // unpublished temp never wins + assert(mesh::loadMorseSettings(&fs)); equals(mesh::morseSettings(), custom()); + assert(mesh::morseSettingsUsable()); + assert(fs.files.at(Path) == good && !fs.exists(Backup)); + assert(mesh::saveMorseSettings(MorseSettings())); + } + for (int fault = 0; fault < 3; ++fault) { + auto fs = filesystem(); fs.files[Backup] = good; + fs.files[Path] = fault == 2 ? good : std::vector{1, 2, 3}; + if (fault == 0) fs.fail_remove = true; + if (fault == 1) fs.fail_rename_from = {Backup}; + if (fault == 2) fs.fail_read_paths = {Path}; + assert(mesh::loadMorseSettings(&fs)); equals(mesh::morseSettings(), custom()); + assert(!mesh::morseSettingsUsable()); + assert(fs.files.at(Backup) == good); // sole readable copy protected from next save + const auto before = fs.files; + assert(!mesh::saveMorseSettings(MorseSettings())); + equals(mesh::morseSettings(), custom()); assert(fs.files == before); + fs.fail_remove = false; fs.fail_rename = 0; fs.fail_rename_from.clear(); fs.fail_read_paths.clear(); + assert(!mesh::morseSettingsUsable()); + assert(mesh::saveMorseSettings(MorseSettings())); defaults(); + assert(mesh::morseSettingsUsable()); + } + { + auto fs = filesystem(); fs.files[Path] = good; fs.files[Backup] = {0}; + assert(mesh::loadMorseSettings(&fs)); equals(mesh::morseSettings(), custom()); + assert(fs.files.at(Backup) == std::vector{0}); // valid primary is authoritative + } +} + +static void transaction_failures() { + for (int fault = 0; fault < 6; ++fault) { + for (int position = 0; position < 12; ++position) { + auto fs = filesystem(); fs.files[Path] = image(custom()); + assert(mesh::loadMorseSettings(&fs)); + const auto before = fs.files.at(Path); + if (fault == 0) fs.fail_write = true; + if (fault == 1) fs.fail_write_after = position; + if (fault == 2) fs.fail_read_open = true; + if (fault == 3) fs.fail_read_after = position; + if (fault == 4) fs.fail_rename = 1; + if (fault == 5) fs.fail_rename_from = {"/morse_prefs.tmp"}; + assert(!mesh::saveMorseSettings(MorseSettings())); + assert(!mesh::morseSettingsUsable()); // no uncertain frequency is sent after an error + equals(mesh::morseSettings(), custom()); + assert(fs.files.at(Path) == before); + fs.fail_write = false; fs.fail_write_after = -1; + fs.fail_read_open = false; fs.fail_read_after = -1; + fs.fail_rename = 0; fs.fail_rename_from.clear(); + assert(mesh::loadMorseSettings(&fs)); equals(mesh::morseSettings(), custom()); + assert(mesh::saveMorseSettings(MorseSettings())); defaults(); + } + } +} + +int main() { + codec(); load_and_roundtrip(); malformed_and_read_failures(); + backup_recovery(); transaction_failures(); +} diff --git a/test/fixtures/morse_trace_cli.cpp b/test/fixtures/morse_trace_cli.cpp new file mode 100644 index 000000000..d359bb786 --- /dev/null +++ b/test/fixtures/morse_trace_cli.cpp @@ -0,0 +1,89 @@ +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +struct TraceRadio : TestRadio { + mesh::MorseRssiTrace trace; + bool fail_read=false; + uint32_t total_override=0; + size_t morseTraceLength() const override { return trace.length(); } + uint32_t morseTraceTotal() const override { return total_override ? total_override : trace.total(); } + bool morseTraceTruncated() const override { return trace.truncated(); } + bool readMorseTrace(size_t offset,mesh::MorseRssiTraceEntry& out) const override { + return !fail_read && trace.read(offset,out); + } +}; +static void sparse_ring_and_decoder_hook() { + mesh::MorseRssiTrace trace; + trace.sample(-110,0,8); trace.sample(-110,520,0); trace.sample(-80,521,5); + assert(trace.length()==0); // no learning, idle/noise, or debounce-only history + trace.sample(-80,525,3); trace.sample(-76,527,3); + assert(trace.length()==1); + mesh::MorseRssiTraceEntry row; + assert(trace.read(0,row) && row.millis==525 && row.rssi_dbm==-80 && row.flags==3); + trace.sample(-110,570,0); assert(trace.length()==2); + trace.sample(-110,60570,0); assert(trace.length()==3); // quiet anchor keeps deltas bounded + trace.sample(-110,200000,0); + assert(trace.length()==1 && trace.truncated() && trace.total()==4); + assert(trace.read(0,row) && row.millis==200000); + assert(!trace.read(1,row) && !trace.read(size_t(-1),row)); + trace.clear(); assert(!trace.length() && !trace.total() && !trace.truncated()); + mesh::MorseRx rx; rx.setTrace(&trace); + assert(rx.start(25,60000,100)==mesh::MorseRxResult::Started); + for (uint32_t time=100; time<620; ++time) rx.sample(-110,time); + assert(trace.length()==0); + for (uint32_t time=620; time<668; ++time) rx.sample(-80,time); + for (uint32_t time=668; time<1004; ++time) rx.sample(-110,time); + rx.stop(1004); + assert(trace.length()==2 && rx.text()[0]=='E'); + const auto retained=trace.total(); rx.sample(-80,1005); assert(trace.total()==retained); + assert(rx.start(25,60000,2000)==mesh::MorseRxResult::Started && !trace.length()); +} +#endif +int main() { + char reply[157]={}; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + sparse_ring_and_decoder_hook(); + TraceRadio radio; radio.rx_supported=true; + assert(mesh::handleMorseCommand(&radio,"morse trace",reply,sizeof(reply))); + assert(std::string(reply).find("> 000/000 t=0 n=END x=0 ")==0); + for (size_t entry=0; entry(guarded+3),157)); + assert(!memcmp(guarded,"AB|",3) && guarded[160]==0xa5); + const std::string page(reinterpret_cast(guarded+3)); + assert(page.find("t=522")!=std::string::npos && page.find("x=1")!=std::string::npos); + assert(page.size()<157); + const auto marker=page.find("n="); assert(marker!=std::string::npos); + if (page.substr(marker+2,3)=="END") break; + const auto next=std::stoul(page.substr(marker+2,3)); + assert(next>offset && next<=offset+8); offset=next; + } + for (const char* command : {"morse trace -1","morse trace 513","morse trace 1x","morse trace 4294967296","morse trace 1 2"}) { + assert(mesh::handleMorseCommand(&radio,command,reply,sizeof(reply)) && !strncmp(reply,"Error:",6)); + } + assert(mesh::handleMorseCommand(&radio,"morse trace 512",reply,sizeof(reply)) && strstr(reply,"n=END")); + radio.fail_read=true; + assert(mesh::handleMorseCommand(&radio,"morse trace",reply,sizeof(reply)) && strstr(reply,"trace changed")); + radio.fail_read=false; radio.total_override=0xffffffffU; + assert(mesh::handleMorseCommand(&radio,"morse trace",reply,sizeof(reply)) && strstr(reply,"t=4294967295")); + char small[8]; memset(small,'X',sizeof(small)); + assert(mesh::handleMorseCommand(&radio,"morse trace",small,sizeof(small)) && small[7]==0); + mesh::usable=false; + assert(mesh::handleMorseCommand(&radio,"morse trace",reply,sizeof(reply)) && strncmp(reply,"Error:",6)); + mesh::MorseReplyHandoff handoff; const uint8_t actor[32]={1}; + handoff.beginCommand(actor); assert(handoff.stage(902100,25,60000,100)); handoff.endCommand(); + assert(!mesh::handleMorseReplyCommand(handoff,&radio,"morse\ttrace 0",reply,sizeof(reply),100)); + assert(handoff.pending() && !radio.rx_starts && !radio.starts); +#else + TestRadio radio; radio.rx_supported=true; + assert(mesh::handleMorseCommand(&radio,"morse trace",reply,sizeof(reply)) && !strncmp(reply,"Error:",6)); + assert(!radio.rx_starts && !radio.starts); +#endif +} diff --git a/test/fixtures/remote_cli_command_identity/test.cpp b/test/fixtures/remote_cli_command_identity/test.cpp index 37cbb8c85..025ff999e 100644 --- a/test/fixtures/remote_cli_command_identity/test.cpp +++ b/test/fixtures/remote_cli_command_identity/test.cpp @@ -13,6 +13,7 @@ #include #include #include +#include "production_morse.inc" #undef MESH_DEBUG_PRINTLN #define MESH_DEBUG_PRINTLN(...) ((void)0) @@ -89,6 +90,8 @@ public: void beginReplyCommand() {} void endReplyCommand() {} } profiles; + mesh::MorseReplyHandoff morse; + mesh::MorseReplyHandoff& morseReply() { return morse; } Profiles& radioProfiles() { return profiles; } } _cli; uint32_t primary_radio_mutation_generation = 0, radio_reply_deadline = 0; @@ -118,6 +121,8 @@ public: // exact reply-barrier ownership below remain production code. enum class Mutation { None, Primary, Secondary } accepted_mutation = Mutation::None; bool queue_accepts = true; + bool morse_mode = false; + TestRadio morse_radio; int cancellations = 0; mesh::Packet queued_ack; std::vector replies; @@ -143,6 +148,7 @@ public: } bool sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, uint8_t, uint32_t, const char* reply, const TransportKey*, mesh::Packet** queued = nullptr) { + if (queued && _cli.morseReply().pending()) _cli.morseReply().prepareReply(&queued_ack); if (!queue_accepts) return false; if (queued) *queued = &queued_ack; std::array key{}, shared{}; @@ -166,6 +172,8 @@ public: void handleCommand(uint32_t, ClientInfo* sender, char* command, char* reply, int, uint8_t) { ++executions; #include "normalization.inc" + if (morse_mode && (mesh::handleMorseReplyCommand(_cli.morseReply(), &morse_radio, command, reply, 157, millis()) + || mesh::handleMorseCommand(&morse_radio, command, reply, 157))) return; if (!fail_command) { // Accepted route changes mirror handleClientPathCommand's live ACL edits. if (strcmp(command, "set outpath flood") == 0) sender->out_path_len = 0xfe; @@ -286,7 +294,54 @@ static void hostIdentityLifecycle() { } #endif +static void morseRemoteDispatchCases() { + for (const char* stop : {"morse stop ", "morse stop ", "morse\tstop", "AA|morse\t\tstop\t "}) { + MyMesh value; value.morse_mode=true; value.morse_radio.rx_supported=true; + value.receive("morse listen 1",101,1); + assert(value._cli.morseReply().pending() && value._cli.morseReply().contains(&value.queued_ack)); + assert(value.executions==1 && !value.morse_radio.rx_starts && value.replies.size()==1); + value.receive("morse listen 1",102,1); + assert(value.executions==1 && value.replies.size()==1); // authoritative cached ACK is not duplicated + value.receive("get morse",103,2); + assert(value.replies.back().find("waiting for command reply")!=std::string::npos); + value.receive("morse read",104,3); + assert(value.replies.back().find("> 0000/0000")!=std::string::npos); + value.receive("morse stats",105,4); + assert(value.replies.back().find("no Morse RX statistics")!=std::string::npos); + assert(value._cli.morseReply().pending() && !value.morse_radio.rx_starts); + value.receive(stop,106,5); + assert(!value._cli.morseReply().pending() && !value.morse_radio.rx_starts); + assert(value.replies.back().find("pending Morse RX cancelled")!=std::string::npos); + } + { + MyMesh value; value.morse_mode=true; value.morse_radio.rx_supported=true; + value.receive("morse listen .5",101,1); + mesh::Packet copy; + value._cli.morseReply().copy(&value.queued_ack,©); + value._cli.morseReply().complete(&value.queued_ack); + assert(!value._cli.morseReply().service(&value.morse_radio,millis(),true,true,902100,25)); + value._cli.morseReply().complete(©); + assert(value._cli.morseReply().service(&value.morse_radio,millis(),true,true,902100,25)); + assert(value.morse_radio.rx_starts==1 && value.morse_radio.rx_duration==30000); + } + { + MyMesh value; value.morse_mode=true; value.morse_radio.rx_supported=true; value.queue_accepts=false; + value.receive("morse listen 1",101,1); + assert(!value._cli.morseReply().pending() && !value.morse_radio.rx_starts); + } + for (const char* mutation : {"set morse.freq 903", "set morse.wpm 30", "morse send E", "cw on", "set radio2 off"}) { + MyMesh value; value.morse_mode=true; value.morse_radio.rx_supported=true; + value.receive("morse listen 1",101,1); + const auto generation=value._cli.morseReply().generation(); + value.receive(mutation,102,2); + assert(value.replies.back().find("handoff pending")!=std::string::npos); + assert(value._cli.morseReply().pending() && value._cli.morseReply().generation()==generation); + assert(!value.morse_radio.rx_starts); + } +} + int main() { + morseRemoteDispatchCases(); #if MESH_ENABLE_HOST_CLI hostIdentityLifecycle(); #endif diff --git a/test/fixtures/room_client_paths.cpp b/test/fixtures/room_client_paths.cpp index a2486a992..1de21ef46 100644 --- a/test/fixtures/room_client_paths.cpp +++ b/test/fixtures/room_client_paths.cpp @@ -103,6 +103,16 @@ struct Sent { bool retry_enabled = false; }; struct MyMesh { + struct CLI { + struct NoMorseReply { + uint32_t generation() const { return 0; } + void beginCommand(const uint8_t*) {} + void endCommand() {} + void prepareReply(const void*) {} + void armReply(const void*, bool) {} + } morse; + NoMorseReply& morseReply() { return morse; } + } _cli; FakeACL acl; mesh::Identity self_id{}; MemoryFS policy_fs; diff --git a/test/fixtures/room_receive_admission/test.cpp b/test/fixtures/room_receive_admission/test.cpp index e37e28e50..b06a7076a 100644 --- a/test/fixtures/room_receive_admission/test.cpp +++ b/test/fixtures/room_receive_admission/test.cpp @@ -77,6 +77,8 @@ struct BaseChatMesh { virtual void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) = 0; void onCommandDataRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) {} void onCLICommandRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*, char*) {} + void onCLIReplyPrepared(const ContactInfo&,mesh::Packet*) {} + void onCLIReplyQueued(const ContactInfo&,mesh::Packet*,bool) {} uint8_t onContactRequest(const ContactInfo&, uint32_t, const uint8_t*, size_t, uint8_t*) { return 0; } void onContactResponse(const ContactInfo&, const uint8_t*, size_t) {} void handleReturnPathRetry(const ContactInfo&, const uint8_t*, uint8_t) {} @@ -89,8 +91,8 @@ struct BaseChatMesh { void sendAckTo(const ContactInfo&, const uint8_t* bytes, size_t length = 4) { ++direct_acks; ack.assign(bytes, bytes + length); } - void sendFloodScoped(const ContactInfo&, mesh::Packet*, unsigned long = 0) {} - void sendDirect(mesh::Packet*, const uint8_t*, uint8_t, unsigned long = 0) {} + bool sendFloodScoped(const ContactInfo&, mesh::Packet*, unsigned long = 0) { return true; } + bool sendDirect(mesh::Packet*, const uint8_t*, uint8_t, unsigned long = 0) { return true; } void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t); }; enum class UIEventType { contactMessage }; diff --git a/test/fixtures/rs232_heartbeat/HardwareSerial.h b/test/fixtures/rs232_heartbeat/HardwareSerial.h new file mode 100644 index 000000000..c4c85c4a3 --- /dev/null +++ b/test/fixtures/rs232_heartbeat/HardwareSerial.h @@ -0,0 +1,47 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Only bytes accepted by the hardware are recorded/delivered. An ended UART +// cannot receive bytes and has no stale RX FIFO on the next begin(). +class HardwareSerial : public Stream { +public: + bool pins_ok = true, begin_ok = true, installed = false; + unsigned begin_calls = 0, end_calls = 0, write_calls = 0; + int tx_capacity = 4096; + size_t write_limit = std::numeric_limits::max(); + std::deque rx; + std::vector tx; + std::function delivered; + + bool setPins(int16_t, int16_t) { return pins_ok; } + void begin(uint32_t) { ++begin_calls; installed = begin_ok; rx.clear(); } + void end() { ++end_calls; installed = false; rx.clear(); } + explicit operator bool() const { return installed; } + int available() override { return installed ? static_cast(rx.size()) : 0; } + int read() override { + if (!installed || rx.empty()) return -1; + const auto value = rx.front(); rx.pop_front(); return value; + } + int availableForWrite() override { + return installed ? static_cast(std::min(tx_capacity, write_limit)) : 0; + } + size_t write(const uint8_t* data, size_t size) override { + ++write_calls; + if (!installed) return 0; + const size_t accepted = std::min(size, write_limit); + tx.insert(tx.end(), data, data + accepted); + if (accepted && delivered) delivered(data, accepted); + return accepted; + } + void feed(const std::vector& bytes) { + if (installed) rx.insert(rx.end(), bytes.begin(), bytes.end()); + } + void feed(const uint8_t* data, size_t size) { + if (installed) rx.insert(rx.end(), data, data + size); + } +}; diff --git a/test/fixtures/rs232_heartbeat/Stream.h b/test/fixtures/rs232_heartbeat/Stream.h new file mode 100644 index 000000000..338e26487 --- /dev/null +++ b/test/fixtures/rs232_heartbeat/Stream.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include + +// Print/Arduino defines these before ordinary firmware headers. Keep them in +// the host build too: a local variable named HEX would otherwise compile here +// yet be expanded to an invalid numeric token on the actual board. +#define HEX 16 +#define DEC 10 +#define OCT 8 +#define BIN 2 + +uint32_t millis(); + +class Stream { +public: + virtual ~Stream() = default; + virtual int available() = 0; + virtual int read() = 0; + virtual size_t write(const uint8_t*, size_t) = 0; + virtual int availableForWrite() { return 4096; } +}; diff --git a/test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h b/test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h new file mode 100644 index 000000000..8ed95cd57 --- /dev/null +++ b/test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h @@ -0,0 +1,77 @@ +#pragma once +#include +#include +#include + +#define MAX_TRANS_UNIT 255 +#define BRIDGE_DEBUG_PRINTLN(...) ((void)0) +struct NodePrefs { uint32_t bridge_baud = 115200; }; + +namespace mesh { +struct Packet { + std::vector payload{1, 2, 3}; + bool allowed = true; + int getRawLength() const { return static_cast(payload.size()); } + uint16_t writeTo(uint8_t* out) const { + for (size_t i = 0; i < payload.size(); ++i) out[i] = payload[i]; + return static_cast(payload.size()); + } + bool readFrom(const uint8_t* bytes, size_t size) { + if (!size || size > MAX_TRANS_UNIT + 1) return false; + payload.assign(bytes, bytes + size); return true; + } +}; +struct RTCClock {}; +struct PacketManager { + bool starved = false; + unsigned allocations = 0, frees = 0; + std::vector> received; + Packet* allocNew() { + ++allocations; + return starved ? nullptr : new Packet; + } + void free(Packet* packet) { ++frees; delete packet; } +}; +} + +struct SeenPackets { + std::vector> seen; + bool wasSeen(const mesh::Packet* packet) const { + for (const auto& bytes : seen) if (bytes == packet->payload) return true; + return false; + } + void markSeen(mesh::Packet* packet) { seen.push_back(packet->payload); } +}; + +class BridgeBase { +public: + static constexpr uint16_t BRIDGE_PACKET_MAGIC = 0xC03E; + static constexpr uint16_t BRIDGE_MAGIC_SIZE = 2, BRIDGE_LENGTH_SIZE = 2, BRIDGE_CHECKSUM_SIZE = 2; + virtual ~BridgeBase() = default; + virtual void begin() = 0; + virtual void end() = 0; + virtual void loop() = 0; + virtual void sendPacket(mesh::Packet*) = 0; + virtual void onPacketReceived(mesh::Packet*) = 0; + bool isRunning() const { return _initialized; } +protected: + bool _initialized = false; + NodePrefs* _prefs; + mesh::PacketManager* _mgr; + mesh::RTCClock* _rtc; + SeenPackets _seen_packets; + BridgeBase(NodePrefs* prefs, mesh::PacketManager* mgr, mesh::RTCClock* rtc) + : _prefs(prefs), _mgr(mgr), _rtc(rtc) {} + bool allowsPacket(const mesh::Packet* packet) { return packet->allowed; } + static uint16_t fletcher16(const uint8_t* bytes, size_t size) { + uint16_t a = 0, b = 0; + for (size_t i = 0; i < size; ++i) { a = (a + bytes[i]) % 255; b = (b + a) % 255; } + return (b << 8) | a; + } + bool validateChecksum(const uint8_t* bytes, size_t size, uint16_t checksum) { + return fletcher16(bytes, size) == checksum; + } + void handleReceivedPacket(mesh::Packet* packet) { + _mgr->received.push_back(packet->payload); _mgr->free(packet); + } +}; diff --git a/test/fixtures/rs232_heartbeat/main.cpp b/test/fixtures/rs232_heartbeat/main.cpp new file mode 100644 index 000000000..232eaed3b --- /dev/null +++ b/test/fixtures/rs232_heartbeat/main.cpp @@ -0,0 +1,693 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Expose driver state only to assert adversarial/failure transitions. All +// behavior runs the compiled production .cpp, not a copied policy model. +#define private public +#include +#undef private +#include "stock_parser.h" +#include "upstream_stock_parser.h" + +uint32_t now_ms = 0; +uint32_t millis() { return now_ms; } +namespace hb = mesh::bridge::rs232heartbeat; +using Policy = mesh::bridge::RS232PowerPolicy; +using Action = RS232Bridge::PowerAction; +constexpr uint64_t A_SESSION = UINT64_C(0x1111222233334444); +constexpr uint64_t B_SESSION = UINT64_C(0xAAAABBBBCCCCDDDD); +constexpr uint64_t C_SESSION = UINT64_C(0x4321567812345678); + +// Independent reference encoding catches field-offset/checksum changes. Wire +// data uses the pre-extension big-endian length + Fletcher16 specification. +uint16_t reference_checksum(const uint8_t* data, size_t size) { + unsigned sum1 = 0, sum2 = 0; + for (size_t i = 0; i < size; ++i) { + sum1 = (sum1 + data[i]) % 255; sum2 = (sum2 + sum1) % 255; + } + return static_cast((sum2 << 8) | sum1); +} +std::vector data_frame(const std::vector& bytes) { + assert(!bytes.empty() && bytes.size() <= MAX_TRANS_UNIT + 1); + std::vector out{0xC0, 0x3E, static_cast(bytes.size() >> 8), + static_cast(bytes.size())}; + out.insert(out.end(), bytes.begin(), bytes.end()); + const auto checksum = reference_checksum(bytes.data(), bytes.size()); + out.push_back(static_cast(checksum >> 8)); out.push_back(static_cast(checksum)); + return out; +} +std::vector control(char op, uint64_t sender, uint64_t target, uint32_t challenge) { + char body[49]{}; + const int written = snprintf(body, sizeof(body), "HB1%c%016llX%016llX%08lX", op, + static_cast(sender), static_cast(target), + static_cast(challenge)); + assert(written == 44); + const auto checksum = reference_checksum(reinterpret_cast(body), 44); + snprintf(body + 44, 5, "%04X", checksum); + std::vector out{0xC0, 0x3E, 0, 0}; + out.insert(out.end(), body, body + 48); return out; +} +void rechecksum(std::vector& frame) { + const auto sum = reference_checksum(frame.data() + 4, 44); + char checksum[5]; snprintf(checksum, sizeof(checksum), "%04X", sum); + std::copy(checksum, checksum + 4, frame.begin() + 48); +} + +struct Peer { + NodePrefs prefs; + HardwareSerial uart; + mesh::PacketManager manager; + mesh::RTCClock rtc; + RS232Bridge driver; + bool enabled = true; + unsigned resumes = 0, suspends = 0; + explicit Peer(uint64_t session) + : driver(&prefs, uart, 5, 6, &manager, &rtc) { + driver.setHeartbeatSession(session); driver.begin(); assert(driver.isRunning()); + } + void tick(bool allow = true, uint32_t idle = 300000, bool force = false) { + if (!enabled) return; // Saved manual OFF is an owner gate, not standby. + driver.loop(); + const auto action = driver.servicePowerPolicy(allow, idle, now_ms, force); + if (action == Action::Suspend) { + ++suspends; driver.end(); driver.completePowerAction(action, !driver.isRunning(), now_ms); + } else if (action == Action::Resume) { + ++resumes; driver.begin(); driver.completePowerAction(action, driver.isRunning(), now_ms); + } + } + void enter_standby(uint32_t at, bool force = true) { + now_ms = at; + const auto action = driver.servicePowerPolicy(true, 0, now_ms, force); + assert(action == Action::Suspend); + driver.end(); driver.completePowerAction(action, true, now_ms); + assert(driver.isPowerSuspended() && !uart.installed); + } + std::string status() const { + char value[160]; driver.formatHeartbeatStatus(value, sizeof(value), now_ms); return value; + } + bool confirmed() const { return status().find("peer=confirmed") != std::string::npos; } +}; + +void codec_test() { + assert(hb::FRAME_SIZE == 52 && hb::BODY_SIZE == 48); + for (auto op : {'P', 'A'}) for (uint64_t sender : {A_SESSION, B_SESSION, UINT64_MAX}) { + const uint64_t target = op == 'P' ? 0 : C_SESSION; + const auto expected = control(op, sender, target, UINT32_MAX); + std::array body{}; + hb::Control outgoing; outgoing.opcode = op; outgoing.sender = sender; + outgoing.target = target; outgoing.challenge = UINT32_MAX; + assert(hb::encode(body.data(), outgoing)); + assert(std::equal(body.begin(), body.end(), expected.begin() + 4)); + hb::Control parsed; + assert(hb::decode(body.data(), parsed)); + assert(parsed.sender == sender && parsed.target == target && parsed.opcode == op + && parsed.challenge == UINT32_MAX); + for (const auto byte : body) assert(byte < 0x80); // Never embeds old magic C0 3E. + for (size_t i = 0; i < body.size(); ++i) for (unsigned bit = 0; bit < 8; ++bit) { + auto corrupted = body; corrupted[i] ^= 1U << bit; + assert(!hb::decode(corrupted.data(), parsed)); + } + } + hb::Control parsed; + for (const auto& bad : { + control('X', A_SESSION, 0, 9), control('P', 0, 0, 9), + control('P', A_SESSION, B_SESSION, 9), control('A', B_SESSION, 0, 9), + control('P', A_SESSION, 0, 0)}) { + assert(!hb::decode(bad.data() + 4, parsed)); + } + for (const auto invalid : { + hb::Control{0, 0, 9, 'P'}, hb::Control{A_SESSION, 0, 0, 'P'}, + hb::Control{A_SESSION, 0, 9, 'X'}, hb::Control{A_SESSION, B_SESSION, 9, 'P'}, + hb::Control{A_SESSION, 0, 9, 'A'}}) { + std::array body; body.fill(0x55); + assert(!hb::encode(body.data(), invalid)); + for (auto byte : body) assert(byte == 0x55); + } + auto version = control('P', A_SESSION, 0, 9); version[6] = '2'; rechecksum(version); + assert(!hb::decode(version.data() + 4, parsed)); + auto lower = control('P', B_SESSION, 0, 9); lower[8] = 'a'; rechecksum(lower); + assert(!hb::decode(lower.data() + 4, parsed)); + assert(!hb::encode(nullptr, parsed) && !hb::decode(nullptr, parsed)); +} + +void policy_test() { + Policy p; + assert(p.service(true, 300000, 0, true, false) == Action::None); + assert(p.wakeDelay(0) == UINT32_MAX); + assert(p.service(true, 300000, 299999, true, false) == Action::None); + assert(p.service(true, 300000, 300000, true, true) == Action::None); // Complete RX/TX owns awake. + assert(p.service(true, 300000, 300000, true, false) == Action::Suspend); + assert(p.service(true, 300000, 300001, true, false) == Action::None); // Owner must complete. + p.complete(Action::Resume, true, 300001); assert(!p.suspended()); // Wrong completion ignored. + p.complete(Action::Suspend, false, 300001); + assert(!p.suspended() && p.retrying()); + assert(p.wakeDelay(300001) == hb::ACTION_RETRY_MS); + assert(p.service(true, 300000, 305000, true, false) == Action::None); + assert(p.service(true, 300000, 305001, true, false) == Action::Suspend); + p.complete(Action::Suspend, true, 305001); + assert(p.suspended() && p.wakeDelay(305001) == 30000); + assert(p.service(true, 300000, 335000, false, false) == Action::None); + assert(p.service(true, 300000, 335001, false, false) == Action::Resume); + p.complete(Action::Resume, false, 335001); + assert(p.suspended() && p.retrying()); + assert(p.service(true, 300000, 340000, false, false) == Action::None); + assert(p.service(true, 300000, 340001, false, false) == Action::Resume); + p.complete(Action::Resume, true, 340001); + assert(!p.suspended() && p.probing() && p.wakeDelay(340001) == 2000); + p.notePeer(340002); + assert(!p.probing() && p.hasPeer() && p.peerAge(340012) == 10); + assert(p.service(true, 300000, 640001, true, false) == Action::None); + + Policy windows; + windows.resetPeer(0); + uint32_t t = 1; + for (unsigned cycle = 1; cycle <= 16; ++cycle) { + assert(windows.service(true, 1, t, true, false) == Action::Suspend); + windows.complete(Action::Suspend, true, t); + t += 30000; + assert(windows.service(true, 1, t, false, false) == Action::Resume); + windows.complete(Action::Resume, true, t); + const uint32_t duration = cycle % 8 == 0 ? 40000 : 2000; + assert(windows.probing() && windows.wakeDelay(t) == duration); + t += duration; + } + // A long receive window contains a full peer short-cycle, plus an entire + // heartbeat exchange allowance. This is the rendezvous guarantee, not RNG. + static_assert(hb::LONG_PROBE_MS > hb::STANDBY_MS + hb::SHORT_PROBE_MS + + hb::PROBE_PING_MS * 2, "long probe must cover a full unsynchronized cycle"); + + Policy wrap; + const uint32_t near_wrap = UINT32_MAX - 499; + wrap.resetPeer(near_wrap); + assert(wrap.service(true, 1000, near_wrap + 999, true, false) == Action::None); + assert(wrap.service(true, 1000, near_wrap + 1000, true, false) == Action::Suspend); + wrap.complete(Action::Suspend, true, near_wrap + 1000); + assert(wrap.wakeDelay(near_wrap + 1001) == 29999); + assert(wrap.service(true, 1000, near_wrap + 31000, false, false) == Action::Resume); + wrap.complete(Action::Resume, true, near_wrap + 31000); + assert(wrap.probing()); + assert(wrap.service(false, 1000, near_wrap + 33000, true, false) == Action::None); + assert(!wrap.probing()); + for (uint32_t age = 0; age <= 500000; age += 1000) + assert(wrap.service(false, 1, near_wrap + age, true, false) == Action::None); +} + +void compatibility_test() { + now_ms = 0; + Peer default_peer(B_SESSION); + for (now_ms = 0; now_ms < 400000; now_ms += 1000) default_peer.tick(true, 1000); + assert(default_peer.uart.end_calls == 0 && !default_peer.uart.tx.empty()); + assert(!default_peer.driver._have_ack && !default_peer.driver.isPowerSuspended()); + assert(default_peer.status().find("armed=off") != std::string::npos); + assert(default_peer.uart.tx.size() % hb::FRAME_SIZE == 0); + for (size_t offset = 0; offset < default_peer.uart.tx.size(); offset += hb::FRAME_SIZE) { + const auto* frame = default_peer.uart.tx.data() + offset; + assert(frame[0] == 0xC0 && frame[1] == 0x3E && frame[2] == 0 && frame[3] == 0); + hb::Control ping; + assert(hb::decode(frame + 4, ping) && ping.opcode == 'P'); + assert(ping.sender == B_SESSION && ping.target == 0); + } + now_ms = 0; + Peer peer(A_SESSION); + peer.driver.setHeartbeatEnabled(false); + // Disabling heartbeat restores a byte-for-byte legacy data-only stream. + for (now_ms = 0; now_ms < 400000; now_ms += 1000) peer.tick(false); + assert(peer.uart.tx.empty() && peer.uart.end_calls == 0); + mesh::Packet data; data.payload = {0xC0, 0x3E, 0, 0, 255, 1, 2, 3}; + peer.driver.sendPacket(&data); + assert(peer.uart.tx == data_frame(data.payload)); + const auto expected = peer.uart.tx; + peer.driver.sendPacket(&data); assert(peer.uart.tx == expected); // Duplicate unchanged. + + for (unsigned version = 0; version < 2; ++version) { + HardwareSerial uart; uart.begin(115200); NodePrefs prefs; mesh::PacketManager manager; + StockBridge local(&prefs, uart, &manager); + UpstreamStockBridge upstream(&prefs, uart, &manager); + StockBridge& receiver = version == 0 ? local : upstream; + // Both stock parser versions receive the exact production control bytes, + // reject them without any Packet allocation and decode the next real data. + uart.feed(default_peer.uart.tx); receiver.loop(); + while (uart.available()) receiver.loop(); + assert(manager.allocations == 0 && manager.received.empty()); + for (unsigned i = 1; i < 100; ++i) { + uart.feed(control(i % 2 ? 'P' : 'A', B_SESSION, i % 2 ? 0 : A_SESSION, i)); + receiver.loop(); assert(manager.allocations == 0 && manager.received.empty()); + } + uart.feed(expected); receiver.loop(); + assert(manager.allocations == 1 && manager.received.size() == 1 + && manager.received[0] == data.payload && uart.tx.empty()); + } + // Real small UART FIFOs did not require a caller retry for legacy data. Keep + // complete synchronous admission even when availableForWrite is just 64. + Peer small_fifo(C_SESSION); small_fifo.driver.setHeartbeatEnabled(false); + small_fifo.uart.tx_capacity = 64; + mesh::Packet large1, large2; + large1.payload.assign(MAX_TRANS_UNIT + 1, 0x11); + large2.payload.assign(MAX_TRANS_UNIT + 1, 0x22); + small_fifo.driver.sendPacket(&large1); small_fifo.driver.sendPacket(&large2); + auto two_frames = data_frame(large1.payload); + const auto second = data_frame(large2.payload); two_frames.insert(two_frames.end(), second.begin(), second.end()); + assert(small_fifo.uart.tx == two_frames && small_fifo.uart.write_calls == 2); + // A prefilled remaining tail after a scheduling stall belongs to the + // original stock frame. Timeout is for incomplete/noisy input, not for + // throwing away valid bytes already in the UART driver. + for (uint32_t pause : {500, 2000}) { + now_ms = 0; Peer stalled(A_SESSION); stalled.driver.setHeartbeatEnabled(false); + const auto full = data_frame(large1.payload); + stalled.uart.feed(full.data(), 12); stalled.driver.loop(); + assert(stalled.driver._rx_buffer_pos == 12 && stalled.manager.received.empty()); + now_ms += pause; stalled.uart.feed(full.data() + 12, full.size() - 12); + stalled.tick(true, 1000); + assert(stalled.manager.received.size() == 1 && stalled.manager.received[0] == large1.payload); + assert(!stalled.driver.isPowerSuspended()); // Legacy data never negotiates auto-standby. + } + // Stock TX -> new RX, including a maximum-length payload and a malformed + // control interrupted by real legacy framing: no swallowing the data frame. + std::vector maximum(MAX_TRANS_UNIT + 1, 0xC0); + maximum[1] = 0x3E; + peer.uart.feed(data_frame(maximum)); peer.driver.loop(); + assert(peer.manager.received.back() == maximum); + auto short_control = control('P', B_SESSION, 0, 42); short_control.resize(13); + peer.uart.feed(short_control); peer.uart.feed(data_frame({10, 11, 12})); peer.driver.loop(); + assert(peer.manager.received.back() == std::vector({10, 11, 12})); + assert(peer.status().find("peer=legacy") != std::string::npos); + assert(!peer.driver._have_ack); // Legacy traffic is not bidirectional proof. + + // A passive peer may respond to an opted-in neighbor without sending PING. + const size_t before = peer.uart.tx.size(); + peer.uart.feed(control('P', B_SESSION, 0, 123)); peer.driver.loop(); + const auto ack = control('A', A_SESSION, B_SESSION, 123); + assert(std::equal(ack.begin(), ack.end(), peer.uart.tx.begin() + before)); + assert(peer.uart.tx.size() == before + ack.size()); + assert(!peer.driver._have_ack); + + // Legacy inbound traffic can refresh its age but cannot negotiate a wake + // schedule. Safe default autosleep still leaves that stock UART on forever. + const auto ends = peer.uart.end_calls; + now_ms += 500000; peer.tick(true, 1000); + assert(peer.uart.end_calls == ends && peer.driver.isRunning() + && !peer.driver._have_ack && !peer.driver.isPowerSuspended()); + + Peer manual_off(C_SESSION); manual_off.driver.end(); manual_off.enabled = false; + const unsigned starts = manual_off.uart.begin_calls; + for (now_ms = 0; now_ms < 1000000; now_ms += 1000) manual_off.tick(true, 1); + assert(!manual_off.driver.isRunning() && manual_off.uart.begin_calls == starts + && manual_off.uart.tx.empty()); +} + +void peer_proof_test() { + now_ms = 100; + Peer peer(A_SESSION); + peer.tick(true); + peer.driver.loop(); + assert(peer.uart.tx == control('P', A_SESSION, 0, peer.driver._outstanding_challenge)); + const auto ping = peer.uart.tx; + const uint32_t challenge = peer.driver._outstanding_challenge; + assert(challenge != 0 && peer.manager.allocations == 0); + peer.uart.feed(ping); peer.driver.loop(); + assert(!peer.driver._have_ack && peer.uart.tx == ping); // Physical TX reflection. + for (const auto& bad : { + control('A', B_SESSION, C_SESSION, challenge), + control('A', B_SESSION, A_SESSION, challenge + 1), + control('A', A_SESSION, A_SESSION, challenge), + control('P', B_SESSION, 0, challenge)}) { + peer.uart.feed(bad); peer.driver.loop(); assert(!peer.driver._have_ack); + } + auto bad_crc = control('A', B_SESSION, A_SESSION, challenge); bad_crc[51] ^= 1; + peer.uart.feed(bad_crc); peer.driver.loop(); assert(!peer.driver._have_ack); + auto version = control('A', B_SESSION, A_SESSION, challenge); version[6] = '2'; rechecksum(version); + peer.uart.feed(version); peer.driver.loop(); assert(!peer.driver._have_ack); + peer.uart.feed(std::vector(700, 0x7F)); peer.driver.loop(); + assert(peer.uart.rx.size() == 188); peer.driver.loop(); // Bounded noisy RX work. + const auto valid = control('A', B_SESSION, A_SESSION, challenge); + now_ms += 1; peer.uart.feed(valid); peer.driver.loop(); + assert(peer.confirmed() && peer.driver._peer_session == B_SESSION); + assert(peer.driver._outstanding_challenge == 0 && peer.manager.allocations == 0); + const uint32_t last = peer.driver._last_ack; + now_ms += 100; peer.uart.feed(valid); peer.driver.loop(); + assert(peer.driver._last_ack == last); // Consume ACK exactly once. + char one[1]{'X'}; peer.driver.formatHeartbeatStatus(one, sizeof(one), now_ms); assert(one[0] == 0); + peer.driver.formatHeartbeatStatus(nullptr, 1, now_ms); + + Peer self_echo(C_SESSION); self_echo.driver.setHeartbeatEnabled(false); + mesh::Packet own; own.payload = {7, 3, 9}; self_echo.driver.sendPacket(&own); + self_echo.uart.feed(self_echo.uart.tx); self_echo.driver.loop(); + assert(!self_echo.driver._have_ack && !self_echo.driver._legacy_rx + && self_echo.manager.received.empty()); + + // A new local session must invalidate ACKs for the old one, and a new remote + // session is accepted only via a fresh current challenge response. + peer.driver.setHeartbeatSession(C_SESSION); + assert(!peer.driver._have_ack && peer.driver._peer_session == 0); + peer.uart.feed(valid); peer.driver.loop(); assert(!peer.driver._have_ack); + peer.tick(true); peer.driver.loop(); + const auto current = peer.driver._outstanding_challenge; + assert(current != 0); + now_ms += hb::ACK_MAX_AGE_MS + 1; + peer.uart.feed(control('A', B_SESSION, C_SESSION, current)); peer.driver.loop(); + assert(!peer.driver._have_ack); // Correct fields but stale ACK. + now_ms = UINT32_MAX - 25; + peer.driver.setHeartbeatSession(UINT64_C(0x3333000011119999)); + peer.tick(true); peer.driver.loop(); const auto wrapping = peer.driver._outstanding_challenge; + now_ms += 40; + peer.uart.feed(control('A', B_SESSION, peer.driver._session, wrapping)); peer.driver.loop(); + assert(peer.confirmed()); + + peer.driver.end(); peer.driver.begin(); + peer.driver._challenge_counter = UINT32_MAX; + peer.driver.loop(); + assert(peer.driver._outstanding_challenge == 1); // Reserved zero is skipped. + const auto previous = peer.driver._last_ack; + now_ms += 10; + peer.uart.feed(control('P', C_SESSION, 0, 55)); peer.driver.loop(); + assert(peer.driver._last_ack == previous); // Remote PING alone is not proof. + peer.driver.setHeartbeatSession(0); + const auto bytes_before = peer.uart.tx.size(); peer.driver.loop(); + assert(!peer.driver._have_ack && peer.uart.tx.size() == bytes_before); +} + +void bounded_io_test() { + now_ms = 0; + Peer peer(A_SESSION); + peer.uart.write_limit = 1; + peer.tick(true); peer.driver.loop(); + assert(peer.driver._tx_size == hb::FRAME_SIZE && peer.driver._outstanding_challenge == 0); + mesh::Packet packet; packet.payload = {9, 8, 7}; + peer.driver.sendPacket(&packet); // Does not interleave data into partial control. + assert(peer.uart.tx.size() < hb::FRAME_SIZE); + for (unsigned i = 0; i < 60; ++i) { ++now_ms; peer.driver.loop(); } + const auto ping = control('P', A_SESSION, 0, peer.driver._outstanding_challenge); + assert(peer.uart.tx == ping && peer.driver._outstanding_challenge != 0); + peer.driver.sendPacket(&packet); + for (unsigned i = 0; i < 12; ++i) { ++now_ms; peer.driver.loop(); } + std::vector expected = ping; + const auto data = data_frame(packet.payload); expected.insert(expected.end(), data.begin(), data.end()); + assert(peer.uart.tx == expected && !peer.driver._have_ack); + // Full TX admission is still not proof that an absent remote received it. + assert(peer.driver._power.peerAge(now_ms) == UINT32_MAX); + + now_ms = 0; Peer blocked(B_SESSION); blocked.uart.write_limit = 0; + blocked.tick(true, 1); blocked.driver.loop(); + now_ms = 1000; + assert(blocked.driver.servicePowerPolicy(true, 1, now_ms, true) == Action::None); + assert(blocked.uart.tx.empty() && blocked.driver._tx_size != 0); + blocked.uart.write_limit = 4096; blocked.driver.loop(); + assert(blocked.driver._tx_size == 0); + assert(blocked.driver.servicePowerPolicy(true, 1, now_ms, true) == Action::None); // Last bytes still on wire. + now_ms += 100; + assert(blocked.driver.servicePowerPolicy(true, 1, now_ms, true) == Action::Suspend); + + now_ms = 0; Peer starved(C_SESSION); starved.manager.starved = true; + starved.uart.feed(data_frame({1, 3, 5})); starved.driver.loop(); + assert(starved.driver._rx_packet_ready && starved.manager.received.empty()); + now_ms = 1000; + assert(starved.driver.servicePowerPolicy(true, 1, now_ms, true) == Action::None); + starved.uart.feed(control('P', B_SESSION, 0, 7)); + const auto unread = starved.uart.rx.size(); starved.driver.loop(); + assert(starved.uart.rx.size() == unread && starved.driver._rx_packet_ready); + starved.manager.starved = false; starved.driver.loop(); + assert(!starved.driver._rx_packet_ready && starved.manager.received.size() == 1); + assert(starved.manager.received[0] == std::vector({1, 3, 5})); + assert(starved.uart.rx.empty()); // Control processing resumes after pool recovery. + assert(starved.driver._power.peerAge(now_ms) == 1000); // Original arrival, not delayed allocation. + + now_ms = 0; Peer partial(A_SESSION); + partial.uart.feed({0xC0, 0x3E, 0, 200, 1}); partial.driver.loop(); + assert(partial.driver._rx_buffer_pos == 5); + const auto assembly = partial.driver.frameAssemblyMs(); + now_ms = assembly - 1; partial.uart.feed({2}); partial.driver.loop(); + assert(partial.driver._rx_buffer_pos == 6); + now_ms = assembly; partial.driver.loop(); assert(partial.driver._rx_buffer_pos == 0); + partial.uart.feed(data_frame({10, 20})); partial.driver.loop(); + assert(partial.manager.received.back() == std::vector({10, 20})); + + // Repeated malformed frame starts do not restart the one deadline grace. + now_ms = 0; Peer noise(C_SESSION); + noise.driver.servicePowerPolicy(true, 100, now_ms, true); + now_ms = 100; noise.uart.feed({0xC0, 0x3E, 0, 200}); noise.driver.loop(); + assert(noise.driver.servicePowerPolicy(true, 100, now_ms, true) == Action::None); + const uint32_t deadline = noise.driver._partial_suspend_until; + bool stopped = false; + for (now_ms = 110; now_ms < deadline; now_ms += 10) { + noise.uart.feed({0xC0, 0x3E, 0, 200}); noise.driver.loop(); + const auto action = noise.driver.servicePowerPolicy(true, 100, now_ms, true); + assert(noise.driver._partial_suspend_until == deadline); + if (action == Action::Suspend) { + // A full malformed CRC frame can be discarded before the deadline. + stopped = true; noise.driver.end(); noise.driver.completePowerAction(action, true, now_ms); break; + } + assert(action == Action::None); + } + if (!stopped) { + now_ms = deadline; + assert(noise.driver.servicePowerPolicy(true, 100, now_ms, true) == Action::Suspend); + } + + now_ms = UINT32_MAX - 50; Peer wrapped_rx(A_SESSION); + wrapped_rx.driver.setHeartbeatEnabled(false); + wrapped_rx.uart.feed({0xC0, 0x3E, 0, 50}); wrapped_rx.driver.loop(); + const auto timeout = wrapped_rx.driver.frameAssemblyMs(); + now_ms += timeout; wrapped_rx.driver.loop(); + assert(wrapped_rx.driver._rx_buffer_pos == 0); +} + +struct Pair { + Peer a, b; + bool cable = true; + unsigned drop_controls = 0; + uint32_t dropped_while_stopped = 0; + Pair() : a(A_SESSION), b(B_SESSION) { + a.uart.delivered = [&](const uint8_t* data, size_t size) { deliver(b, data, size); }; + b.uart.delivered = [&](const uint8_t* data, size_t size) { deliver(a, data, size); }; + } + void deliver(Peer& peer, const uint8_t* data, size_t size) { + if (!cable) return; + if (drop_controls && size == hb::FRAME_SIZE) { --drop_controls; return; } + if (!peer.uart.installed) { ++dropped_while_stopped; return; } + peer.uart.feed(data, size); + } + void tick(uint32_t idle = 300000) { + a.tick(true, idle); b.tick(true, idle); + a.driver.loop(); b.driver.loop(); // bounded extra UART service, no forced wakes + } + bool confirmed() const { return a.confirmed() && b.confirmed(); } + void negotiate() { + // Learn the peer's capability through real challenge traffic, rather than + // setting _have_ack in the fixture. This is the safe default sleep gate. + const uint32_t start = now_ms; + for (uint32_t elapsed = 0; elapsed < 1000 && !confirmed(); elapsed += 10) { + now_ms = start + elapsed; tick(); + } + assert(confirmed() && a.driver._have_ack && b.driver._have_ack); + } +}; + +void rendezvous_test() { + uint32_t max_recovery = 0; + std::vector phases{1, 1999, 2001, 29999, 30001, 31999}; + for (uint32_t phase = 0; phase < 32000; phase += 100) phases.push_back(phase); + for (const auto phase : phases) { + now_ms = 0; Pair pair; + pair.negotiate(); + const uint32_t idle_at = now_ms + 300001; + pair.a.enter_standby(idle_at, false); pair.b.enter_standby(idle_at + phase, false); + assert(pair.a.driver._have_ack && pair.b.driver._have_ack); // Capability survives soft UART stop. + const uint32_t start = idle_at + phase; + bool recovered = false; + for (uint32_t elapsed = 0; elapsed <= 328000; elapsed += 50) { + now_ms = start + elapsed; pair.tick(); + if (pair.confirmed()) { max_recovery = std::max(max_recovery, elapsed); recovered = true; break; } + } + if (!recovered) fprintf(stderr, "No rendezvous at phase=%lu\n", static_cast(phase)); + assert(recovered); + assert(pair.a.driver.isRunning() && pair.b.driver.isRunning() + && !pair.a.driver.isPowerSuspended() && !pair.b.driver.isPowerSuspended()); + assert(pair.a.manager.allocations == 0 && pair.b.manager.allocations == 0); + if (phase == 18000) { + // Two-second fixed windows never overlap here. Recovery MUST use the + // scheduled long receive window, not coincident random clocks. + assert(pair.a.resumes >= 8 || pair.b.resumes >= 8); + assert(pair.dropped_while_stopped > 0); + } + } + printf("326 deterministic phases; maximum recovery=%lu ms\n", static_cast(max_recovery)); +} + +void recovery_test() { + // Same-phase simultaneous PINGs are handled without requiring a master. + now_ms = 0; Pair pair; + for (; now_ms < 1000 && !pair.confirmed(); now_ms += 10) pair.tick(); + assert(pair.confirmed()); + assert(pair.a.driver._peer_session == B_SESSION && pair.b.driver._peer_session == A_SESSION); + // Restart creates a different boot session, while the surviving endpoint + // uses its current challenge rather than a stale ACK from the old peer. + pair.b.driver.end(); pair.b.driver.setHeartbeatSession(C_SESSION); pair.b.driver.begin(); + assert(!pair.b.driver._have_ack); + for (; now_ms < 40000 && (!pair.b.confirmed() || pair.a.driver._peer_session != C_SESSION); now_ms += 10) + pair.tick(); + assert(pair.confirmed() && pair.a.driver._peer_session == C_SESSION); + + // A stock replacement continuing ordinary RX, but not answering challenges, + // must revoke historical negotiated sleep. A later real ACK re-arms it. + now_ms = 0; Pair replacement; replacement.negotiate(); replacement.cable = false; + now_ms = replacement.a.driver._last_ack + hb::ACTIVE_PING_MS + hb::ACK_MAX_AGE_MS + 1; + replacement.a.uart.feed(data_frame({11, 22, 33})); replacement.a.tick(true, 1000); + assert(!replacement.a.driver._have_ack && replacement.a.driver._peer_session == 0); + assert(replacement.a.status().find("armed=off") != std::string::npos); + now_ms += 500000; replacement.a.tick(true, 1000); + assert(replacement.a.driver.isRunning() && !replacement.a.driver.isPowerSuspended()); + replacement.cable = true; + const auto restored_at = now_ms; + for (uint32_t elapsed = 0; elapsed < 35000 && !replacement.confirmed(); elapsed += 10) { + now_ms = restored_at + elapsed; replacement.tick(); + } + assert(replacement.confirmed()); replacement.a.tick(true, 300000); + assert(replacement.a.status().find("armed=on") != std::string::npos); + + now_ms = 0; Pair heartbeat_off; heartbeat_off.negotiate(); + heartbeat_off.a.driver.setHeartbeatEnabled(false); + now_ms += 500000; heartbeat_off.a.tick(true, 1000, true); + assert(heartbeat_off.a.driver.isRunning() && !heartbeat_off.a.driver.isPowerSuspended()); + assert(heartbeat_off.a.status().find("armed=off") != std::string::npos); + + // After loss/offline standby, restoring wire connectivity wakes NEITHER + // endpoint. Periodic timed rendezvous alone restores the link. + now_ms = 0; Pair sleeping; sleeping.negotiate(); sleeping.cable = false; + const auto sleep_at = now_ms + 300001; + sleeping.a.enter_standby(sleep_at, false); sleeping.b.enter_standby(sleep_at + 17000, false); + sleeping.drop_controls = 3; + bool recovered = false; + for (uint32_t elapsed = 0; elapsed <= 660000; elapsed += 50) { + now_ms = sleep_at + 17000 + elapsed; + if (elapsed == 40000) sleeping.cable = true; + sleeping.tick(); + if (sleeping.confirmed()) { recovered = true; break; } + } + assert(recovered && sleeping.drop_controls == 0); + + // A failed physical UART begin leaves standby and its saved intent intact; + // no tight loop retry, and the later timed successful begin recovers. + now_ms = 0; Peer retry(A_SESSION); retry.enter_standby(0); + now_ms = 30000; + const auto resume = retry.driver.servicePowerPolicy(true, 300000, now_ms, true); + assert(resume == Action::Resume); + retry.uart.begin_ok = false; retry.driver.begin(); + assert(!retry.driver.isRunning()); retry.driver.completePowerAction(resume, false, now_ms); + now_ms = 34999; + assert(retry.driver.servicePowerPolicy(true, 300000, now_ms, true) == Action::None); + retry.uart.begin_ok = true; now_ms = 35000; retry.tick(true, 300000, true); + assert(retry.driver.isRunning() && !retry.driver.isPowerSuspended()); + assert(retry.status().find("state=probing") != std::string::npos); + // Turning the feature off while suspended must immediately resume, rather + // than persisting an accidental hard-disable or waiting 30 seconds. + now_ms = 0; Peer off(B_SESSION); off.enter_standby(0); + now_ms = 5; off.tick(false); + assert(off.driver.isRunning() && !off.driver.isPowerSuspended()); + off.driver.loop(); assert(off.uart.tx.size() == hb::FRAME_SIZE); + assert(off.status().find("heartbeat=on") != std::string::npos); // Independent of autosleep. + + // Staggered boot + missed initial traffic recovers on next active heartbeat. + now_ms = 0; Pair boot; boot.b.driver.end(); boot.b.enabled = false; + for (; now_ms < 120000; now_ms += 100) boot.tick(); + assert(!boot.a.confirmed() && !boot.b.confirmed()); + boot.b.enabled = true; boot.b.driver.setHeartbeatSession(C_SESSION); boot.b.driver.begin(); + for (const uint32_t end = now_ms + 35000; now_ms < end && !boot.confirmed(); now_ms += 100) boot.tick(); + assert(boot.confirmed()); +} + +void counter_cycle_test() { + constexpr uint64_t FULL_CYCLE = UINT64_C(1) << 32; + // Keep an absolute reference clock in this test so setting the 32-bit mock + // back near an old timestamp explicitly means 49.7 days elapsed, not a + // backwards wall-clock change. No RX occurs during that entire gap. + now_ms = 100; Pair pair; pair.negotiate(); pair.cable = false; + auto& heartbeat = pair.a; + const uint32_t ack_at = heartbeat.driver._last_ack; + assert(heartbeat.driver._ack_fresh && heartbeat.driver._have_ack); + uint64_t absolute = static_cast(ack_at) + heartbeat.driver._idle_ms + 1; + now_ms = static_cast(absolute); heartbeat.driver.loop(); + assert(!heartbeat.driver._ack_fresh && heartbeat.driver._have_ack); + assert(heartbeat.status().find("peer=unknown") != std::string::npos); + heartbeat.driver.end(); + absolute = static_cast(ack_at) + FULL_CYCLE + 5; + now_ms = static_cast(absolute); + assert(static_cast(now_ms - ack_at) == 5); // Naive age would look fresh again. + heartbeat.driver.loop(); // Ended UART loop cannot fabricate new presence. + const auto resume = heartbeat.driver.servicePowerPolicy(true, 300000, now_ms); + assert(resume == Action::Resume); + assert(!heartbeat.driver._ack_fresh && heartbeat.driver._have_ack); + assert(heartbeat.status().find("peer=unknown") != std::string::npos); + heartbeat.driver.begin(); heartbeat.driver.completePowerAction(resume, true, now_ms); + heartbeat.driver.loop(); + assert(!heartbeat.driver._ack_fresh && heartbeat.driver._have_ack); + const auto fresh_challenge = heartbeat.driver._outstanding_challenge; + assert(fresh_challenge != 0); + now_ms += 10; + heartbeat.uart.feed(control('A', B_SESSION, A_SESSION, fresh_challenge)); heartbeat.driver.loop(); + assert(heartbeat.driver._ack_fresh && heartbeat.confirmed()); // Real RX alone refreshes it. + + // The owner's policy must expire presence even if the physical UART is + // already ended and loop() therefore returns before any normal processing. + now_ms = 500; Peer stopped(C_SESSION); stopped.driver.loop(); + const auto challenge = stopped.driver._outstanding_challenge; + stopped.uart.feed(control('A', B_SESSION, C_SESSION, challenge)); stopped.driver.loop(); + assert(stopped.driver._ack_fresh && stopped.confirmed()); + const auto stopped_ack_at = stopped.driver._last_ack; + stopped.driver.end(); + absolute = static_cast(stopped_ack_at) + stopped.driver._idle_ms + 1; + now_ms = static_cast(absolute); + const auto stopped_action = stopped.driver.servicePowerPolicy(true, 300000, now_ms); + assert(stopped_action == Action::Resume && !stopped.driver._ack_fresh && stopped.driver._have_ack); + absolute = static_cast(stopped_ack_at) + FULL_CYCLE + 5; + now_ms = static_cast(absolute); + stopped.driver.servicePowerPolicy(true, 300000, now_ms); + assert(!stopped.driver._ack_fresh && stopped.driver._have_ack); + assert(stopped.status().find("peer=unknown") != std::string::npos); + + // Legacy inbound reachability also has sticky expiry, independently of the + // historical fact that a legacy packet was observed in the distant past. + now_ms = 1000; Peer legacy(A_SESSION); legacy.driver.setHeartbeatEnabled(false); + legacy.uart.feed(data_frame({19, 20, 21})); legacy.driver.loop(); + assert(legacy.driver._legacy_rx && legacy.driver._legacy_fresh); + assert(legacy.status().find("peer=legacy") != std::string::npos); + const uint32_t legacy_at = legacy.driver._last_legacy_rx; + legacy.driver.end(); + absolute = static_cast(legacy_at) + legacy.driver._idle_ms + 1; + now_ms = static_cast(absolute); + const auto legacy_resume = legacy.driver.servicePowerPolicy(false, 300000, now_ms); + assert(legacy_resume == Action::Resume && !legacy.driver._legacy_fresh && legacy.driver._legacy_rx); + absolute = static_cast(legacy_at) + FULL_CYCLE + 5; + now_ms = static_cast(absolute); + legacy.driver.servicePowerPolicy(false, 300000, now_ms); + assert(static_cast(now_ms - legacy_at) == 5); + assert(!legacy.driver._legacy_fresh && legacy.driver._legacy_rx); + assert(legacy.status().find("peer=unknown") != std::string::npos); + legacy.driver.begin(); legacy.driver.completePowerAction(legacy_resume, true, now_ms); + legacy.driver.loop(); assert(!legacy.driver._legacy_fresh); + legacy.uart.feed(data_frame({22, 23, 24})); legacy.driver.loop(); + assert(legacy.driver._legacy_fresh && !legacy.driver._have_ack); + assert(legacy.status().find("peer=legacy") != std::string::npos); +} + +int main(int argc, char** argv) { + assert(argc == 2); + const std::string name = argv[1]; + if (name == "codec") codec_test(); + else if (name == "policy") policy_test(); + else if (name == "compatibility") compatibility_test(); + else if (name == "peer-proof") peer_proof_test(); + else if (name == "bounded-io") bounded_io_test(); + else if (name == "rendezvous") rendezvous_test(); + else if (name == "recovery") recovery_test(); + else if (name == "counter-cycle") counter_cycle_test(); + else assert(false); + printf("%s passed\n", name.c_str()); +} diff --git a/test/fixtures/rs232_heartbeat/stock_parser.h b/test/fixtures/rs232_heartbeat/stock_parser.h new file mode 100644 index 000000000..fe871e805 --- /dev/null +++ b/test/fixtures/rs232_heartbeat/stock_parser.h @@ -0,0 +1,71 @@ +#pragma once + +// Frozen production RX methods from RS232Bridge.cpp at +// 015a3e8e05957d3e432c289c2fd48d060d81591e (before heartbeat support). +// Only the class qualifier was changed. Do not teach this parser controls: +// compatibility requires the OLD receiver to ignore every zero-length frame. +class StockBridge : public BridgeBase { +public: + Stream* _serial; + uint8_t _rx_buffer[MAX_TRANS_UNIT + 1 + 6]{}; + uint16_t _rx_buffer_pos = 0; + bool _rx_packet_ready = false; + static constexpr uint16_t SERIAL_OVERHEAD = 6; + StockBridge(NodePrefs* prefs, Stream& serial, mesh::PacketManager* mgr) + : BridgeBase(prefs, mgr, nullptr), _serial(&serial) { _initialized = true; } + void begin() override { _initialized = true; } + void end() override { _initialized = false; } + void sendPacket(mesh::Packet*) override {} + void onPacketReceived(mesh::Packet* packet) override { handleReceivedPacket(packet); } + + void drainReceivedPackets() { + if (!_rx_packet_ready) return; + mesh::Packet* pkt = _mgr->allocNew(); + if (!pkt) return; + const uint16_t len = (_rx_buffer[2] << 8) | _rx_buffer[3]; + const bool valid = pkt->readFrom(_rx_buffer + 4, len); + _rx_buffer_pos = 0; + _rx_packet_ready = false; + if (valid) onPacketReceived(pkt); + else _mgr->free(pkt); + } + + void loop() override { + if (_initialized == false) return; + drainReceivedPackets(); + int remaining = _serial->available(); + if (remaining > 512) remaining = 512; + while (remaining-- > 0 && !_rx_packet_ready) { + const int value = _serial->read(); + if (value < 0) break; + const uint8_t b = static_cast(value); + if (_rx_buffer_pos < 2) { + if ((_rx_buffer_pos == 0 && b == ((BRIDGE_PACKET_MAGIC >> 8) & 0xFF)) || + (_rx_buffer_pos == 1 && b == (BRIDGE_PACKET_MAGIC & 0xFF))) { + _rx_buffer[_rx_buffer_pos++] = b; + } else { + _rx_buffer_pos = 0; + if (b == ((BRIDGE_PACKET_MAGIC >> 8) & 0xFF)) _rx_buffer[_rx_buffer_pos++] = b; + } + } else { + _rx_buffer[_rx_buffer_pos++] = b; + if (_rx_buffer_pos >= 4) { + uint16_t len = (_rx_buffer[2] << 8) | _rx_buffer[3]; + if (len == 0 || len > (MAX_TRANS_UNIT + 1)) { + _rx_buffer_pos = 0; + continue; + } + if (_rx_buffer_pos == len + SERIAL_OVERHEAD) { + uint16_t received_checksum = (_rx_buffer[4 + len] << 8) | _rx_buffer[5 + len]; + if (validateChecksum(_rx_buffer + 4, len, received_checksum)) { + _rx_packet_ready = true; + drainReceivedPackets(); + } + if (!_rx_packet_ready) _rx_buffer_pos = 0; + } + } + } + } + drainReceivedPackets(); + } +}; diff --git a/test/fixtures/rs232_heartbeat/upstream_stock_parser.h b/test/fixtures/rs232_heartbeat/upstream_stock_parser.h new file mode 100644 index 000000000..26596b384 --- /dev/null +++ b/test/fixtures/rs232_heartbeat/upstream_stock_parser.h @@ -0,0 +1,72 @@ +#pragma once + +// Production stock loop() from meshcore-dev/MeshCore at +// bbe54f8c55d5ccaaeb7e6f867a8f19fc8afb57bd. The class qualifier alone is changed +// to keep this executable compatibility reference independent of our driver. +class UpstreamStockBridge : public StockBridge { +public: + using StockBridge::StockBridge; + void loop() override; +}; + +void UpstreamStockBridge::loop() { + // Guard against uninitialized state + if (_initialized == false) { + return; + } + + while (_serial->available()) { + uint8_t b = _serial->read(); + + if (_rx_buffer_pos < 2) { + // Waiting for magic word + if ((_rx_buffer_pos == 0 && b == ((BRIDGE_PACKET_MAGIC >> 8) & 0xFF)) || + (_rx_buffer_pos == 1 && b == (BRIDGE_PACKET_MAGIC & 0xFF))) { + _rx_buffer[_rx_buffer_pos++] = b; + } else { + // Invalid magic byte, reset and start over + _rx_buffer_pos = 0; + // Check if this byte could be the start of a new magic word + if (b == ((BRIDGE_PACKET_MAGIC >> 8) & 0xFF)) { + _rx_buffer[_rx_buffer_pos++] = b; + } + } + } else { + // Reading length, payload, and checksum + _rx_buffer[_rx_buffer_pos++] = b; + + if (_rx_buffer_pos >= 4) { + uint16_t len = (_rx_buffer[2] << 8) | _rx_buffer[3]; + + // Validate length field + if (len > (MAX_TRANS_UNIT + 1)) { + BRIDGE_DEBUG_PRINTLN("RX invalid length %d, resetting\n", len); + _rx_buffer_pos = 0; // Invalid length, reset + continue; + } + + if (_rx_buffer_pos == len + SERIAL_OVERHEAD) { // Full packet received + uint16_t received_checksum = (_rx_buffer[4 + len] << 8) | _rx_buffer[5 + len]; + + if (validateChecksum(_rx_buffer + 4, len, received_checksum)) { + BRIDGE_DEBUG_PRINTLN("RX, len=%d crc=0x%04x\n", len, received_checksum); + mesh::Packet *pkt = _mgr->allocNew(); + if (pkt) { + if (pkt->readFrom(_rx_buffer + 4, len)) { + onPacketReceived(pkt); + } else { + BRIDGE_DEBUG_PRINTLN("RX failed to parse packet\n"); + _mgr->free(pkt); + } + } else { + BRIDGE_DEBUG_PRINTLN("RX failed to allocate packet\n"); + } + } else { + BRIDGE_DEBUG_PRINTLN("RX checksum mismatch, rcv=0x%04x\n", received_checksum); + } + _rx_buffer_pos = 0; // Reset for next packet + } + } + } + } +} diff --git a/test/fixtures/rs232_mqtt_runtime.cpp b/test/fixtures/rs232_mqtt_runtime.cpp index 2904ec461..cb5266c1e 100644 --- a/test/fixtures/rs232_mqtt_runtime.cpp +++ b/test/fixtures/rs232_mqtt_runtime.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +struct FakeRng { void random(uint8_t* out, size_t size) { memset(out, 0x42, size); } }; #define BRIDGE_MAX_BAUD 115200 #define PUB_KEY_SIZE 32 #define MESH_DEBUG_PRINTLN(...) ((void)0) @@ -24,6 +26,9 @@ struct StrHelper { struct NodePrefs { uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_bridge_enabled = 0; uint16_t bridge_delay = 0; + uint8_t powersaving_enabled = 1, rs232_autosleep = 1; + uint8_t rs232_heartbeat = 1; + uint16_t rs232_idle_seconds = 300; uint8_t bridge_pkt_src = 0, bridge_uart = 0, bridge_channel = 0, bridge_format = 0; uint32_t bridge_baud = 0; float freq = 915, bw = 125; @@ -66,6 +71,16 @@ class RS232Bridge : public AbstractBridge { int rx, tx; bool running = false; public: + using PowerAction = mesh::bridge::RS232PowerPolicy::Action; + mesh::bridge::RS232PowerPolicy power; + void setHeartbeatSession(uint64_t) {} + void setHeartbeatEnabled(bool) {} + PowerAction servicePowerPolicy(bool allowed, uint32_t idle, uint32_t now, bool force = false) { + return power.service(allowed && force, idle, now, running, false); + } + void completePowerAction(PowerAction action, bool success, uint32_t now) { power.complete(action, success, now); } + uint32_t powerWakeDelayMs(uint32_t now) const { return power.wakeDelay(now); } + void formatHeartbeatStatus(char* out, size_t size, uint32_t) const { snprintf(out, size, "state=active peer=unknown"); } inline static unsigned live = 0, fail_starts = 0, fail_allocations = 0; static void* operator new(size_t size, const std::nothrow_t&) noexcept { if (fail_allocations) { --fail_allocations; return nullptr; } @@ -179,6 +194,7 @@ struct Callbacks { virtual bool isRs232BridgeRunning() const = 0; virtual bool setRs232BridgeState(bool) = 0; virtual bool restartRs232Bridge() = 0; + virtual void formatRs232Status(char*) = 0; virtual bool isEspNowBridgeRunning() = 0; virtual bool setEspNowBridgeState(bool) = 0; virtual bool restartEspNowBridge() = 0; @@ -203,6 +219,8 @@ public: mesh::LocalIdentity self_id; void* _mgr = nullptr; void* _radio = nullptr; void* _ms = nullptr; void* getRTCClock() { return nullptr; } + FakeRng rng; + FakeRng* getRNG() { return &rng; } mesh::LocalIdentity getSelfId() { return self_id; } const char* getFirmwareVer() { return "test"; } const char* getBuildDate() { return "test"; } diff --git a/test/fixtures/rs232_power_settings_cli.cpp b/test/fixtures/rs232_power_settings_cli.cpp new file mode 100644 index 000000000..c57f70d6a --- /dev/null +++ b/test/fixtures/rs232_power_settings_cli.cpp @@ -0,0 +1,109 @@ +@PARSERS@ +struct CommonCLI { + NodePrefs* _prefs; + MyMesh* _callbacks; + FakeSensors* _sensors; + bool fail_save = false; + unsigned saves = 0; + NodePrefs stored; + explicit CommonCLI(MyMesh& node) : _prefs(&node._prefs), _callbacks(&node), _sensors(&node.sensors) {} + bool trySavePrefs() { + if (fail_save) return false; + ++saves; stored = *_prefs; return true; + } + void savePrefs() { ++saves; stored = *_prefs; } + void set(const char* input, char* reply) { + char config[256] = {}; assert(strlen(input) < sizeof(config)); strcpy(config, input); + @SETS@ + strcpy(reply, "Unknown"); + } + void get(const char* config, char* reply) { + @GETS@ + else strcpy(reply, "Unknown"); + } +}; +static void expect(CommonCLI& cli, const char* key, const char* value) { + char reply[160] = {}; cli.get(key, reply); assert(strcmp(reply, value) == 0); +} +static void standby(MyMesh& node) { + assert(node.bridge && node.isRs232BridgeRunning()); + node._prefs.rs232_autosleep = 1; + node.bridge->capability_armed = true; + node.bridge->requested = RS232Bridge::PowerAction::Suspend; + node.serviceRs232BridgePower(); + assert(node.bridge && !node.isRs232BridgeRunning() && node.bridge->isPowerSuspended()); +} +int main() { + { + MyMesh node; CommonCLI cli(node); char reply[160] = {}; + expect(cli, "rs232.heartbeat", "> on"); + expect(cli, "rs232.autosleep", "> on"); + expect(cli, "rs232.idle", "> 300s"); + for (const char* input : {"rs232.heartbeat on junk", "rs232.heartbeat off ", + "rs232.heartbeat -1", "rs232.heartbeat 1", + "rs232.autosleep on junk", "rs232.autosleep off ", + "rs232.autosleep -1", "rs232.autosleep 999", + "rs232.autosleep force junk", "rs232.autosleep force "}) { + const unsigned saves = cli.saves; cli.set(input, reply); + if (strncmp(reply, "Error:", 6) != 0 || cli.saves != saves) fprintf(stderr, "Unexpected boolean: %s => %s\n", input, reply); + assert(strncmp(reply, "Error:", 6) == 0 && cli.saves == saves); + assert(node._prefs.rs232_heartbeat == 1 && node._prefs.rs232_autosleep == 1); + } + for (const char* suffix : {"-1", "59", "3601", "4294967296", + "18446744073709551615", "60 seconds", "", "nan"}) { + char input[128]; snprintf(input, sizeof(input), "rs232.idle %s", suffix); + const unsigned saves = cli.saves; cli.set(input, reply); + if (strncmp(reply, "Error:", 6) != 0 || cli.saves != saves) fprintf(stderr, "Unexpected idle: %s => %s\n", input, reply); + assert(strncmp(reply, "Error:", 6) == 0 && cli.saves == saves); + assert(node._prefs.rs232_idle_seconds == 300); + } + for (const char* input : {"rs232.idle +60", "rs232.idle 60 ", "rs232.idle 60\t"}) { + cli.set(input, reply); assert(strncmp(reply, "OK", 2) == 0); + assert(node._prefs.rs232_idle_seconds == 60); + } + cli.set("rs232.heartbeat off", reply); assert(strncmp(reply, "OK", 2) == 0); + expect(cli, "rs232.heartbeat", "> off"); + cli.set("rs232.autosleep on", reply); assert(strncmp(reply, "OK", 2) == 0); + expect(cli, "rs232.autosleep", "> on"); + cli.set("rs232.autosleep force", reply); assert(strncmp(reply, "OK", 2) == 0); + expect(cli, "rs232.autosleep", "> force"); + for (uint16_t seconds : {60, 300, 999, 3600}) { + char input[64]; snprintf(input, sizeof(input), "rs232.idle %u", seconds); + cli.set(input, reply); assert(strncmp(reply, "OK", 2) == 0); + assert(node._prefs.rs232_idle_seconds == seconds && cli.stored.rs232_idle_seconds == seconds); + } + cli.fail_save = true; + cli.set("rs232.heartbeat on", reply); assert(strncmp(reply, "Error:", 6) == 0); + assert(node._prefs.rs232_heartbeat == 0); + cli.set("rs232.autosleep off", reply); assert(strncmp(reply, "Error:", 6) == 0); + assert(node._prefs.rs232_autosleep == 2); + cli.set("rs232.idle 60", reply); assert(strncmp(reply, "Error:", 6) == 0); + assert(node._prefs.rs232_idle_seconds == 3600); + } + for (const char* command : {"rs232.enabled off", "rs232.enabled on", + "bridge.enabled off", "bridge.enabled on", + "bridge.baud 57600", "bridge.uart 1", "bridge.uart 2"}) { + MyMesh node; CommonCLI cli(node); char reply[160] = {}; + node._prefs.bridge_enabled = node._prefs.rs232_bridge_enabled = 1; + node._prefs.bridge_baud = 115200; + assert(node.setRs232BridgeState(true)); standby(node); + cli.fail_save = true; cli.set(command, reply); +#ifdef WITH_MQTT_BRIDGE + if (strncmp(command, "bridge.enabled", 14) == 0) { + // This alias belongs to MQTT, not UART. It must not delete/wake RS232. + assert(node.bridge && node.bridge->isPowerSuspended() && !node.isRs232BridgeRunning()); + continue; + } +#endif + assert(strncmp(reply, "Error:", 6) == 0); + assert(node._prefs.bridge_enabled && node._prefs.rs232_bridge_enabled); + if (!node.bridge || !node.isRs232BridgeRunning()) fprintf(stderr, "Lost standby owner: %s => %s\n", command, reply); + assert(node.bridge && node.isRs232BridgeRunning()); + assert(node._prefs.bridge_uart == 2 && node.active_rs232_bridge_uart == 2); + assert(node._prefs.bridge_baud == 115200); + assert(!Serial1.running && Serial2.running); + cli.get("rs232.status", reply); + assert(strstr(reply, "saved=on running=on") && strstr(reply, "autosleep=on")); + } + assert(RS232Bridge::live == 0); +} diff --git a/test/fixtures/rs232_power_settings_prefs.cpp b/test/fixtures/rs232_power_settings_prefs.cpp new file mode 100644 index 000000000..2e9591045 --- /dev/null +++ b/test/fixtures/rs232_power_settings_prefs.cpp @@ -0,0 +1,126 @@ +struct Capture { + std::vector bytes; + std::map offsets; + size_t write(const uint8_t* data, size_t size) { + offsets[data] = bytes.size(); bytes.insert(bytes.end(), data, data + size); return size; + } + size_t offset(const void* field) const { return offsets.at(field); } +}; +static void defaults(const NodePrefs& prefs) { + assert(prefs.rs232_heartbeat == 1); + assert(prefs.rs232_autosleep == 1); + assert(prefs.rs232_idle_seconds == 300); +} +static void seed(CommonCLI& cli) { + strcpy(cli.prefs.node_name, "UART power node"); + strcpy(cli.prefs.password, "same-secret"); + cli.prefs.bridge_uart = 2; cli.prefs.bridge_enabled = 1; + cli.prefs.rs232_bridge_enabled = 1; cli.prefs.bridge_baud = 57600; + setDefaultDirectRetryPrefs(&cli.prefs); +} +static void read(CommonCLI& cli, const std::vector& image) { + // Reused objects must not retain a previous opt-in after any failed read. + cli.prefs.rs232_heartbeat = 0; cli.prefs.rs232_autosleep = 1; + cli.prefs.rs232_idle_seconds = 3600; + cli.fs.files["/com_prefs"] = image; + cli.loadPrefsInt(&cli.fs, "/com_prefs"); + assert(strcmp(cli.prefs.node_name, "UART power node") == 0); + assert(strcmp(cli.prefs.password, "same-secret") == 0); + assert(cli.prefs.rs232_bridge_enabled == 1 && cli.prefs.bridge_uart == 2); +} +int main() { + using namespace mesh::bridge; + CommonCLI writer; seed(writer); + defaults(writer.prefs); + Capture baseline; assert(writeCommonPrefsImage(baseline, &writer.prefs)); + assert(rs232power::PREFS_SIZE == 10 && baseline.bytes.size() == 887); + assert(baseline.offset(writer.prefs.node_name) == 4); + assert(baseline.offset(&writer.prefs.freq) == 72); + assert(baseline.offset(&writer.prefs.bridge_enabled) == 127); + assert(baseline.offset(&writer.prefs.ota_channel) == 873); + assert(baseline.bytes[875] == 1 && baseline.bytes[876] == 0xB1); + assert(baseline.bytes[877] == 'R' && baseline.bytes[878] == 'S'); + CommonCLI reader; + std::vector legacy(baseline.bytes.begin(), baseline.bytes.begin() + 877); + read(reader, legacy); defaults(reader.prefs); + for (uint8_t heartbeat : {0, 1}) { + for (uint8_t autosleep : {0, 1, 2}) { + for (uint16_t idle : {60, 300, 3600}) { + writer.prefs.rs232_heartbeat = heartbeat; + writer.prefs.rs232_autosleep = autosleep; + writer.prefs.rs232_idle_seconds = idle; + writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common); + assert(writer._common_save_succeeded); + Capture checked; assert(writeCommonPrefsImage(checked, &writer.prefs)); + assert(checked.bytes == writer.fs.files.at("/com_prefs")); + assert(checked.bytes.size() == baseline.bytes.size()); + for (size_t offset = 0; offset < 877; ++offset) { + assert(checked.bytes[offset] == baseline.bytes[offset]); + } + read(reader, checked.bytes); + assert(reader.prefs.rs232_heartbeat == heartbeat); + assert(reader.prefs.rs232_autosleep == autosleep); + assert(reader.prefs.rs232_idle_seconds == idle); + } + } + } + writer.prefs.rs232_heartbeat = 0; writer.prefs.rs232_autosleep = 1; + writer.prefs.rs232_idle_seconds = 60; + Capture current; assert(writeCommonPrefsImage(current, &writer.prefs)); + for (size_t length = 877; length < 887; ++length) { + read(reader, {current.bytes.begin(), current.bytes.begin() + length}); + defaults(reader.prefs); + } + for (size_t offset = 877; offset < 887; ++offset) { + auto bad = current.bytes; bad[offset] ^= 1; + read(reader, bad); defaults(reader.prefs); + } + // Checked but unsupported versions/booleans/ranges are also fail-closed. + for (auto entry : {std::pair{880, 2}, {881, 2}, {882, 3}, + {884, 0}, {883, 255}}) { + auto bad = current.bytes; bad[entry.first] = entry.second; + uint16_t crc = rs232heartbeat::checksum(bad.data() + 877, 8); + bad[885] = crc >> 8; bad[886] = crc; + read(reader, bad); defaults(reader.prefs); + } + for (int offset = 877; offset < 887; ++offset) { + reader.fs.fail_read_after = offset; + read(reader, current.bytes); defaults(reader.prefs); + } + reader.fs.fail_read_after = -1; + reader.prefs.rs232_heartbeat = 0; reader.prefs.rs232_autosleep = 1; + reader.prefs.rs232_idle_seconds = 3600; + reader.loadPrefsInt(&reader.fs, "/missing"); defaults(reader.prefs); + reader.fs.fail_read_open = true; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); defaults(reader.prefs); + + for (int fault = 0; fault < 6; ++fault) { + for (int offset = 877; offset < 887; ++offset) { + CommonCLI transaction; seed(transaction); + transaction.savePrefs(&transaction.fs, PrefsSaveRouting::Scope::Common); + assert(transaction._common_save_succeeded); + const auto previous = transaction.fs.files.at("/com_prefs"); + transaction.prefs.rs232_heartbeat = 0; transaction.prefs.rs232_autosleep = 1; + transaction.prefs.rs232_idle_seconds = 60; + if (fault == 0) transaction.fs.fail_write = true; + if (fault == 1) transaction.fs.fail_write_after = offset; + if (fault == 2) transaction.fs.fail_read_open = true; + if (fault == 3) { +#ifdef WITH_MQTT_BRIDGE + continue; // Observer verifies reopened size; it does not re-read CRC bytes. +#else + transaction.fs.fail_read_after = offset; +#endif + } + if (fault == 4) transaction.fs.fail_rename = 1; + if (fault == 5) transaction.fs.fail_rename_from = {"/com_prefs.tmp"}; + assert(!transaction.trySavePrefs()); + assert(transaction.fs.files.at("/com_prefs") == previous); + transaction.fs.fail_write = false; transaction.fs.fail_write_after = -1; + transaction.fs.fail_read_open = false; transaction.fs.fail_read_after = -1; + transaction.fs.fail_rename = 0; transaction.fs.fail_rename_from.clear(); + assert(transaction.recoverCommonPrefsFiles(&transaction.fs)); + read(transaction, previous); defaults(transaction.prefs); + } + } +} diff --git a/test/fixtures/rs232_runtime/json.cpp b/test/fixtures/rs232_runtime/json.cpp index ae8c49fd7..b52083cd8 100644 --- a/test/fixtures/rs232_runtime/json.cpp +++ b/test/fixtures/rs232_runtime/json.cpp @@ -4,6 +4,7 @@ #include #include #include +#include struct NodePrefs : ConfigSerializer { uint8_t bridge_enabled = 0, bridge_uart = 2, espnow_bridge_enabled = 0, rs232_bridge_enabled = 0; @@ -11,6 +12,9 @@ struct NodePrefs : ConfigSerializer { uint8_t bridge_pkt_src = 1, bridge_channel = 1, bridge_format = 0; uint32_t bridge_baud = 115200; uint8_t usb_logging_enabled = 0, usb_debug_enabled = 0; + uint8_t rs232_autosleep = 1; + uint8_t rs232_heartbeat = 1; + uint16_t rs232_idle_seconds = 300; char bridge_secret[16] = "test-secret", node_name[32] = "before import"; @BRIDGE_SCHEMA@ BridgePrefs bridge; diff --git a/test/fixtures/rs232_runtime/main.cpp b/test/fixtures/rs232_runtime/main.cpp index 09a54e57d..7f66c460c 100644 --- a/test/fixtures/rs232_runtime/main.cpp +++ b/test/fixtures/rs232_runtime/main.cpp @@ -5,6 +5,11 @@ #include #include #include +#include + +static uint32_t fake_millis = 100; +static uint32_t millis() { return fake_millis; } +struct FakeRng { void random(uint8_t* out, size_t size) { memset(out, 0x42, size); } }; #define WITH_BRIDGE 1 #define WITH_RS232_BRIDGE Serial2 @@ -31,6 +36,9 @@ template T constrain(T value, int low, int high) { struct NodePrefs { uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_bridge_enabled = 0; uint16_t bridge_delay = 0; + uint8_t powersaving_enabled = 1, rs232_autosleep = 1; + uint8_t rs232_heartbeat = 1; + uint16_t rs232_idle_seconds = 300; uint8_t bridge_pkt_src = 0, bridge_uart = 0, bridge_channel = 0, bridge_format = 0; uint32_t bridge_baud = 0; char bridge_secret[32] = {}; @@ -67,6 +75,16 @@ class RS232Bridge : public AbstractBridge { int rx, tx; bool running = false; public: + using PowerAction = mesh::bridge::RS232PowerPolicy::Action; + mesh::bridge::RS232PowerPolicy power; + void setHeartbeatSession(uint64_t) {} + void setHeartbeatEnabled(bool) {} + PowerAction servicePowerPolicy(bool allowed, uint32_t idle, uint32_t now, bool force = false) { + return power.service(allowed && force, idle, now, running, false); + } + void completePowerAction(PowerAction action, bool success, uint32_t now) { power.complete(action, success, now); } + uint32_t powerWakeDelayMs(uint32_t now) const { return power.wakeDelay(now); } + void formatHeartbeatStatus(char* out, size_t size, uint32_t) const { snprintf(out, size, "state=active peer=unknown"); } inline static unsigned live = 0; inline static unsigned fail_starts = 0; unsigned loops = 0, sent = 0; @@ -102,8 +120,6 @@ public: }; #ifdef WITH_ESPNOW_BRIDGE -static uint32_t fake_millis = 100; -static uint32_t millis() { return fake_millis; } static bool millisHasNowPassed(uint32_t deadline) { return fake_millis >= deadline; } #endif struct FakeBoard { bool ota = false; bool isOTAUpdateRunning() const { return ota; } }; @@ -120,6 +136,7 @@ struct Callbacks { virtual bool isRs232BridgeRunning() const = 0; virtual bool setRs232BridgeState(bool) = 0; virtual bool restartRs232Bridge() = 0; + virtual void formatRs232Status(char*) = 0; #ifdef WITH_ESPNOW_BRIDGE virtual bool isEspNowBridgeRunning() = 0; virtual bool setEspNowBridgeState(bool) = 0; @@ -139,6 +156,8 @@ public: uint32_t shared_espnow_retry_at = 0; #endif void* _mgr = nullptr; + FakeRng rng; + FakeRng* getRNG() { return &rng; } void* getRTCClock() { return nullptr; } void configureBridgeFilter(AbstractBridge*) {} void defaults() { @DEFAULTS@ } diff --git a/test/fixtures/rs232_runtime/prefs.cpp b/test/fixtures/rs232_runtime/prefs.cpp index 11c662f8c..e59ad505c 100644 --- a/test/fixtures/rs232_runtime/prefs.cpp +++ b/test/fixtures/rs232_runtime/prefs.cpp @@ -56,7 +56,12 @@ int main(int argc, char** argv) { const size_t esp_marker_offset = channel_offset + 1; const size_t rs_intent_offset = initial.offset(&writer.prefs.rs232_bridge_enabled); const size_t rs_marker_offset = channel_offset + 3; - assert(initial.bytes.size() == 877 && channel_offset == 873); + assert(channel_offset == 873); +#ifdef WITH_RS232_BRIDGE + assert(initial.bytes.size() == 877 + mesh::bridge::rs232power::PREFS_SIZE); +#else + assert(initial.bytes.size() == 877); +#endif assert(rs_intent_offset == channel_offset + 2); #ifdef ESPNOW_BRIDGE_MERGED assert(initial.bytes[esp_marker_offset] == 0xA1); diff --git a/test/fixtures/sensor_tracker_client.h b/test/fixtures/sensor_tracker_client.h index 4b02a8933..bb66a11ac 100644 --- a/test/fixtures/sensor_tracker_client.h +++ b/test/fixtures/sensor_tracker_client.h @@ -163,7 +163,12 @@ public: FakeTrigger alert1, alert2; FakeTrigger* alert_tasks[4] = {&alert1, &alert2}; int num_alert_tasks = 2; - struct CLI { bool active = false; bool hasActiveUserGpioTimer() const { return active; } } _cli; + struct CLI { + struct NoMorseReply { void complete(const void*) {} void fail(const void*) {} } morse; + NoMorseReply& morseReply() { return morse; } + bool active = false; + bool hasActiveUserGpioTimer() const { return active; } + } _cli; struct ClockSync { unsigned observed = 0; void observeAcceptedFlood(const mesh::Packet*) { ++observed; } } _clock_sync; uint32_t set_radio_at = 0, revert_radio_at = 0; diff --git a/test/morse_rx_floor_step_experiment.py b/test/morse_rx_floor_step_experiment.py new file mode 100644 index 000000000..e3bc7005b --- /dev/null +++ b/test/morse_rx_floor_step_experiment.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Read-only lab A/B: compile temporary decoder copies, never edit firmware. + +This diagnostic intentionally reports failing cases rather than teaching CI to +accept a known decoding defect. The ordinary test_morse_rx.py remains the +shipping-code regression suite. +""" +from pathlib import Path +import argparse +import unittest + +from test_morse_tx import compile_run +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +def experimental_estimator(original, *, seed=True): + """Core's proposed two-level estimator, applied only to a temporary copy.""" + if "if (_keyed && !_floor_settled)" in original: + return original if seed else original.replace(" _signal = rssi_dbm;\n", "", 1) + if "if (_keyed && threshold < _signal - 5.0f)" in original: + return original if seed else original.replace(" _signal = rssi_dbm;\n", "", 1) + threshold = "const float threshold = _noise + rise - (_keyed ? 3.0f : 0.0f);" + signal = "if (_keyed && rssi_dbm > _noise + 6.0f)" + off = " _keyed = false;\n _last_edge = edge_at;" + on = " _keyed = true;\n _last_edge = edge_at;" + assert all(fragment in original for fragment in (threshold, signal, off, on)), "update experiment for the current decoder" + header = original.replace(threshold, "float threshold = _noise + rise - (_keyed ? 3.0f : 0.0f);\n if (_keyed) threshold=fmaxf(threshold,_signal-5.0f);") + header = header.replace(signal, "if (_keyed && raw_keyed && rssi_dbm > _noise + 6.0f)") + header = header.replace(off, " _keyed = false;\n _noise=rssi_dbm; _signal=_noise+12.0f;\n _last_edge = edge_at;") + if seed: + header = header.replace(on, " _keyed = true; _signal=rssi_dbm;\n _last_edge = edge_at;") + return header + + +def seeded_peak_baseline(original): + """Recreate the earlier approved peak-5 estimator for historical A/B.""" + if "if (_keyed && !_floor_settled)" not in original: + return original + header=original.replace(method(original,"if (_keyed && !_floor_settled)"), + "if (_keyed && threshold < _signal - 5.0f) threshold = _signal - 5.0f;") + header=header.replace("const float span = _signal - _noise;\n float rise = span * 0.5f;", + "float rise = (_signal - _noise) * 0.35f;") + header=header.replace(" if (rise < 6.0f) rise = 6.0f;", + " if (rise < 6.0f) rise = 6.0f;\n if (rise > 24.0f) rise = 24.0f;") + header=header.replace(" const uint16_t maximum_debounce = raw_keyed ? 5 : 10;\n if (debounce > maximum_debounce) debounce = maximum_debounce;", + " if (debounce > 5) debounce = 5;") + return header + + +def legacy_estimator(original): + """Reconstruct the pre-fix behavior as an executable negative control.""" + header = original + if "if (_keyed && !_floor_settled)" in header: + header=header.replace(method(header,"if (_keyed && !_floor_settled)"),"") + header=header.replace("const float span = _signal - _noise;\n float rise = span * 0.5f;", + "float rise = (_signal - _noise) * 0.35f;") + header=header.replace(" if (rise < 6.0f) rise = 6.0f;", + " if (rise < 6.0f) rise = 6.0f;\n if (rise > 24.0f) rise = 24.0f;") + header = header.replace(" if (_keyed && threshold < _signal - 5.0f) threshold = _signal - 5.0f;\n", "") + header = header.replace("float threshold = _noise + rise - (_keyed ? 3.0f : 0.0f);", + "const float threshold = _noise + rise - (_keyed ? 3.0f : 0.0f);") + header = header.replace("if (_keyed && raw_keyed && rssi_dbm > _noise + 6.0f)", + "if (_keyed && rssi_dbm > _noise + 6.0f)") + header = header.replace(" _signal = rssi_dbm;\n", "", 1) + header = header.replace(" _noise = rssi_dbm;\n _signal = _noise + 12.0f; // do not hide the next, weaker mark with an old peak\n", "") + header=header.replace(" _noise = rssi_dbm;\n _floor_settled = true;\n _signal = _noise + 12.0f; // do not hide the next, weaker mark with an old peak\n","") + quiet = method(header, "if (!_keyed && !raw_keyed && !_candidate_valid)") + header = header.replace(quiet, quiet.replace(" _signal = _noise + 12.0f;\n", "")) + return header + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--estimator", action="store_true", help="test the isolated seeded peak-relative design and existing decoder regressions") + parser.add_argument("--mark-fade", action="store_true", help="quantify strong carrier fades against an isolated uncapped midpoint proposal") + parser.add_argument("--hybrid-matrix",action="store_true",help="test provisional span/first-OFF hybrid variants without editing firmware") + parser.add_argument("--variant-filter",help="Run only isolated variant names containing this string") + parser.add_argument("--actual-matrix",action="store_true",help="report the current shipping decoder's exact capture and internal edges") + args = parser.parse_args() + code = (ROOT / "test/fixtures/morse_rx_floor_step.cpp").read_text(encoding="ascii") + original = (ROOT / "src/helpers/MorseRx.h").read_text(encoding="utf-8") + tx = (ROOT / "src/helpers/MorseTx.h").read_text(encoding="utf-8") + if args.actual_matrix: + fixture=(ROOT / "test/fixtures/morse_rx_mark_fade.cpp").read_text(encoding="ascii") + report=compile_run(unittest.TestCase(),fixture,flags=("-DMESH_MORSE_MAX_WPM=240","-DMORSE_HYBRID_MATRIX")) + rows=[row for row in report.splitlines() if "required=1" in row] + print(f"Actual matrix {sum('correct=1' in row for row in rows)}/{len(rows)} exact captures") + print("\n".join(row for row in rows if "correct=0" in row or "cuts=0 " not in row or "false_marks=0 " not in row)) + return + if args.mark_fade or args.hybrid_matrix: + fixture=(ROOT / "test/fixtures/morse_rx_mark_fade.cpp").read_text(encoding="ascii") + original=seeded_peak_baseline(original) + midpoint=original.replace("(_signal - _noise) * 0.35f","(_signal - _noise) * 0.5f") + midpoint=midpoint.replace(" if (rise > 24.0f) rise = 24.0f;\n","") + midpoint=midpoint.replace(" if (_keyed && threshold < _signal - 5.0f) threshold = _signal - 5.0f;\n","") + assert midpoint!=original,"update midpoint experiment for actual source" + variants=[("current seeded peak-5dB",original),("isolated uncapped midpoint / minus3dB OFF",midpoint)] + if args.hybrid_matrix: + anchor=" const bool raw_keyed = rssi_dbm >= threshold;" + peak=" if (_keyed && _signal-_noise <= 40.0f && threshold < _signal-5.0f) threshold=_signal-5.0f;\n" + hybrid=midpoint.replace(anchor,peak+anchor) + first_only=hybrid.replace("_keyed && _signal-_noise <= 40.0f","_keyed && !_have_floor_after_mark && _signal-_noise <= 40.0f") + first_only=first_only.replace(" bool _active = false, _keyed = false, _have_noise = false;", + " bool _have_floor_after_mark=false;\n bool _active = false, _keyed = false, _have_noise = false;") + first_only=first_only.replace(" _have_noise = _candidate_valid = _keyed = false;", + " _have_floor_after_mark=false;\n _have_noise = _candidate_valid = _keyed = false;") + first_only=first_only.replace(" _keyed = false;\n _noise = rssi_dbm;\n", + " _keyed = false;\n _have_floor_after_mark=true;\n _noise = rssi_dbm;\n") + variants += [("hybrid span<=40 peak bound",hybrid),("hybrid bound only before first OFF",first_only)] + falling_quarter=first_only.replace(" if (debounce > 5) debounce = 5;", + " if (raw_keyed && debounce > 5) debounce = 5;") + assert falling_quarter!=first_only,"update isolated falling debounce for actual decoder" + variants.append(("first-OFF hybrid with uncapped quarter-dot falling debounce",falling_quarter)) + falling_ten=first_only.replace(" if (debounce > 5) debounce = 5;", + " if (debounce > (raw_keyed?5:10)) debounce = raw_keyed?5:10;") + smooth=first_only.replace(" if (_keyed && !_have_floor_after_mark && _signal-_noise <= 40.0f && threshold < _signal-5.0f) threshold=_signal-5.0f;", + " if (_keyed && !_have_floor_after_mark) {\n const float margin=fmaxf(5.0f,(_signal-_noise-30.0f)*0.5f);\n if (threshold < _signal-margin) threshold=_signal-margin;\n }") + assert smooth!=first_only,"update smooth provisional margin experiment" + variants += [("first-OFF hybrid with falling debounce capped10ms",falling_ten), + ("first-OFF smooth provisional margin",smooth)] + smooth_ten=smooth.replace(" if (debounce > 5) debounce = 5;", + " if (debounce > (raw_keyed?5:10)) debounce = raw_keyed?5:10;") + variants.append(("smooth provisional margin plus falling debounce capped10ms",smooth_ten)) + for name,header in variants: + if args.variant_filter and args.variant_filter not in name: + continue + print(name) + flags=("-DMESH_MORSE_MAX_WPM=240","-DMORSE_HYBRID_MATRIX") if args.hybrid_matrix else () + result=compile_run(unittest.TestCase(),fixture,flags=flags,headers={"helpers/MorseRx.h":header,"helpers/MorseTx.h":tx}) + if not args.hybrid_matrix: + print(result,end="") + continue + rows=[row for row in result.splitlines() if "required=1" in row]; failed=[row for row in rows if "correct=0" in row] + print(f"Matrix {len(rows)-len(failed)}/{len(rows)} exact for SNR>=6dB; failures:") + print("\n".join(failed[:24])) + for test_fixture in ("morse_rx_decoder.cpp","morse_rx_decay_shoulder.cpp","morse_rx_floor_step.cpp"): + test_code=(ROOT / "test/fixtures" / test_fixture).read_text(encoding="ascii") + extra=("-DMORSE_SHOULDER_REQUIRE_PASS",) if "shoulder" in test_fixture else (("-DMORSE_ESTIMATOR_REQUIRE_PASS","-DMORSE_ESTIMATOR_EXTENDED") if "floor_step" in test_fixture else ()) + try: + compile_run(unittest.TestCase(),test_code,flags=("-DMESH_MORSE_MAX_WPM=240",)+extra, + headers={"helpers/MorseRx.h":header,"helpers/MorseTx.h":tx}) + print(test_fixture+": PASS") + except AssertionError as error: + print(test_fixture+": FAIL "+str(error)[:250]) + if "floor_step" in test_fixture: + report=compile_run(unittest.TestCase(),test_code,flags=("-DMESH_MORSE_MAX_WPM=240","-DMORSE_ESTIMATOR_EXTENDED"), + headers={"helpers/MorseRx.h":header,"helpers/MorseTx.h":tx}) + print("\n".join(row for row in report.splitlines() if 'capture="SOS TEST 25"' not in row and 'capture="SOS TEST 25 "' not in row)) + elif "decoder" in test_fixture and ("quarter-dot" in name or "capped10ms" in name): + tail=test_code.replace('rx.pattern("."); rx.quiet(10);','rx.pattern("."); rx.quiet(14);') + try: + compile_run(unittest.TestCase(),tail,flags=("-DMESH_MORSE_MAX_WPM=240",), + headers={"helpers/MorseRx.h":header,"helpers/MorseTx.h":tx}) + print("Full decoder with explicitly confirmed14ms OFF tail: PASS") + except AssertionError as tail_error: + print("Full decoder with confirmed14ms OFF tail: FAIL "+str(tail_error)[:250]) + return + cap_expression = "if (rise > 24.0f) rise = 24.0f;" + signal_expression = "if (_keyed && rssi_dbm > _noise + 6.0f)" + if args.estimator: + header = experimental_estimator(original) + print("Current approved estimator; this experiment does not edit shipping firmware") + headers = {"helpers/MorseRx.h": header, "helpers/MorseTx.h": tx} + print(compile_run(unittest.TestCase(), code, + flags=("-DMESH_MORSE_MAX_WPM=240", "-DMORSE_ESTIMATOR_EXTENDED", "-DMORSE_ESTIMATOR_REQUIRE_PASS"), + headers=headers), end="") + normal = (ROOT / "test/fixtures/morse_rx_decoder.cpp").read_text(encoding="ascii") + print(compile_run(unittest.TestCase(), normal, headers=headers), end="") + return + original = legacy_estimator(original) + assert cap_expression in original and signal_expression in original, "update experiment for the current decoder" + for freeze in (False, True): + for cap in (24, 40, 60): + header = original.replace(cap_expression, f"if (rise > {cap}.0f) rise = {cap}.0f;") + if freeze: + header = header.replace(signal_expression, "if (_keyed && raw_keyed && rssi_dbm > _noise + 6.0f)") + print(f"CAP {cap}; freeze signal during OFF debounce: {freeze}") + print(compile_run(unittest.TestCase(), code, headers={"helpers/MorseRx.h": header, + "helpers/MorseTx.h": tx}), end="") + + +if __name__ == "__main__": + main() diff --git a/test/test_carrier_wave.py b/test/test_carrier_wave.py index 774edc133..1f16d749c 100644 --- a/test/test_carrier_wave.py +++ b/test/test_carrier_wave.py @@ -14,43 +14,95 @@ HARNESS = r''' #include #include #include +#include +#include #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 #define RADIOLIB_ERR_NONE 0 #define RADIOLIB_ERR_INVALID_OUTPUT_POWER -1 +#define RADIOLIB_ERR_INVALID_FREQUENCY -2 +#define RADIOLIB_ERR_UNKNOWN -16 #define SX127X_CURRENT_LIMIT 120 #define LORA_TX_POWER 17 #define MESH_DEBUG_PRINTLN(...) ((void)0) static void setFlag() {} struct Board { bool awake=false; unsigned before=0, after=0; + bool frequency_ok=true; unsigned frequency_prepares=0; + bool prepareRadioFrequency(float) { ++frequency_prepares; return frequency_ok; } void setRadioTestActive(bool value) { awake=value; } void onBeforeTransmit() { ++before; } void onAfterTransmit() { ++after; } }; struct Chip { int start_result=0, stop_result=0, fsk_result=0, power_result=0, limit_result=0; + int standby_result=0, frequency_result=0, irq_result=0, ramp_result=0, rx_result=0; + int warm_standby_result=0; unsigned warm_standby_calls=0; + uint8_t last_standby_mode=0; bool standbyXOSC=false; + uint32_t tx_latency=0, standby_latency=0; int power=17, initial_fsk_power=0, current_limit=60; + int ramp_time=4; + unsigned ramp_calls=0, ramp_power_calls=0; unsigned starts=0, stops=0, standby_calls=0, fsk_starts=0; + unsigned raw_rx_starts=0; + unsigned admission_order=0,last_receive_order=0; + unsigned morse_ramp_calls=0; float frequency=0, deviation=5; bool carrier=false, fsk=false, ook=true, rfo=false; + int startReceive() { last_receive_order=++admission_order; ++raw_rx_starts; return rx_result; } + int startReceive(uint32_t timeout,uint32_t flags,uint32_t mask,size_t length) { + assert(timeout==0xffffffU && !flags && !mask && !length); return startReceive(); + } + int receiveDirect() { return startReceive(); } void (*callback)()=setFlag; - int standby() { ++standby_calls; carrier=false; return 0; } + int standby() { + ++standby_calls; g_mock_millis+=standby_latency; + if (!standby_result) carrier=false; + return standby_result; + } + int standby(uint8_t mode) { + ++warm_standby_calls; last_standby_mode=mode; + return warm_standby_result ? warm_standby_result : standby(); + } + int setFrequency(float value) { frequency=value; return frequency_result; } int beginFSK(float freq, float, float dev, float, int8_t dbm, uint16_t, bool enable_ook) { ++fsk_starts; fsk=true; carrier=false; frequency=freq; deviation=dev; initial_fsk_power=power=dbm; ook=enable_ook; current_limit=60; return fsk_result; } int setCurrentLimit(int value) { current_limit=value; return limit_result; } + int setMorsePaRamp() { ++morse_ramp_calls; return ramp_result; } + int setPaRampTime(uint8_t ramp) { + ++ramp_calls; + if (!ramp_result) ramp_time=ramp; + return ramp_result; + } + int setTxParams(int8_t value, uint8_t ramp) { + const int result=setOutputPower(value); + if (!result) ramp_time=ramp; + return result; + } int setOutputPower(int8_t value, bool force_rfo=false) { standby(); if (power_result) return power_result; - power=value; rfo=force_rfo; return 0; + power=value; rfo=force_rfo; ramp_time=4; return 0; } - int clearIrqFlags(uint32_t) { return 0; } + int setOutputPower(int8_t value, bool force_rfo, uint16_t ramp) { + ++ramp_power_calls; + const int result=setOutputPower(value,force_rfo); + if (!result) ramp_time=ramp; + return result; + } + int setOutputPower(int8_t value, int ramp) { + ++ramp_power_calls; + const int result=setOutputPower(value,false); + if (!result) ramp_time=ramp; + return result; + } + int clearIrqFlags(uint32_t) { return irq_result; } void clearPacketReceivedAction() { callback=nullptr; } void setPacketReceivedAction(void (*fn)()) { callback=fn; } - int transmitDirect() { carrier=true; ++starts; return start_result; } + int transmitDirect() { g_mock_millis+=tx_latency; carrier=true; ++starts; return start_result; } int finishTransmit() { ++stops; if (!stop_result) carrier=false; return stop_result; } }; using CustomSX1276=Chip; @@ -58,14 +110,39 @@ struct RadioLibWrapper : mesh::Radio { uint8_t state = STATE_RX; RadioInterruptEvents events; bool hasPendingRadioInterrupt() const { return events.pending(); } - bool registerInterruptAction() { chip.setPacketReceivedAction(setFlag); events.claim(); return true; } + unsigned register_failures=0; + bool registerInterruptAction() { + if (register_failures) { --register_failures; return false; } + chip.setPacketReceivedAction(setFlag); events.claim(); return true; + } Chip chip; Chip* _radio=&chip; Board board; Board* _board=&board; mesh::RadioProfiles _profiles; bool _cw_active=false, _cw_stopping=false, _cw_board_tx=false; uint8_t _cw_profile=0, _cw_restore_profile=0, _active_profile=0; uint32_t _cw_until=0, _cw_retry_at=0, _cw_generation=0; + mesh::MorseTx _morse; + bool _morse_owned=false; + float _morse_frequency=mesh::MorseTx::DefaultFrequencyMHz; + uint8_t _morse_wpm=mesh::MorseTx::DefaultWpm; + uint32_t _morse_generation[2]={}; + @MORSE_RX_FIELDS@ + bool _morse_rx_owned=false, _morse_rx_fsk_mode=false; + uint8_t _morse_rx_read_errors=0; + bool receiver_allocation_ok=true, fail_morse_params=false; + float rssi=-110, morse_bandwidth=0; + uint32_t rssi_latency=0; + unsigned rssi_reads=0; bool _params_valid=true, _rx_ps_armed=true, _rx_hold_continuous=false; + bool _rx_boosted_gain_valid=false, _cur_rx_boosted_gain=false; + bool rx_gain_apply_ok=true, physical_rx_gain=false; + unsigned rx_gain_applies=0,rx_gain_order=0; + virtual bool applyRxBoostedGainMode(bool enabled) { + rx_gain_order=++chip.admission_order; + ++rx_gain_applies; + if (rx_gain_apply_ok) physical_rx_gain=enabled; + return rx_gain_apply_ok; + } bool _nf_calib_active=true, _profile_refresh_required=false; unsigned long _wd_observe_until=0; bool packet=false, busy=false, reset_ok=false, supported=true, deep_supported=true; @@ -79,9 +156,12 @@ struct RadioLibWrapper : mesh::Radio { RadioLibWrapper() { g_mock_millis=100; state=STATE_RX; _profiles.primary.freq=909.5; + _profiles.primary.bw=500; _profiles.secondary.params.freq=911.5; + _profiles.secondary.params.bw=500; _profiles.secondary.mode=mesh::RadioProfileMode::RxTx; } + @MORSE_DESTRUCTOR@ mesh::RadioProfiles* profiles() override { return &_profiles; } bool supportsCarrierWave() const override { return supported; } bool isCarrierWaveActive() const override { return _cw_active; } @@ -90,19 +170,48 @@ struct RadioLibWrapper : mesh::Radio { mesh::RadioParamApplyResult setCarrierWave(uint8_t,uint32_t) override; bool stopCarrierWave(); bool serviceCarrierWave(); + bool serviceMorseTx(); + bool serviceMorseRx(); + bool supportsMorseTx() const override; + mesh::MorseTxResult startMorseTx(float,uint8_t,const char*) override; + mesh::MorseTxResult stopMorseTx() override; + @MORSE_STATUS@ + @MORSE_RX_STATUS@ +#if MESH_ENABLE_MORSE_RX + virtual bool canAllocateMorseReceiver() const { return receiver_allocation_ok; } +#endif + virtual bool applyParams(float freq,float bw,uint8_t,uint8_t) { + morse_bandwidth=bw; + return !fail_morse_params && chip.setFrequency(freq)==0; + } + virtual float getCurrentRSSI() { ++rssi_reads; g_mock_millis+=rssi_latency; return rssi; } bool setTxPower(int8_t dbm); @NATIVE_HOOKS@ + @RX_HOOKS@ + @RX_START_HOOKS@ + // Explicit stand-in for a proven native-RSSI modem. Concrete production + // FSK preparation is executed separately; the abstract base rejects RX. + virtual float morseReceiverBandwidthKHz() const { return 4.8f; } + virtual int16_t prepareMorseReceiver(float freq) { + if (!board.prepareRadioFrequency(freq)) return RADIOLIB_ERR_INVALID_FREQUENCY; + if (!applyParams(freq,morseReceiverBandwidthKHz(),7,5)) return RADIOLIB_ERR_UNKNOWN; + return RADIOLIB_ERR_NONE; + } virtual bool supportsRadioDeepInit() const { return deep_supported; } uint8_t beginReconfigure(); void endReconfigure(bool resume) { if (resume) startRecv(); } bool isChipBusy() { return busy; } bool isReceivingPacket() { return packet; } bool isInRecvMode() const override { return state==STATE_RX; } + bool validateProfile(const mesh::RadioProfileParams& p) const override { + return std::isfinite(p.freq) && p.freq >= 100 && p.freq <= 1000; + } void stopReceiveDutyCycle() { ++rtc_stops; _rx_ps_armed=false; } mesh::RadioParamApplyResult tuneProfile(uint8_t target) { ++tunes; - if (fail_tune) return mesh::RadioParamApplyResult::FAILED; + if (fail_tune || chip.frequency_result) return mesh::RadioParamApplyResult::FAILED; _active_profile=target; frequency=_cur_freq=_profiles.params(target).freq; + chip.frequency=frequency; return mesh::RadioParamApplyResult::APPLIED; } void startRecv() override { @@ -113,7 +222,7 @@ struct RadioLibWrapper : mesh::Radio { bool restoreAfterDeepInit() { ++resets; if (reset_ok) { - chip.carrier=false; chip.fsk=false; chip.stop_result=0; + chip.carrier=false; chip.fsk=false; chip.stop_result=chip.standby_result=0; return applyCachedTxPower(_cur_dbm)==0; } return reset_ok; @@ -193,6 +302,7 @@ int main() { RadioLibWrapper w; assert(w.setCarrierWave(1,100)==Result::APPLIED); w.chip.stop_result=-705; + w.chip.standby_result=-705; assert(w.setCarrierWave(0,0)==Result::FAILED); assert(w._cw_active && w._cw_stopping && w.board.awake && w.board.after==1); const auto attempts=w.chip.stops; @@ -205,7 +315,11 @@ int main() { RadioLibWrapper w; w.setCarrierWave(0,100); w.fail_rx_starts=2; assert(w.setCarrierWave(0,0)==Result::FAILED); // never claim RX resumed - assert(!w._cw_active && !w.isInRecvMode()); + assert(w._cw_active && w._cw_stopping && w.board.awake && !w.isInRecvMode()); + advance(100); w.serviceCarrierWave(); + assert(w._cw_active && w._cw_stopping && w.board.awake && !w.isInRecvMode()); + advance(100); w.serviceCarrierWave(); + assert(!w._cw_active && !w.board.awake && w.isInRecvMode()); } { RadioLibWrapper w; w.setCarrierWave(0,100); @@ -324,27 +438,73 @@ int main() { ''' -class CarrierWaveTest(unittest.TestCase): - def test_production_transitions_and_commands(self): - source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text() - signatures = ('uint8_t RadioLibWrapper::beginReconfigure(', +def production_harness(): + source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text() + signatures = ('uint8_t RadioLibWrapper::beginReconfigure(', 'bool RadioLibWrapper::setTxPower(', 'mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(', 'uint32_t RadioLibWrapper::carrierWaveRemainingMillis(', 'bool RadioLibWrapper::stopCarrierWave(', - 'bool RadioLibWrapper::serviceCarrierWave(') - code = HARNESS.replace('@METHODS@', '\n'.join(method(source, s) for s in signatures)) - header = (ROOT/'src/helpers/radiolib/RadioLibWrappers.h').read_text() - code = code.replace('@NATIVE_HOOKS@', '\n'.join(method(header, s) for s in ( + 'bool RadioLibWrapper::serviceCarrierWave(', + 'bool RadioLibWrapper::supportsMorseTx(', + 'mesh::MorseTxResult RadioLibWrapper::startMorseTx(', + 'mesh::MorseTxResult RadioLibWrapper::stopMorseTx(', + 'bool RadioLibWrapper::serviceMorseTx(', + 'mesh::MorseRxResult RadioLibWrapper::startMorseRx(', + 'mesh::MorseRxResult RadioLibWrapper::stopMorseRx(', + 'size_t RadioLibWrapper::readMorseCapture(', + 'void RadioLibWrapper::clearMorseCapture(', + 'bool RadioLibWrapper::serviceMorseRx(') + def extract_transition(signature): + if signature == 'bool RadioLibWrapper::stopCarrierWave(': + # The production enabled/disabled RX branches share a closing + # block; bound the complete actual method instead of counting + # mutually exclusive preprocessor braces together. + start = source.index(signature) + end = source.index('bool RadioLibWrapper::serviceCarrierWave(',start) + return source[start:end].strip() + if signature in ('mesh::MorseRxResult RadioLibWrapper::startMorseRx(', + 'mesh::MorseRxResult RadioLibWrapper::stopMorseRx(', + 'size_t RadioLibWrapper::readMorseCapture(', + 'void RadioLibWrapper::clearMorseCapture('): + return '#if MESH_ENABLE_MORSE_RX\n'+method(source,signature)+'\n#endif' + return method(source,signature) + code = HARNESS.replace('@METHODS@', '\n'.join(extract_transition(s) for s in signatures)) + header = (ROOT/'src/helpers/radiolib/RadioLibWrappers.h').read_text() + fields_start = header.rfind('#if MESH_ENABLE_MORSE_RX',0,header.index('mesh::MorseRx* _morse_rx')) + fields_end = header.index(' bool _morse_rx_owned',fields_start) + code = code.replace('@MORSE_RX_FIELDS@',header[fields_start:fields_end]) + code = code.replace('@MORSE_DESTRUCTOR@',method(header,'~RadioLibWrapper(')) + code = code.replace('@NATIVE_HOOKS@', '\n'.join(method(header, s) for s in ( 'virtual int16_t enterCarrierWave(', 'virtual bool restoreCarrierWaveModem(', - 'virtual int16_t applyCachedTxPower('))) - sx1276 = (ROOT/'src/helpers/radiolib/CustomSX1276Wrapper.h').read_text() - code = code.replace('@SX1276_METHODS@', '\n'.join(method(sx1276, s) for s in ( + 'virtual int16_t applyCachedTxPower(', 'virtual int16_t prepareMorseCarrier(', + 'virtual int16_t enterMorseCarrier(', 'virtual int16_t leaveMorseCarrier(', + 'virtual bool validateMorseFrequency('))) + code = code.replace('@RX_HOOKS@', method(header, 'virtual float readMorseRssi(')) + code = code.replace('@RX_START_HOOKS@',method(header,'virtual int16_t startMorseReceiver(')) + code = code.replace('@MORSE_STATUS@', '\n'.join(method(header, s) for s in ( + 'bool isMorseTxActive(', 'float morseTxFrequency(', 'uint8_t morseTxWpm(', + 'uint32_t morseTxRemainingMillis('))) + rx_status_start = header.rfind('#if MESH_ENABLE_MORSE_RX',0,header.index('bool isMorseRxActive(')) + rx_status_end = header.index(' mesh::RadioProfiles* profiles()',rx_status_start) + rx_status = header[rx_status_start:rx_status_end].replace( + 'bool supportsMorseRx() const override { return false; }', + 'bool supportsMorseRx() const override { return supported && deep_supported; }') + code = code.replace('@MORSE_RX_STATUS@',rx_status) + sx1276 = (ROOT/'src/helpers/radiolib/CustomSX1276Wrapper.h').read_text() + code = code.replace('@SX1276_METHODS@', '\n'.join(method(sx1276, s) for s in ( 'bool supportsCarrierWave(', 'int16_t enterCarrierWave(', - 'bool restoreCarrierWaveModem('))) - dac = (ROOT/'src/helpers/radiolib/DacPaSX1276Wrapper.h').read_text() - code = code.replace('@DAC_METHODS@', '\n'.join(method(dac, s) for s in ( + 'bool restoreCarrierWaveModem(', 'int16_t prepareMorseCarrier(', + 'bool validateMorseFrequency('))) + dac = (ROOT/'src/helpers/radiolib/DacPaSX1276Wrapper.h').read_text() + code = code.replace('@DAC_METHODS@', '\n'.join(method(dac, s) for s in ( 'int16_t applyCachedTxPower(', 'uint8_t indexForDbm('))) + return code + + +class CarrierWaveTest(unittest.TestCase): + def test_production_transitions_and_commands(self): + code = production_harness() with tempfile.TemporaryDirectory() as folder: cpp = Path(folder)/'cw.cpp'; exe = Path(folder)/'cw.exe' cpp.write_text(code) diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 4e92ef441..f249f8a1e 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -50,6 +50,7 @@ COMMON_QUERY_EXCEPTIONS = { 'bridge.running': 'Live bridge state; set bridge.enabled controls intent', 'espnow.running': 'Live ESP-NOW bridge state; set espnow.enabled controls intent', 'rs232.running': 'Live UART bridge state; set rs232.enabled controls intent', + 'rs232.status': 'Live heartbeat, negotiated standby and UART owner diagnostics', 'bridge.type': 'Build-selected bridge transport', 'password': 'Written by the password command, not set password', 'public.key': 'Derived from identity; written through set prv.key', diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index c63fe7035..6c8ce511e 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -130,6 +130,7 @@ struct Clock { uint32_t now = mesh::FleetCommand::MinEpoch + 1000; uint32_t getCurrentTime() const { return now; } }; class MyMesh : public mesh::Mesh { public: + struct NoMorseReply { void copy(const void*, const void*) {} } _morse_reply; mesh::LocalIdentity self_id; Clock clock; ChannelDetails channels[MAX_GROUP_CHANNELS]; @@ -1237,7 +1238,7 @@ class CompanionFleetControlTests(unittest.TestCase): prefix = function[:function.index("#if COMPANION_FEATURE_READER")] fleet_start = function.rindex("#if MESH_ENABLE_FLEET_CONTROL", 0, function.index('if (!strncmp(command, "fleet", 5)')) - fleet_end = function.index(" if (_radio_profiles.handle", fleet_start) + fleet_end = function.index(" if (mesh::handleMorseReplyCommand", fleet_start) # Keep the actual compile guard as well as its complete branch so # compact profiles prove that none of the fleet dependencies survive. cls.production = prefix + function[fleet_start:fleet_end] + "\n return false;\n}\n" diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index 40ac3e38a..0da0ccf46 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -57,14 +57,22 @@ struct Radio : mesh::Radio { bool isInRecvMode() const override { return true; } }; struct Sensors { double node_lat=0,node_lon=0; } sensors; +// Morse dispatch is independently executable in its own shared-handler suite; +// this legacy fixture keeps that optional branch inactive for tuple rollback. +namespace mesh { +template bool handleMorseReplyCommand(Args&&...) { return false; } +template bool handleMorseCommand(Args&&...) { return false; } +} @COMMAND_FILTER@ struct MyMesh : CompanionFleetFixture { + struct NoMorseReply {} _morse_reply; uint8_t self_id = 0; // Signing is unreachable in this radio-only fixture. CompanionNodePrefs _prefs; DataStore store; DataStore* _store=&store; MemoryFS profile_fs; Radio radio; + Radio* _radio=&radio; Clock clock; mesh::RadioProfileCLI _radio_profiles; char reply[160]={}; diff --git a/test/test_esp32_advert_cache.py b/test/test_esp32_advert_cache.py index 06e5015a2..307b68d14 100644 --- a/test/test_esp32_advert_cache.py +++ b/test/test_esp32_advert_cache.py @@ -67,7 +67,9 @@ class AdvertCacheTest(unittest.TestCase): '-fsanitize=address,undefined', '-fno-omit-frame-pointer', '-DESP32_PLATFORM=1', '-DESP32=1', f'-DMESH_CONTACT_CACHE={cache}', '-I', str(ROOT / 'test/fixtures/advert_cache'), - '-I', str(ROOT / 'src'), '-I', str(temp), + # Ensure the generated mesh.h wins over Mesh.h on case-folded + # Windows/WSL filesystems as well as case-sensitive CI hosts. + '-I', str(temp), '-I', str(ROOT / 'src'), str(ROOT / 'test/fixtures/advert_cache/test.cpp'), '-o', str(binary), ], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 04cf16321..f30bdd9f2 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -195,8 +195,8 @@ class FirmwareRamTest(unittest.TestCase): combined = ram.requirements("ESP32_PLATFORM", { **defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1, }, "v3_repeater") - self.assertEqual(combined["required_heap_bytes"] - mqtt["required_heap_bytes"], 2560 + 4096) - self.assertEqual(combined["components"]["uart_bridge_and_driver"], 2560 + 4096) + self.assertEqual(combined["required_heap_bytes"] - mqtt["required_heap_bytes"], 3072 + 4096) + self.assertEqual(combined["components"]["uart_bridge_and_driver"], 3072 + 4096) # A selectable alternate port does not create two UART owners. alternate = ram.requirements("ESP32_PLATFORM", { **defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1, @@ -351,12 +351,12 @@ class FirmwareRamTest(unittest.TestCase): **defines, "RAK4631_COMBINED_ETHERNET": 1, }, "RAK_4631_repeater_unified_lora_ota") components = combined["components"] - self.assertEqual(components["uart_bridge_and_driver"], 2560) + self.assertEqual(components["uart_bridge_and_driver"], 3072) self.assertEqual(components["ethernet_or_external_ota_workspaces"], 11344) self.assertNotIn("ota_encoder_workspace", components) self.assertNotIn("ota_self_source_scratch", components) self.assertEqual(components["display_pixels_and_driver"], 2048) - self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 2560 - 2048) + self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 3072 - 2048) self.assertEqual(components["loop_and_callback_stacks"], 8192 + 3072) self.assertEqual(components["flood_filter_table"], 63 * 192 + 16) # An alternate port selects the same bridge instead of allocating two. @@ -463,9 +463,9 @@ class FirmwareRamTest(unittest.TestCase): } full = ram.requirements("ESP32_PLATFORM", defines, "Heltec_v3_repeater_observer_mqtt") parts = full["components"] - self.assertEqual(full["required_heap_bytes"], 209184) + self.assertEqual(full["required_heap_bytes"], 209696) self.assertEqual(parts["display_pixels_and_driver"], 2048) - self.assertEqual(parts["uart_bridge_and_driver"], 2560 + 4096) + self.assertEqual(parts["uart_bridge_and_driver"], 3072 + 4096) self.assertEqual(parts["wireless_stacks"], 49152) self.assertEqual(parts["mqtt_startup_allowance"], 24576) self.assertEqual(parts["allocation_and_transient_margin"], 16384) @@ -485,7 +485,7 @@ class FirmwareRamTest(unittest.TestCase): sources = ( ("src/helpers/bridges/RS232Bridge.cpp", "RS232Bridge::RS232Bridge(", "struct RS232Bridge { unsigned char bytes[OBJECT_BYTES]; };\n", - ((2304, 0, True), (2305, 0, False))), + ((2816, 0, True), (2817, 0, False))), ("src/helpers/ui/SSD1306Display.cpp", "bool SSD1306Display::i2c_probe(", "struct SSD1306Display { unsigned char bytes[OBJECT_BYTES]; " "static constexpr unsigned FRAMEBUFFER_BYTES = FRAME_BYTES; };\n", diff --git a/test/test_fleet_radio_schedules.py b/test/test_fleet_radio_schedules.py index 1af823b09..6a56e969b 100644 --- a/test/test_fleet_radio_schedules.py +++ b/test/test_fleet_radio_schedules.py @@ -29,6 +29,8 @@ class Radio {public: static constexpr uint32_t CarrierWaveDefaultMillis=10000,CarrierWaveMaxMillis=60000; virtual bool supportsCarrierWave()const{return false;} virtual bool isCarrierWaveActive()const{return false;} + virtual bool isMorseRxActive()const{return false;} + virtual bool isMorseTxActive()const{return false;} virtual uint32_t carrierWaveRemainingMillis()const{return 0;} virtual uint8_t carrierWaveProfile()const{return 0;} virtual RadioParamApplyResult setCarrierWave(uint8_t,uint32_t){return RadioParamApplyResult::FAILED;} diff --git a/test/test_fleet_role_wiring.py b/test/test_fleet_role_wiring.py index 7dc4b2242..6202f645e 100644 --- a/test/test_fleet_role_wiring.py +++ b/test/test_fleet_role_wiring.py @@ -88,6 +88,12 @@ struct Fleet { struct CLI { Fleet fleet; Profiles profiles; + struct NoMorseReply { + void complete(const void*) {} + void fail(const void*) {} + void copy(const void*, const void*) {} + } morse; + NoMorseReply& morseReply() { return morse; } bool enrolled = true; Fleet* fleetChannel() { return enrolled ? &fleet : nullptr; } Profiles& radioProfiles() { return profiles; } diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index cc19309a1..51eaa91e4 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -29,7 +29,19 @@ PREAMBLE = r''' #define MAX_MQTT_SLOTS 2 #define MAX_LORA_TX_POWER 22 #define TXT_TYPE_CLI_COMMAND 1 +uint32_t millis() { return 0; } namespace mesh { +// The real Morse parser, capture paging and ownership are executed by their +// dedicated tests. This fixture isolates unrelated local/remote CLI access. +template +bool handleMorseCommand(Radio*, const char*, char*, size_t) { return false; } +struct MorseReplyHandoff { + unsigned generation() const { return 0; } + void beginCommand(const uint8_t*) {} + void endCommand() {} +}; +template +bool handleMorseReplyCommand(MorseReplyHandoff&, Radio*, const char*, char*, size_t, uint32_t) { return false; } struct RadioProfileCLI { bool remote_origin=false; bool handle(const char*, char*, size_t, bool remote=false) { remote_origin=remote;return false; } @@ -58,6 +70,7 @@ struct Utils { } struct ContactInfo { bool allowed=true; + struct { uint8_t pub_key[32] = {}; } id; bool isRemoteCLIAllowed() const { return allowed; } }; struct WiFiSetupPortal { @@ -117,6 +130,8 @@ struct WebConfigServer { const char* FIRMWARE_VERSION="test"; const char* FIRMWARE_BUILD_DATE="test"; struct MyMesh : CompanionFleetFixture { + mesh::MorseReplyHandoff _morse_reply; + bool _morse_reply_admission = false; mesh::RadioProfileCLI _radio_profiles; Identity self_id; Store store; Store* _store=&store; diff --git a/test/test_morse_auth.py b/test/test_morse_auth.py new file mode 100644 index 000000000..cc77dc45b --- /dev/null +++ b/test/test_morse_auth.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Execute the production legacy portable handoff and live manager allowlists.""" +from pathlib import Path +import unittest + +from test_morse_tx import compile_run +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include + +struct ClientInfo { + enum Permission { Guest = 0, Admin = 1, RegionManager = 2, FilterManager = 4 }; + unsigned permissions; + bool isAdmin() const { return (permissions & Admin) != 0; } + bool isRegionMgr() const { return (permissions & RegionManager) != 0; } + bool isFilterMgr() const { return (permissions & FilterManager) != 0; } +}; + +@MANAGER_HELPERS@ + +struct CommonCLI { + unsigned calls = 0, rf_actions = 0, settings = 0; + uint32_t last_timestamp = 0; + void handleCommand(uint32_t timestamp, const char* command, char* reply) { + ++calls; + last_timestamp = timestamp; + if (!strncmp(command, "morse send ", 11) || !strcmp(command, "morse stop")) ++rf_actions; + if (!strncmp(command, "set morse.", 10)) ++settings; + strcpy(reply, "delegated"); + } +}; + +struct LegacyPortable { + CommonCLI _cli; + void dispatch(ClientInfo* sender, uint32_t sender_timestamp, + const char* command, char* reply) { + @PORTABLE_HANDOFF@ + } +}; + +static void denied(LegacyPortable& node, ClientInfo* sender, const char* command) { + for (uint32_t timestamp : {uint32_t(0), uint32_t(123)}) { + const unsigned calls = node._cli.calls; + const unsigned rf = node._cli.rf_actions; + const unsigned settings = node._cli.settings; + char reply[160] = {}; + node.dispatch(sender, timestamp, command, reply); + assert(!strcmp(reply, "Err - not permitted")); + assert(node._cli.calls == calls); + assert(node._cli.rf_actions == rf && node._cli.settings == settings); + } +} + +static void allowed(LegacyPortable& node, ClientInfo* sender, const char* command) { + const uint32_t timestamp = sender ? 123 : 0; + const unsigned calls = node._cli.calls; + char reply[160] = {}; + node.dispatch(sender, timestamp, command, reply); + assert(!strcmp(reply, "delegated")); + assert(node._cli.calls == calls + 1 && node._cli.last_timestamp == timestamp); +} + +int main() { + LegacyPortable node; + ClientInfo guest{ClientInfo::Guest}, admin{ClientInfo::Admin}; + ClientInfo region{ClientInfo::RegionManager}, filter{ClientInfo::FilterManager}; + ClientInfo combined{ClientInfo::RegionManager | ClientInfo::FilterManager}; + for (const char* command : { + "morse send TEST DE N0CALL", "morse stop", "set morse.freq 902.100", "set morse.wpm 25", + "morse listen 5", "morse read", "morse read 128", "morse stats", "morse clear", + "get morse", "get morse.freq", "get morse.wpm", "cw on", "cw off", "set cw2 on"}) { + // These assertions also use the helpers if the production gate is + // accidentally removed, so the regression fails on behavior, not on an + // unrelated unused-function compiler warning. + assert(!isRegionMgrAllowed(command) && !isFilterMgrAllowed(command)); + for (ClientInfo* sender : {&guest, ®ion, &filter, &combined}) denied(node, sender, command); + allowed(node, &admin, command); + allowed(node, nullptr, command); + } + assert(node._cli.rf_actions == 4 && node._cli.settings == 4); + + for (const char* command : {"ver", "board", "clock", "sensor get battery_voltage", + "sensor list", "get role", "get public.key", "get rx.watchdog", "get clock.sync"}) { + assert(isCommonManagerReadOnlyAllowed(command)); + denied(node, &guest, command); + for (ClientInfo* sender : {®ion, &filter, &combined, &admin}) allowed(node, sender, command); + allowed(node, nullptr, command); + } + for (const char* command : {"get password", "get guest.password", "get wifi.pwd", "get bridge.secret"}) { + for (ClientInfo* sender : {&guest, ®ion, &filter, &combined}) denied(node, sender, command); + allowed(node, &admin, command); + allowed(node, nullptr, command); + } + + // Closing the old portable bypass must not erase delegated manager roles. + allowed(node, ®ion, "set flood.channel.scope public #local"); + allowed(node, &combined, "set flood.channel.scope public #local"); + denied(node, &filter, "set flood.channel.scope public #local"); + allowed(node, &filter, "set flood.filter.1 group-text 4"); + allowed(node, &combined, "set flood.filter.1 group-text 4"); + denied(node, ®ion, "set flood.filter.1 group-text 4"); +#if MESH_ENABLE_FLOOD_RULE_ENGINE + allowed(node, &filter, "get flood.rule"); + allowed(node, &filter, "set flood.rule.0 drop"); +#else + denied(node, &filter, "get flood.rule"); + denied(node, &filter, "set flood.rule.0 drop"); +#endif + puts("Legacy portable Morse authorization and manager delegation passed"); +} +''' + + +class MorseAuthorizationTests(unittest.TestCase): + def test_actual_legacy_portable_handoff_keeps_morse_admin_only(self): + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="utf-8") + handler = extract_braced(source, "void MyMesh::handleCommand(uint32_t sender_timestamp,") + start = handler.index("// Compatibility path for manually defined legacy portable builds.") + call = "_cli.handleCommand(sender_timestamp, command, reply);" + end = handler.index(call, start) + len(call) + # Keep the actual statements between the stable handoff marker and + # CommonCLI invocation. Do not inject a test-only authorization gate; + # the previous unguarded production branch must fail the C++ asserts. + handoff = handler[start:end] + helpers = "\n".join(extract_braced(source, signature) for signature in ( + "static bool commandFamilyMatches(", "static bool isCommonManagerReadOnlyAllowed(", + "static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(", + )) + code = HARNESS.replace("@MANAGER_HELPERS@", helpers).replace("@PORTABLE_HANDOFF@", handoff) + for extended_filters in (0, 1): + with self.subTest(extended_filters=extended_filters): + compile_run(self, code, flags=(f"-DMESH_ENABLE_FLOOD_RULE_ENGINE={extended_filters}",)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_cli.py b/test/test_morse_cli.py new file mode 100644 index 000000000..e604e5d8e --- /dev/null +++ b/test/test_morse_cli.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Compile exact shared Morse CLI and checked persistent settings with faults.""" +from pathlib import Path +import unittest +import zlib +import re +from test_morse_tx import compile_run, portable_radio_header +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +class MorseCLITests(unittest.TestCase): + def test_exact_shared_handler_strict_arguments_bounded_replies_and_rollback(self): + code = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii") + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), + headers={"Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + def test_exact_receive_cli_timed_sessions_retained_paging_and_recovery(self): + code = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + code += (ROOT / "test/fixtures/morse_rx_cli.cpp").read_text(encoding="ascii") + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), + headers={"Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + def test_hil_only_trace_ring_sparse_edges_paging_and_reflected_prefix_canary(self): + code = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + code += (ROOT / "test/fixtures/morse_trace_cli.cpp").read_text(encoding="ascii") + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + for hil in (0, 1): + with self.subTest(hil=hil): + compile_run(self, code, flags=("-Wno-unused-parameter", f"-DMESH_MORSE_HIL={hil}"), + headers={"Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + def test_actual_common_dispatch_reserves_reflected_prefix_for_bulk_capture_pages(self): + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8") + dispatch = re.search(r"if \(mesh::handleMorseCommand\(_callbacks->getProfileRadio\(\)," + r"\s*command,\s*reply,\s*[^\n]+\)\) return;", common) + self.assertIsNotNone(dispatch, "the real CommonCLI Morse dispatch is required") + code = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + code += (ROOT / "test/fixtures/morse_reply_prefix.cpp").read_text(encoding="ascii").replace( + "@COMMON_MORSE_CALL@", dispatch.group(0)) + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), + headers={"Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + def test_actual_settings_codec_boot_recovery_and_checked_writers_on_all_backends(self): + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + fs_mock = fs_mock.replace("bool fail_write = false;", "bool fail_write = false; bool fail_presence = false;\n std::vector fail_read_paths;") + fs_mock = fs_mock.replace("if (*mode == 'r' && fail_read_open) return {};", + "if (*mode == 'r' && (fail_read_open || std::find(fail_read_paths.begin(), fail_read_paths.end(), path) != fail_read_paths.end())) return {};") + presence = ("#pragma once\nnamespace mesh { template bool filePresence(FS* fs, " + "const char* path, bool& present) { if (!fs || fs->fail_presence) return false; " + "present = fs->exists(path); return true; } }\n") + transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text(encoding="ascii").replace( + '#include "IdentityStore.h"', '#include ') + headers = { + "helpers/IdentityStore.h": fs_mock, + "FilePresence.h": presence, + "AtomicFileWriter.h": (ROOT / "src/helpers/AtomicFileWriter.h").read_text(encoding="ascii"), + "ContactFileTransaction.h": transaction, + "PersistentStoreFormat.h": (ROOT / "src/helpers/PersistentStoreFormat.h").read_text(encoding="ascii"), + } + prefix = b"MC\x01\x19" + (902100).to_bytes(4, "little") + golden = prefix + (zlib.crc32(prefix) ^ 0xffffffff).to_bytes(4, "little") + main = (ROOT / "test/fixtures/morse_settings.cpp").read_text(encoding="ascii").replace( + "@DEFAULT_IMAGE@", ",".join(str(byte) for byte in golden)) + code = (ROOT / "src/helpers/MorseSettings.cpp").read_text(encoding="ascii").replace( + '#include "MorseSettings.h"', '#include ') + "\n" + main + for platform in ("NRF52_PLATFORM", "STM32_PLATFORM", "ESP32_PLATFORM", "RP2040_PLATFORM"): + with self.subTest(platform=platform): + compile_run(self, code, flags=("-D" + platform, "-DATOMIC_FILE_WRITER_IMPLEMENTATION", + "-Wno-sign-compare"), headers=headers) + + def test_actual_companion_binary_webconfig_and_common_cli_fem_guards(self): + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="ascii") + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + code = (ROOT / "test/fixtures/morse_fem_guard.cpp").read_text(encoding="ascii") + code = code.replace("@COMPANION_METHODS@", "\n".join(method(companion, signature) for signature in ( + "bool MyMesh::applyAndSaveFemRxGain(", "bool MyMesh::applyAndSaveFemTxGain("))) + branches = "\nelse ".join(method(common, signature) for signature in ( + 'if (memcmp(config, "radio.fem.rxgain ", 17) == 0)', + 'if (memcmp(config, "radio.fem.txgain ", 17) == 0)')) + compile_run(self, code.replace("@COMMON_BRANCHES@", branches)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py new file mode 100644 index 000000000..5a8760682 --- /dev/null +++ b/test/test_morse_loop_policy.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Exercise production Morse redraw deferral and fresh voltage cutoff checks.""" +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from test_morse_tx import compile_run +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] +RADIO = """#pragma once +namespace mesh { class Radio { public: + virtual bool isMorseTxActive() const { return false; } + virtual bool isMorseRxActive() const { return false; } + virtual bool isCarrierWaveActive() const { return false; } +}; } +""" + +FIXTURE = r''' +#include +#include +#include +#include +#include +static uint32_t now_ms=10000; +uint32_t millis() { return now_ms; } +bool millisReached(uint32_t now,uint32_t due) { return int32_t(now-due)>=0; } +static unsigned adc_reads=0,frames=0,shutdowns=0; +void delay(uint32_t) {} +struct TestRadio : mesh::Radio { + bool tx=false,rx=false,cw=false; + bool isMorseTxActive() const override { return tx; } + bool isMorseRxActive() const override { return rx; } + bool isCarrierWaveActive() const override { return cw; } +} radio_driver; +struct TestBoard { bool external=false; bool isExternalPowered() const { return external; } } board; +struct TestMesh { mesh::Radio* getProfileRadio() { return &radio_driver; } } the_mesh; +namespace UIColor { constexpr int popup_bkg=0,popup_txt=1,warning_txt=2; } +struct Display { + bool on=true; + bool isOn() const { return on; } + bool isEink() const { return false; } + int width() const { return 128; } + int height() const { return 64; } + void startFrame() { ++frames; } + void endFrame() {} + void setTextSize(int) {} + void setColor(int) {} + void fillRect(int,int,int,int) {} + void drawRect(int,int,int,int) {} + void drawTextCentered(int,int,const char*) {} +}; +struct Screen { int render(Display&) { ++adc_reads; return 1000; } }; +struct StatusBar { + void update(Display&,const char*,uint16_t,bool,bool,bool) {} + bool needsRedraw() const { return true; } + void render(Display&) {} +}; +constexpr uint32_t BOOT_SCREEN_MILLIS=3000; +constexpr uint16_t AUTO_SHUTDOWN_MILLIVOLTS=3400; +struct UI { + Display display; Display* _display=&display; + Screen screen; Screen* curr=&screen; + StatusBar _statusBar; + struct Prefs { const char* node_name="test"; } prefs; + Prefs* _node_prefs=&prefs; + uint32_t _next_refresh=0,_alert_expiry=0,ui_started_at=0,next_batt_chck=0; + uint16_t _cached_batt_mv=4000,voltage=3350; + bool _need_refresh=true; + char _alert[4]={},_origin[4]={},_msg[4]={}; + bool isButtonGesturePending() { return false; } + bool isPairingScreenActive() { return false; } + bool isBuzzerQuiet() { return true; } + bool getGPSState() { return false; } + bool isBluetoothEnabled() { return false; } + void renderPairingBanner() {} + void serviceRadioProfileDisplayPage() {} + void applyDisplayFlip() {} + void renderCurrScreen() { ++adc_reads; } + uint16_t getBattMilliVolts() { ++adc_reads; return voltage; } + void shutdown() { ++shutdowns; } + void redraw() { const bool morse_timing=mesh::morseTimingActive(&radio_driver); (void)morse_timing; @REDRAW@ } + void cutoff() { @CUTOFF@ } +}; +''' + + +class MorseLoopPolicyTests(unittest.TestCase): + def test_actual_ui_radio_declarations_cover_each_production_expression(self): + # Compile only declarations genuinely supplied by the production UI + # source or its included role/target header. No synthetic global mesh + # definitions may conceal an undeclared instance (Sensor uses an + # accessor rather than Companion/Room/Repeater's global the_mesh). + specs = ( + ("examples/companion_radio/ui-new/UITask.cpp", "examples/companion_radio/MyMesh.h", "MyMesh"), + ("examples/companion_radio/ui-tiny/UITask.cpp", "examples/companion_radio/MyMesh.h", "MyMesh"), + ("examples/companion_radio/ui-orig/UITask.cpp", "examples/companion_radio/MyMesh.h", "MyMesh"), + ("examples/simple_repeater/UITask.cpp", "examples/simple_repeater/MyMesh.h", "MyMesh"), + ("examples/simple_room_server/UITask.cpp", "examples/simple_room_server/MyMesh.h", "MyMesh"), + ("examples/simple_sensor/UITask.cpp", "examples/simple_sensor/SensorMesh.h", "SensorMesh"), + ) + target = (ROOT / "variants/heltec_v4/target.h").read_text(encoding="ascii") + target_radio = re.search(r"^extern WRAPPER_CLASS radio_driver;", target, re.M).group(0) + for source_path, header_path, role in specs: + with self.subTest(ui=source_path): + source = (ROOT / source_path).read_text(encoding="ascii") + header = (ROOT / header_path).read_text(encoding="ascii") + loop = method(source, "void UITask::loop()") + expression = re.search(r"const bool morse_timing = mesh::morseTimingActive\([^;]+;", loop).group(0) + self.assertRegex(source, r'#include ["<](?:\.\./)?(?:MyMesh|SensorMesh)\.h[">]') + declarations = re.findall(r"^extern " + role + r" the_mesh;|^" + role + r"& activeSensorMesh\(\);", + source + "\n" + header, re.M) + if re.search(r'#include ["<]target\.h[">]', source + "\n" + header): + declarations.append(target_radio) + getter = "" + if "getProfileRadio(" in expression: + getter = method(header, "mesh::Radio* getProfileRadio()").replace(" override", "") + code = """#include +class TestRadio : public mesh::Radio {}; +#define WRAPPER_CLASS TestRadio +class @ROLE@ { public: mesh::Radio* _radio=nullptr; @GETTER@ }; +@DECLARATIONS@ +bool checkRadioReference() { @EXPRESSION@ return morse_timing; } +""".replace("@ROLE@", role).replace("@GETTER@", getter).replace( + "@DECLARATIONS@", "\n".join(dict.fromkeys(declarations))).replace("@EXPRESSION@", expression) + with tempfile.TemporaryDirectory(prefix="morse-ui-declarations-") as directory: + temp = Path(directory) + (temp / "Dispatcher.h").write_text(RADIO, encoding="ascii") + (temp / "test.cpp").write_text(code, encoding="ascii") + result = subprocess.run(["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-fsyntax-only", + "-I", str(temp), "-I", str(ROOT / "src"), str(temp / "test.cpp")], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + if role == "SensorMesh": + # This exact old global-instance expression must fail, + # not silently succeed because a host mock invented it. + negative = code.replace("activeSensorMesh().getProfileRadio()", "the_mesh.getProfileRadio()") + (temp / "test.cpp").write_text(negative, encoding="ascii") + result = subprocess.run(["g++", "-std=c++17", "-fsyntax-only", "-I", str(temp), + "-I", str(ROOT / "src"), str(temp / "test.cpp")], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("the_mesh", result.stderr) + + def test_actual_common_loop_keeps_gpio_expiry_live_while_background_is_deferred(self): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + loop = method(source, "void CommonCLI::loop()") + code = FIXTURE.replace("@REDRAW@", "").replace("@CUTOFF@", "") + r''' +#define MESH_ENABLE_MORSE_RX 0 +#define USER_GPIO_CONTROL 1 +struct UserGpio { + struct Completion { uint8_t pin=4,state=0; uint32_t request_id=99; }; + unsigned loops=0; bool pending=true; + void loop() { ++loops; } + bool takeCompletion(Completion& out) { + if(!pending) return false; + pending=false; out={}; return true; + } +}; +struct Callbacks { + unsigned completed=0; + mesh::Radio* getProfileRadio() { return &radio_driver; } + void onUserGpioTimerCompleted(uint8_t pin,uint8_t state,uint32_t id) { + assert(pin==4 && state==0 && id==99); ++completed; + } +}; +struct CommonCLI { + Callbacks callbacks; Callbacks* _callbacks=&callbacks; + UserGpio _user_gpio; + struct Profiles { unsigned loops=0; void loop() { ++loops; } } _radio_profiles; + unsigned management=0; + void loopManagement() { ++management; } + void loop(); +}; +@LOOP@ +int main() { + for(unsigned mode=0;mode<4;++mode) { + radio_driver.tx=mode==1; radio_driver.rx=mode==2; radio_driver.cw=mode==3; + CommonCLI cli; cli.loop(); + assert(cli._user_gpio.loops==1 && cli.callbacks.completed==1); + const unsigned ordinary=mode==1 || mode==2 ? 0:1; + assert(cli._radio_profiles.loops==ordinary && cli.management==ordinary); + radio_driver.tx=radio_driver.rx=false; + cli.loop(); + assert(cli._radio_profiles.loops==ordinary+1 && cli.management==ordinary+1); + assert(cli._user_gpio.loops==2 && cli.callbacks.completed==1); + } +} +''' + compile_run(self, code.replace("@LOOP@", loop), headers={"Dispatcher.h": RADIO}) + + def test_only_morse_ownership_defers_work_and_recovery_stays_deferred(self): + code = FIXTURE.replace("@REDRAW@", "").replace("@CUTOFF@", "") + r''' +int main() { + assert(!mesh::morseTimingActive(nullptr)); + assert(!mesh::morseTimingActive(&radio_driver)); + radio_driver.cw=true; assert(!mesh::morseTimingActive(&radio_driver)); + radio_driver.tx=true; assert(mesh::morseTimingActive(&radio_driver)); + radio_driver.tx=false; radio_driver.rx=true; assert(mesh::morseTimingActive(&radio_driver)); + radio_driver.rx=false; assert(!mesh::morseTimingActive(&radio_driver)); +} +''' + compile_run(self, code, headers={"Dispatcher.h": RADIO}) + + def test_actual_all_six_ui_redraw_blocks_defer_without_consuming_deadlines(self): + specs = ( + ("examples/companion_radio/ui-new/UITask.cpp", + "if (!morse_timing && millis() >= _next_refresh"), + ("examples/companion_radio/ui-tiny/UITask.cpp", + "if (!morse_timing && _display != NULL && _display->isOn())"), + ("examples/companion_radio/ui-orig/UITask.cpp", + "if (!morse_timing && _display != NULL && _display->isOn())"), + ("examples/simple_repeater/UITask.cpp", "if (!morse_timing && _display->isOn())"), + ("examples/simple_room_server/UITask.cpp", "if (!morse_timing && _display->isOn())"), + ("examples/simple_sensor/UITask.cpp", "if (!morse_timing && _display->isOn())"), + ) + for path, signature in specs: + with self.subTest(ui=path): + loop = method((ROOT / path).read_text(encoding="ascii"), "void UITask::loop()") + self.assertIn("const bool morse_timing = mesh::morseTimingActive(", loop) + redraw = method(loop, signature) + self.assertIn("_display->startFrame()", redraw) + code = FIXTURE.replace("@REDRAW@", redraw).replace("@CUTOFF@", "") + r''' +int main() { + for(bool external : {false,true}) { + board.external=external; + for(unsigned mode=0;mode<4;++mode) { + UI ui; adc_reads=frames=0; + radio_driver.tx=mode==1; radio_driver.rx=mode==2; radio_driver.cw=mode==3; + ui.redraw(); + if(mode==1 || mode==2) { + assert(frames==0 && adc_reads==0 && ui._next_refresh==0); + radio_driver.tx=radio_driver.rx=false; + ui.redraw(); assert(frames==1 && adc_reads==1); + } else assert(frames==1 && adc_reads==1); + assert(ui._next_refresh>now_ms); + } + } +} +''' + compile_run(self, code, flags=("-Wno-unused-variable",), headers={"Dispatcher.h": RADIO}) + + def test_actual_full_and_tiny_ui_cutoff_reads_fresh_voltage_even_during_morse(self): + for variant in ("ui-new", "ui-tiny"): + with self.subTest(ui=variant): + source = (ROOT / f"examples/companion_radio/{variant}/UITask.cpp").read_text(encoding="ascii") + loop = method(source, "void UITask::loop()") + cutoff = method(loop, "if (millis() > next_batt_chck)") + self.assertNotIn("morse_timing", cutoff) + code = FIXTURE.replace("@REDRAW@", "").replace("@CUTOFF@", cutoff) + r''' +int main() { + for(bool external : {false,true}) for(unsigned mode=0;mode<4;++mode) { + board.external=external; now_ms=10000; + radio_driver.tx=mode==1; radio_driver.rx=mode==2; radio_driver.cw=mode==3; + UI ui; ui._cached_batt_mv=4000; ui.voltage=3350; + adc_reads=shutdowns=0; + ui.cutoff(); + assert(adc_reads==1 && shutdowns==unsigned(!external)); + assert(ui.next_batt_chck==18000); + ui.cutoff(); assert(adc_reads==1); + now_ms=18001; ui.voltage=3500; + ui.cutoff(); assert(adc_reads==2 && shutdowns==unsigned(!external)); + } +} +''' + compile_run(self, code, headers={"Dispatcher.h": RADIO}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_remote.py b/test/test_morse_remote.py new file mode 100644 index 000000000..3a7f607a9 --- /dev/null +++ b/test/test_morse_remote.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +"""No-hardware tests for capture paging and identity/nonce-bound LoRa replies.""" +from pathlib import Path +import argparse +import asyncio +import importlib.util +import json +import sys +import types +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +# Load the actual hardware runner with inert imports; these tests never create +# a serial port, run an SDK, touch a real output file or send radio commands. +serial_mock = types.ModuleType("serial") +serial_tools_mock = types.ModuleType("serial.tools") +serial_tools_mock.list_ports = types.SimpleNamespace(comports=lambda: []) +with patch.dict(sys.modules, {"serial":serial_mock,"serial.tools":serial_tools_mock}): + bench_spec = importlib.util.spec_from_file_location("tested_morse_bench",ROOT / "tools/hil/morse_bench.py") + actual_bench = importlib.util.module_from_spec(bench_spec) + bench_spec.loader.exec_module(actual_bench) + +bench = types.ModuleType("morse_bench") +bench.Console, bench.SERIALS = object, {"rak": "test-rak", "t096": "test-source","pocket":"test-pocket"} +bench.live_temporary_profile = actual_bench.live_temporary_profile +bench.DEFAULT_CW_FREQUENCY = actual_bench.DEFAULT_CW_FREQUENCY +bench.parse_cw_frequency = actual_bench.parse_cw_frequency +with patch.dict(sys.modules, {"morse_bench": bench}): + spec = importlib.util.spec_from_file_location("tested_morse_remote", ROOT / "tools/hil/morse_remote.py") + remote = importlib.util.module_from_spec(spec) + spec.loader.exec_module(remote) + +KEY = "ab" * 32 + + +def page(offset, total, following, fragment, truncated=0): + marker = "END " if following is None else f"{following:04d}" + return f"> {offset:04d}/{total:04d} next={marker} truncated={truncated} {fragment}" + + +class CaptureParserTests(unittest.TestCase): + def test_cw_frequency_default_band_and_packet_guard_are_enforced(self): + self.assertEqual(actual_bench.DEFAULT_CW_FREQUENCY,902.1) + self.assertEqual(remote.DEFAULT_CW_FREQUENCY,902.1) + for text,expected in (("902.1000001",902.1),("902",902.0),("928",928.0), + ("909.239999",909.239999),("909.760001",909.760001)): + self.assertEqual(actual_bench.parse_cw_frequency(text),expected) + self.assertEqual(remote.parse_cw_frequency(text),expected) + for text in ("nan","inf","-inf","oops","901.999999","928.000001","909.5","909.24","909.76"): + with self.subTest(text=text),self.assertRaises(argparse.ArgumentTypeError): + actual_bench.parse_cw_frequency(text) + + def test_capture_evidence_keeps_transport_and_rf_accuracy_independent(self): + local_pages=[{"offset":0,"reply":"local evidence"}] + remote_pages=[{"offset":0,"reply":"remote evidence"}] + noisy="REMOTE MORSE TEST E " + evidence=remote.capture_evidence("REMOTE MORSE TEST",noisy,local_pages,noisy,remote_pages) + self.assertTrue(evidence["exact_remote_match"]) + self.assertFalse(evidence["exact_rf_match"],"extra E noise must never become a successful RF result") + self.assertEqual(evidence["local"],noisy) + self.assertEqual(evidence["remote"],noisy) + self.assertEqual(evidence["local_pages"],local_pages) + self.assertEqual(evidence["remote_pages"],remote_pages) + before_remote=remote.capture_evidence("SOS","SOS ",local_pages) + self.assertIsNone(before_remote["exact_remote_match"]) + self.assertTrue(before_remote["exact_rf_match"]) + self.assertIsNone(before_remote["remote"]) + self.assertEqual(before_remote["remote_pages"],[]) + changed=remote.capture_evidence("SOS","SOS ",local_pages,"SOS",remote_pages) + self.assertFalse(changed["exact_remote_match"],"transport must preserve even trailing whitespace exactly") + self.assertTrue(changed["exact_rf_match"]) + + def test_only_live_temporary_profiles_are_accepted(self): + self.assertEqual(actual_bench.live_temporary_profile("> TempRadio active: 909.500,500,5,5 (30 min)\r\n> "),(909.5,500,5,5)) + self.assertEqual(actual_bench.live_temporary_profile("get tempradio\r\n -> > 919.5,500.000,5,5,88\r\n"),(919.5,500,5,5)) + for reply in ("> off", "TempRadio inactive", "TempRadio pending: 909.5,500,5,5", + "saved radio: 909.5,500,5,5", "OK - saved", "TempRadio active: nan,500,5,5", + "TempRadio active: 909.5,500,5,5junk", "> 909.5,500,5,5,88,extra"): + with self.subTest(reply=reply): + self.assertIsNone(actual_bench.live_temporary_profile(reply)) + + def test_nominal_cw_duration_matches_units_and_rejects_unencodable_words(self): + # SOS is 21 mark/intra-character units plus two three-unit letter gaps. + self.assertEqual(remote.nominal_seconds("SOS",25),27*.048) + self.assertEqual(remote.nominal_seconds("E E",25),9*.048) + self.assertEqual(remote.nominal_seconds("E",1),1.2) + self.assertEqual(remote.nominal_seconds("E",60),.02) + self.assertEqual(remote.nominal_seconds("?",25),15*.048) + for text in ("", " SOS", "SOS ", "S O", "S\tO"): + with self.subTest(text=text), self.assertRaises((ValueError, KeyError)): + remote.nominal_seconds(text,25) + with self.assertRaises(KeyError): + remote.nominal_seconds("snowman\u2603",25) + + def test_exact_padded_end_offsets_whitespace_and_maximum_capture(self): + self.assertEqual(remote.parse_capture_page(page(0, 0, None, ""), 0), ("", None, 0)) + self.assertEqual(remote.parse_capture_page("AB|" + page(0, 8, 4, " SOS"), 0), (" SOS", 4, 8)) + self.assertEqual(remote.parse_capture_page(page(4, 8, None, " E "), 4), (" E ", None, 8)) + self.assertEqual(remote.parse_capture_page(page(4000, 4096, None, "E" * 96), 4000), ("E" * 96, None, 4096)) + self.assertEqual(remote.public_key("Public key: " + KEY.upper()), KEY) + for key in ("none", KEY + "a", KEY + " " + "cd" * 32): + with self.assertRaises(RuntimeError): + remote.public_key(key) + + def test_bad_missing_truncated_nonprogressing_or_incomplete_pages_rejected(self): + for reply, offset in ((page(1, 10, 2, "E"), 0), ("not a page", 0), + (page(0, 5, None, "ABC"), 0), (page(0, 5, 3, "AB"), 0), + (page(0, 5, 0, ""), 0), (page(0, 2, 2, "AB"), 0), + (page(0, 1, None, "AB"), 0), (page(0, 4097, 1, "E"), 0), + (page(0, 3, None, "ABC", 1), 0)): + with self.subTest(reply=reply), self.assertRaises(RuntimeError): + remote.parse_capture_page(reply, offset) + + +class BenchControlTests(unittest.TestCase): + def test_direct_companion_ok_needs_closing_prompt_and_never_accepts_echo_only(self): + class Port: + in_waiting=0 + def __init__(self,chunks): self.chunks=iter(chunks); self.reads=0; self.writes=[] + def write(self,data): self.writes.append(data) + def read(self,length): self.reads+=1; return next(self.chunks,b"") + for parts in ((b"set tempradio 909.5,500,5,5,30\r\n",b" OK\r\n",b"> "), + (b"get morse\r\n -> > off;\r\n",b"> ")): + receiver=object.__new__(actual_bench.Console) + receiver.companion=True; receiver.serial_number="test"; receiver.commands=[]; receiver.port=Port(parts) + with patch.object(actual_bench,"time",types.SimpleNamespace(monotonic=lambda:0)): + reply=receiver.command("set tempradio 909.5,500,5,5,30") + self.assertEqual(reply,b"".join(parts).decode()) + self.assertEqual(receiver.port.reads,len(parts)) + self.assertEqual(receiver.port.writes,[b"set tempradio 909.5,500,5,5,30\r\n"]) + receiver=object.__new__(actual_bench.Console) + receiver.companion=True; receiver.serial_number="test"; receiver.commands=[] + receiver.port=Port((b"set tempradio 909.5,500,5,5,30\r\n> ",)) + times=iter((0,0,4)) + with patch.object(actual_bench,"time",types.SimpleNamespace(monotonic=lambda:next(times))): + with self.assertRaisesRegex(RuntimeError,"Incomplete CLI response"): + receiver.command("set tempradio 909.5,500,5,5,30") + + def test_sync_live_profile_waits_for_exact_applied_tuple_and_times_out(self): + receiver=object.__new__(actual_bench.Console); receiver.serial_number="test" + calls=[]; replies=iter(("OK - saved", "TempRadio pending: 909.5,500,5,5","> 919.5,500,5,5,88", + "TempRadio active: 909.5,500,5,5 (30 min)")) + receiver.start_when_idle=lambda text:calls.append(text) + receiver.command=lambda text:(calls.append(text),next(replies))[1] + clock=types.SimpleNamespace(monotonic=lambda:0,sleep=lambda delay:calls.append(("sleep",delay))) + with patch.object(actual_bench,"time",clock): + self.assertIn("active",receiver.apply_live_temporary("909.5,500,5,5")) + self.assertEqual(calls[0],"set tempradio 909.5,500,5,5,30") + self.assertEqual(calls.count("get tempradio"),4) + self.assertEqual(calls.count(("sleep",.1)),3) + receiver.command=lambda text:"TempRadio pending: 909.5,500,5,5" + times=iter((0,13)) + with patch.object(actual_bench,"time",types.SimpleNamespace(monotonic=lambda:next(times),sleep=lambda delay:None)): + with self.assertRaisesRegex(RuntimeError,"did not apply"): + receiver.apply_live_temporary("909.5,500,5,5") + calls.clear() + with self.assertRaises(ValueError): receiver.apply_live_temporary("909.5,500,5") + self.assertEqual(calls,[]) + + def test_selected_partner_and_direction_open_only_exact_pair(self): + for partner,direction in ((None,"both"),("pocket","both"),("pocket","to-rak"),("pocket","from-rak")): + with self.subTest(partner=partner,direction=direction): + opened=[]; trials=[] + class Output: + text=None + def exists(self): return False + def write_text(self,text): self.text=text + output=Output() + class Console: + def __init__(self,identity): self.serial_number=identity; self.commands=[]; opened.append(self) + def setup(self,cw_frequency=902.1): self.commands.append("setup"); self.cw_frequency=cw_frequency + def close(self): self.commands.append("close") + def trial(tx,rx,wpm,message,trace=False): + trials.append((tx.serial_number,rx.serial_number,wpm,message,trace)) + return {"passed":True} + arguments=["morse_bench","--output","memory","--rates","25","--messages","1","--direction",direction] + if partner: + arguments += ["--partner",partner] + with patch.object(actual_bench,"Console",Console), patch.object(actual_bench,"trial",trial), \ + patch.object(actual_bench,"Path",lambda path:output),patch("builtins.print"), \ + patch.object(sys,"argv",arguments): + actual_bench.main() + selected=partner or "t096" + rak_id,partner_id=actual_bench.SERIALS["rak"],actual_bench.SERIALS[selected] + self.assertEqual([device.serial_number for device in opened],[rak_id,partner_id]) + expected=[] + if direction in ("both","to-rak"): expected.append((partner_id,rak_id)) + if direction in ("both","from-rak"): expected.append((rak_id,partner_id)) + self.assertEqual([(item[0],item[1]) for item in trials],expected) + self.assertTrue(all(item[2:]==(25,actual_bench.MESSAGES[0],False) for item in trials)) + self.assertTrue(all(device.commands==["setup","close"] for device in opened)) + self.assertTrue(all(device.cw_frequency==902.1 for device in opened)) + result=json.loads(output.text) + self.assertEqual(result["partner"],selected) + self.assertEqual(result["direction"],direction) + self.assertEqual(set(result["commands"]),{"rak",selected}) + self.assertTrue(result["finished"]) + with patch.object(actual_bench,"Console") as console,patch.object(actual_bench,"Path") as output_path, \ + patch.object(sys,"argv",["morse_bench","--output","memory","--partner","pocket","--t096-rxgain","off"]), \ + patch("sys.stderr"): + output_path.return_value.exists.return_value=False + with self.assertRaises(SystemExit): actual_bench.main() + console.assert_not_called() + + def test_capture_payload_error_is_not_confused_with_cli_error(self): + commands=[] + receiver=object.__new__(actual_bench.Console) + def command(text,**kwargs): + commands.append((text,kwargs)) + return page(0,5,None,"ERROR") + receiver.command=command + self.assertEqual(receiver.capture(),"ERROR") + self.assertEqual(commands,[("morse read 0",{"allow_error":True})]) + receiver.command=lambda text,**kwargs: "Error: no capture" + with self.assertRaisesRegex(RuntimeError,"Malformed"): + receiver.capture() + + def test_quiet_control_never_transmits_and_restores_gain_on_success_or_failure(self): + for failure in (None,"gain_set","capture"): + with self.subTest(failure=failure): + devices=[] + class Output: + text=None + def exists(self): return False + def write_text(self,text): self.text=text + output=Output() + class Console: + def __init__(self,identity): + self.serial_number=identity; self.commands=[]; self.closed=False + devices.append(self) + def setup(self,cw_frequency=902.1): self.commands.append("setup") + def command(self,text,*args,**kwargs): + self.commands.append(text) + if text=="get radio.fem.rxgain": return "> on" + if text=="set radio.fem.rxgain off" and failure=="gain_set": + raise RuntimeError("injected gain set failure") + return "OK" + def start_when_idle(self,text): return self.command(text) + def capture(self): + if failure=="capture": raise RuntimeError("injected capture failure") + return "" + def close(self): self.closed=True + with patch.object(actual_bench,"Console",Console), patch.object(actual_bench,"Path",lambda path:output), \ + patch.object(actual_bench.time,"sleep"), patch("builtins.print"), \ + patch.object(sys,"argv",["morse_bench","--output","memory","--quiet-seconds","1","--t096-rxgain","off"]): + if failure: + with self.assertRaisesRegex(RuntimeError,"injected"): + actual_bench.main() + else: + actual_bench.main() + self.assertEqual(len(devices),2) + self.assertTrue(all(device.closed for device in devices)) + gain_device=next(device for device in devices if device.serial_number==actual_bench.SERIALS["t096"]) + self.assertEqual(gain_device.commands[-2:],["morse stop","set radio.fem.rxgain on"]) + self.assertFalse(any("morse send" in command for device in devices for command in device.commands)) + result=json.loads(output.text) + self.assertEqual(result["finished"],failure is None) + if failure: + self.assertIn("injected",result["error"]) + else: + self.assertEqual(len(result["trials"]),2) + self.assertTrue(all(item["kind"]=="quiet_control" and item["passed"] for item in result["trials"])) + + +class HandoffProtocolTests(unittest.IsolatedAsyncioTestCase): + async def test_async_live_profile_waits_for_exact_radio_and_bounds_timeout(self): + calls=[]; replies=iter(("OK - saved","> off", "TempRadio pending: 909.5,500,5,5", + "> 919.5,500,5,5,88","> 909.5,500,5,5,88")) + async def command(text): calls.append(text); return next(replies) + with patch.object(remote,"time",types.SimpleNamespace(monotonic=lambda:0)),patch.object(remote.asyncio,"sleep") as sleep: + self.assertEqual(await remote.apply_live_temporary(command,"909.5,500,5,5"),"> 909.5,500,5,5,88") + self.assertEqual(calls,["set tempradio 909.5,500,5,5,30"]+["get tempradio"]*4) + self.assertEqual(sleep.await_count,3) + async def pending(text): return "TempRadio pending: 909.5,500,5,5" + times=iter((0,13)) + with patch.object(remote,"time",types.SimpleNamespace(monotonic=lambda:next(times))): + with self.assertRaisesRegex(RuntimeError,"did not apply"): + await remote.apply_live_temporary(pending,"909.5,500,5,5") + calls.clear() + with self.assertRaises(ValueError): await remote.apply_live_temporary(command,"909.5,500,5") + self.assertEqual(calls,[]) + + def setUp(self): + class Reader: + def __init__(self): + self.forwarded = [] + + async def handle_rx(self, data): + self.forwarded.append(data) + + class Event: + def __init__(self, payload=None, error=False): + self.payload, self.error = payload or {}, error + + def is_error(self): + return self.error + + self.Reader, self.Event = Reader, Event + self.original = Reader.handle_rx + self.records, self.actions = [], [] + self.callbacks = [] + self.local_reply, self.remote_reply = None, None + self.admission_error = False + self.send_exception = None + self.mc = types.SimpleNamespace(_reader=Reader()) + self.mc.commands = types.SimpleNamespace(send=self.send_local, send_cmd=self.send_remote) + self.mc.subscribe = self.subscribe + self.bridge = remote.BinaryCLI(self.mc, Reader, "CONTACT", lambda kind, **fields: self.records.append((kind, fields))) + + def tearDown(self): + self.bridge.restore_reader() + self.assertIs(self.Reader.handle_rx, self.original) + + def subscribe(self, kind, callback): + self.assertEqual(kind, "CONTACT") + self.actions.append("subscribe") + self.callbacks.append(callback) + + def unsubscribe(): + self.actions.append("unsubscribe") + self.callbacks.remove(callback) + + return types.SimpleNamespace(unsubscribe=unsubscribe) + + def deliver(self, text, peer=KEY[:12], text_type=1): + for callback in tuple(self.callbacks): + callback(self.Event({"pubkey_prefix": peer, "txt_type": text_type, "text": text})) + + async def send_local(self, data, expected_events): + self.actions.append(("local_send", data)) + self.assertIsNone(expected_events) + self.assertEqual(data[0], 0x42) + if self.local_reply: + await self.local_reply(data[1:].decode("ascii")) + return self.Event() + + async def send_remote(self, key, text, timestamp): + self.actions.append(("remote_send", key, text, timestamp)) + self.assertTrue(self.callbacks, "subscription must exist before admission/send") + if self.send_exception: + raise self.send_exception + if self.remote_reply: + self.remote_reply(text) + return self.Event(error=self.admission_error) + + async def test_capture_paging_preserves_exact_text_and_rejects_changed_total_or_stall(self): + answers = {"morse read 0": page(0, 8, 4, "SOS "), "morse read 4": page(4, 8, None, "TEST")} + + async def command(text): + return answers[text] + + text, pages = await remote.read_capture(command) + self.assertEqual(text, "SOS TEST") + self.assertEqual([entry["offset"] for entry in pages], [0, 4]) + answers = {"morse read 0": page(0, 5, None, "ERROR")} + self.assertEqual((await remote.read_capture(command))[0], "ERROR") + answers = {"morse read 0": page(0, 8, 4, "SOS "), "morse read 4": page(4, 8, None, "TEST")} + answers["morse read 4"] = page(4, 9, None, "TEST ") + with self.assertRaisesRegex(RuntimeError, "changed"): + await remote.read_capture(command) + + async def many(text): + offset = int(text.split()[-1]) + return page(offset, 4096, offset + 1, "E") + + with self.assertRaisesRegex(RuntimeError, "did not finish"): + await remote.read_capture(many) + + async def test_local_nonce_identity_bound_reader_and_forwarding(self): + other = self.Reader() + + async def respond(text): + prefix = text[:3] + await other.handle_rx(b"\x1d" + prefix.encode() + b"OTHER READER") + await self.mc._reader.handle_rx(b"\x02normal SDK event") + await self.mc._reader.handle_rx(b"\x1dZZ|stale") + await self.mc._reader.handle_rx(b"\x1d" + prefix.encode() + b"> correct") + + self.local_reply = respond + self.assertEqual(await self.bridge.local("get morse"), "> correct") + self.assertEqual(await self.bridge.local("get morse"), "> correct") + self.assertEqual([action[1][1:4] for action in self.actions], [b"00|", b"01|"]) + self.assertEqual(len(other.forwarded), 2) + self.assertEqual(self.mc._reader.forwarded, [b"\x02normal SDK event"] * 2) + self.local_reply = None + with self.assertRaises((TimeoutError, asyncio.TimeoutError, RuntimeError)): + await self.bridge.local("get morse", timeout=.01) + + async def test_remote_fast_reply_precedes_send_event_and_rejects_wrong_correlation(self): + def respond(text): + prefix = text[:3] + self.deliver(prefix + "wrong peer", peer="cd" * 6) + self.deliver(prefix + "wrong message type", text_type=0) + self.deliver("ZZ|wrong nonce") + self.deliver(prefix + "OK - Morse RX queued", peer=KEY[:12].upper()) + + self.remote_reply = respond + current_utc=1234 + with patch.object(remote.time, "time", side_effect=lambda: current_utc): + for expected in (1234, 1235): + current_utc=expected + reply, latency = await self.bridge.remote(KEY, "morse listen 1") + self.assertEqual(reply, "OK - Morse RX queued") + self.assertGreaterEqual(latency, 0) + self.assertEqual(self.actions[-2][3], expected) + self.assertEqual(self.actions[0], "subscribe") + self.assertFalse(self.callbacks) + self.assertEqual([record[1]["nonce"] for record in self.records], ["00", "01"]) + + async def test_frozen_or_backstepped_clock_never_sends_and_unsubscribes(self): + self.bridge.last_timestamp=1234 + for utc in (1234,1200): + monotonic=iter((0,3)) + clock=types.SimpleNamespace(time=lambda: utc,monotonic=lambda: next(monotonic)) + with patch.object(remote,"time",clock): + with self.assertRaisesRegex(RuntimeError,"stopped/backstepped"): + await self.bridge.remote(KEY,"morse listen 1") + self.assertFalse(self.callbacks) + self.assertFalse(any(isinstance(action,tuple) and action[0]=="remote_send" for action in self.actions)) + + async def test_cancellation_during_utc_wait_unsubscribes_without_sending(self): + self.bridge.last_timestamp=1234 + with patch.object(remote.time,"time",return_value=1234): + pending=asyncio.create_task(self.bridge.remote(KEY,"morse listen 1")) + await asyncio.sleep(.001) + self.assertTrue(self.callbacks) + pending.cancel() + with self.assertRaises(asyncio.CancelledError): + await pending + self.assertFalse(self.callbacks) + self.assertFalse(any(isinstance(action,tuple) and action[0]=="remote_send" for action in self.actions)) + + async def test_remote_send_admission_is_not_reply_and_all_failures_unsubscribe(self): + with self.assertRaises((TimeoutError, asyncio.TimeoutError, RuntimeError)): + await self.bridge.remote(KEY, "morse listen 1", timeout=.01) + self.assertFalse(self.callbacks) + self.admission_error = True + with self.assertRaisesRegex(RuntimeError, "not admitted"): + await self.bridge.remote(KEY, "morse listen 1") + self.assertFalse(self.callbacks) + self.admission_error = False + self.send_exception = ValueError("transport failed") + with self.assertRaisesRegex(ValueError, "transport failed"): + await self.bridge.remote(KEY, "morse listen 1") + self.assertFalse(self.callbacks) + + async def test_error_replies_nonce_exhaustion_and_reader_restore_do_not_clobber_others(self): + self.remote_reply = lambda text: self.deliver(text[:3] + "Error: radio busy") + with self.assertRaisesRegex(RuntimeError, "Remote CLI error"): + await self.bridge.remote(KEY, "morse listen 1") + self.assertIn("Error:", (await self.bridge.remote(KEY, "morse listen 1", allow_error=True))[0]) + self.bridge.counter = 36**2 + with self.assertRaisesRegex(RuntimeError, "exhausted"): + await self.bridge.remote(KEY, "morse listen 1") + self.assertFalse(self.callbacks) + external = lambda reader, data: None + self.Reader.handle_rx = external + self.bridge.restore_reader() + self.assertIs(self.Reader.handle_rx, external) + self.Reader.handle_rx = self.bridge.patched + + +class HarnessRunTests(unittest.IsolatedAsyncioTestCase): + async def exercise(self,source,*,noise=False,sdk_failure=False): + actions=[]; opened=[]; results={"passed":False}; retained="SOS E " if noise else "SOS " + source_key="cd"*32 + class Port: + def __init__(self,identity): self.port=identity; self.is_open=True + def write(self,data): actions.append(("port_write",self.port,data)) + def close(self): self.is_open=False; actions.append(("port_close",self.port)) + class Console: + def __init__(self,identity,*args): + self.identity=identity; self.port=Port(identity); self.commands=[]; self.status_reads=0 + opened.append(self); actions.append(("open",identity)) + def command(self,text,*args,**kwargs): + self.commands.append(text); actions.append(("console",self.identity,text)) + if text=="get public.key": return KEY if self.identity==remote.SERIALS["rak"] else source_key + if text=="get tempradio": return "> 909.5,500,5,5,88" + if text=="get morse": + self.status_reads+=1 + return "> RX 902.100 MHz" if self.status_reads==2 else "> off; captured=4" + if text.startswith("morse read "): return page(0,len(retained),None,retained) + return "OK" + def start_when_idle(self,text): return self.command(text) + def apply_live_temporary(self,profile): self.commands.append("set tempradio "+profile+",30"); actions.append(("temporary",self.identity,profile)) + def read_quiet(self,*args): return "" + def close(self): self.port.close() + class MC: + def __init__(self): self.commands=types.SimpleNamespace(add_contact=self.add_contact) + async def add_contact(self,contact): + actions.append(("contact",contact["public_key"])) + return types.SimpleNamespace(is_error=lambda:False) + async def start_auto_message_fetching(self): actions.append(("fetch",)) + async def disconnect(self): actions.append(("disconnect",)) + class MeshCore: + @staticmethod + async def create_serial(port,**kwargs): + actions.append(("sdk_open",port)) + return None if sdk_failure else MC() + class Bridge: + def __init__(self,*args): pass + async def local(self,text,*args,**kwargs): + actions.append(("local",text)) + if text=="get public.key": return source_key + if text=="get tempradio": return "> TempRadio active: 909.5,500,5,5 (30 min)" + return "> off;" if text=="get morse" else "OK" + async def local_when_idle(self,text,*args,**kwargs): return await self.local(text) + async def remote(self,key,text,*args,**kwargs): + actions.append(("remote",text)) + if text=="morse listen 6": return "Error: duration",0 + if text.startswith("morse listen "): return "OK - Morse RX queued",0 + if text.startswith("morse read "): return page(0,len(retained),None,retained),0 + if text=="get tempradio": return "> 909.5,500,5,5,88",0 + if text=="get radio": return "> saved 910.525,250,11,8",0 + return "> off;",0 + def restore_reader(self): actions.append(("reader_restore",)) + sdk=types.ModuleType("meshcore"); sdk.MeshCore=MeshCore + sdk.EventType=types.SimpleNamespace(CONTACT_MSG_RECV="CONTACT") + reader=types.ModuleType("meshcore.reader"); reader.MessageReader=object + async def call_inert(func,*args,**kwargs): return func(*args,**kwargs) + def record(kind,**fields): + actions.append(("record",kind)) + results.setdefault("events",[]).append({"kind":kind,**fields}) + args=types.SimpleNamespace(source=source,minutes=.5,wpm=25,message="SOS",repeat=1,cw_frequency=902.1) + error=None + with patch.dict(sys.modules,{"meshcore":sdk,"meshcore.reader":reader}), \ + patch.object(remote,"Console",Console),patch.object(remote,"BinaryCLI",Bridge), \ + patch.object(remote.asyncio,"to_thread",call_inert),patch.object(remote.asyncio,"sleep"), \ + patch.object(remote,"time",types.SimpleNamespace(time=lambda:1234,monotonic=lambda:0)): + try: + await remote.run(args,results,record) + except RuntimeError as caught: + error=caught + return actions,opened,results,error + + async def test_exact_selected_source_and_expiry_recovery_before_comparison(self): + for source,noise in (("t096",False),("pocket",False),("pocket",True)): + with self.subTest(source=source,extra_noise=noise): + actions,opened,result,error=await self.exercise(source,noise=noise) + self.assertEqual([device.identity for device in opened],[remote.SERIALS["rak"],remote.SERIALS[source]]) + self.assertTrue(all(not device.port.is_open for device in opened)) + self.assertEqual(result["passed"],not noise) + if noise: + self.assertRegex(str(error),"RF decoded text differs") + self.assertFalse(result["capture"]["exact_rf_match"]) + self.assertTrue(result["capture"]["exact_remote_match"]) + self.assertEqual(result["capture"]["local"],"SOS E ") + self.assertTrue(result["recovery"]["passed"]) + else: + self.assertIsNone(error) + expired=actions.index(("record","listen_expired")) + verified=actions.index(("record","source_packet_radio_verified")) + self.assertLess(expired,verified) + self.assertLess(verified,actions.index(("record","local_capture_received"))) + self.assertLess(actions.index(("record","local_capture_received")),actions.index(("remote","morse read 0"))) + self.assertIn(("record","recovery_checked"),actions) + self.assertLess(actions.index(("record","recovery_checked")),actions.index(("disconnect",))) + commands=[action[2] for action in actions if action[0]=="console"] + commands += [action[1] for action in actions if action[0]=="local"] + self.assertFalse(any(command.startswith("set name ") or command.startswith("set radio ") for command in commands)) + self.assertFalse(any("919.5" in command for command in commands)) + self.assertTrue(all(action[2]==remote.PACKET_RADIO for action in actions if action[0]=="temporary")) + + async def test_failed_binary_handoff_recovers_only_selected_identity(self): + actions,opened,result,error=await self.exercise("pocket",sdk_failure=True) + self.assertRegex(str(error),"Could not start source Binary") + self.assertEqual([device.identity for device in opened], + [remote.SERIALS["rak"],remote.SERIALS["pocket"],remote.SERIALS["pocket"]]) + self.assertTrue(all(not device.port.is_open for device in opened)) + self.assertFalse(result["passed"]) + self.assertFalse(any(action[0]=="local" for action in actions)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_reply_handoff.py b/test/test_morse_reply_handoff.py new file mode 100644 index 000000000..955c1932c --- /dev/null +++ b/test/test_morse_reply_handoff.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Run remote CW listening only after the exact physical reply copies drain.""" +from pathlib import Path +import unittest +from test_morse_tx import compile_run, portable_radio_header +from test_radio_receive_contract import method +from test_radio_reply_mutation import LIFECYCLE + +ROOT = Path(__file__).resolve().parents[1] + + +class MorseReplyHandoffTests(unittest.TestCase): + def test_rx_disabled_handoff_is_stateless_and_never_calls_radio(self): + code = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {",1)[0] + code += """ +#include +int main() { + static_assert(MESH_ENABLE_MORSE_RX==0,"test must use the flash-saving RX gate"); + static_assert(sizeof(mesh::MorseReplyHandoff)==1,"disabled handoff must retain no session state"); + mesh::MorseReplyHandoff h; TestRadio radio; radio.rx_supported=true; + uint8_t actor[32]={1}; int packet=0,copy=1; + h.beginCommand(actor); + assert(!h.remoteCommand() && !h.pending() && !h.generation() && h.actor()==nullptr); + assert(!h.stage(902100,25,60000,0xfffffff0)); + h.prepareReply(&packet); h.copy(&packet,©); h.armReply(&packet,true); + h.complete(&packet); h.fail(©); h.endCommand(); + assert(!h.contains(&packet) && !h.ownsReply(&packet) && !h.ownsReply(©)); + for(uint32_t now : {0U,30000U,0xffffffffU}) { + assert(!h.service(&radio,now,true,true,902100,25)); + assert(!h.service(nullptr,now,false,false,0,0)); + } + h.cancel(); h.armReply(nullptr,false); + assert(!h.pending() && !h.generation() && radio.rx_starts==0 && radio.rx_stops==0); + char reply[160]; + assert(mesh::handleMorseCommand(&radio,"morse send SOS",reply,sizeof(reply))); + assert(radio.starts==1 && radio.active && radio.rx_starts==0); +} +""" + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self,code,flags=("-Wno-unused-parameter","-DMESH_ENABLE_MORSE_RX=0"),headers={ + "Dispatcher.h":portable_radio_header(),"helpers/IdentityStore.h":fs_mock, + "helpers/TempRadioReplyBarrier.h":"#error disabled handoff must not compile the reply barrier\n"}) + + def test_actual_shared_handoff_remote_parser_and_all_copy_deadlines(self): + code = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + code += (ROOT / "test/fixtures/morse_reply_handoff.cpp").read_text(encoding="ascii") + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), headers={ + "Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + def test_actual_common_loop_reauthorizes_full_acl_identity_after_phy_completion(self): + code = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8") + code += (ROOT / "test/fixtures/morse_reply_loop.cpp").read_text(encoding="ascii").replace( + "@LOOP@", method(common, "void CommonCLI::loop(")) + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), headers={ + "Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + def test_actual_all_infrastructure_and_tracker_phy_callbacks_own_exact_copies(self): + first = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + lifecycle = LIFECYCLE.split("int main() {", 1)[0] + lifecycle = lifecycle.replace("struct Packet {};", "struct Packet { int getPayloadType() const { return 3; } void calculatePacketHash(uint8_t* out) const { memset(out, 0, 8); } };") + lifecycle = lifecycle.replace(" struct NoMorseReply {\n void complete(const void*) {}\n void fail(const void*) {}\n void copy(const void*, const void*) {}\n } morse;", " mesh::MorseReplyHandoff morse;") + lifecycle = lifecycle.replace("NoMorseReply& morseReply()", "mesh::MorseReplyHandoff& morseReply()") + lifecycle = lifecycle.replace(" CLI _cli;", " CLI _cli;\n bool tracker_pending=false, tracker_sent=false; uint32_t tracker_reply_until=0; uint8_t tracker_packet_hash[8]={};\n uint32_t futureMillis(uint32_t delay) { return now_ms+delay; }") + lifecycle = "#define MAX_HASH_SIZE 8\n#define PAYLOAD_TYPE_REQ 1\nnamespace mesh { namespace tracker { constexpr unsigned kReplyWindowSeconds=30; } }\n" + lifecycle + main = (ROOT / "test/fixtures/morse_reply_role_callbacks.cpp").read_text(encoding="ascii") + specs = [ + ("repeater", "MyMesh", "examples/simple_repeater/MyMesh.cpp", None), + ("room", "MyMesh", "examples/simple_room_server/MyMesh.cpp", None), + ("sensor", "SensorMesh", "examples/simple_sensor/SensorMesh.cpp", None), + ("tracker", "SensorMesh", "examples/simple_sensor/Tracker.cpp", "examples/simple_sensor/SensorMesh.cpp"), + ("companion", "MyMesh", "examples/companion_radio/MyMesh.cpp", None), + ] + for name, role, path, copy_path in specs: + with self.subTest(role=name): + source = (ROOT / path).read_text(encoding="utf-8") + methods = "\n".join(method(source, signature) for signature in ( + f"void {role}::onSendComplete(", f"void {role}::onSendFail(")) + copy_source = (ROOT / (copy_path or path)).read_text(encoding="utf-8") + methods += "\n" + method(copy_source, f"void {role}::onRadioProfileCopyQueued(") + if name == "repeater": + methods += "\n" + method(source, "void MyMesh::finishRadioReply(") + role_fixture = lifecycle.replace("MyMesh", role) + if name == "companion": + role_fixture = role_fixture.replace(" CLI _cli;", " CLI _cli;\n mesh::MorseReplyHandoff& _morse_reply = _cli.morse;") + code = first + role_fixture.replace("@METHODS@", methods) + code += main.replace("@ROLE@", role) + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), headers={ + "Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + def test_actual_companion_admission_actor_allocation_and_queue_failure(self): + first = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") + methods = "\n".join(method(source, signature) for signature in ( + "void MyMesh::onCLICommandRecv(", "void MyMesh::onCLIReplyPrepared(", "void MyMesh::onCLIReplyQueued(")) + code = first + (ROOT / "test/fixtures/morse_reply_companion.cpp").read_text(encoding="ascii").replace("@METHODS@", methods) + fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), headers={ + "Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock}) + + base = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text(encoding="utf-8") + recv = method(base, "void BaseChatMesh::onPeerDataRecv(") + begin = recv.index("onCLIReplyPrepared(from, reply_pkt)") + end = recv.index("onCLIReplyQueued(from, reply_pkt, queued)", begin) + self.assertLess(begin, recv.index("queued = sendFloodScoped", begin)) + self.assertLess(begin, recv.index("queued = sendDirect", begin)) + self.assertLess(recv.index("queued = sendDirect", begin), end) + self.assertIn("else onCLIReplyQueued(from, nullptr, false)", recv[end:]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_rx.py b/test/test_morse_rx.py new file mode 100644 index 000000000..e09372fce --- /dev/null +++ b/test/test_morse_rx.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Execute timed CW RSSI decoding and actual exclusive receive transitions.""" +from pathlib import Path +import unittest +from test_morse_tx import compile_run, portable_radio_header +from test_carrier_wave import production_harness +from test_radio_receive_contract import method +from morse_rx_floor_step_experiment import legacy_estimator + +ROOT = Path(__file__).resolve().parents[1] + + +class MorseRxTests(unittest.TestCase): + def test_actual_hil_fast_off_keeps_rising_deglitch_and_production_unchanged(self): + code=(ROOT / "test/fixtures/morse_rx_fast_off.cpp").read_text(encoding="ascii") + for hil,flag,expected in ((1,None,0),(1,0,0),(1,1,1),(0,1,0),(0,-1,0),(0,2,0)): + with self.subTest(hil=hil,fast_off=flag): + flags=["-DMESH_MORSE_MAX_WPM=240",f"-DMESH_MORSE_HIL={hil}",f"-DEXPECTED_FAST_OFF={expected}"] + if flag is not None: flags.append(f"-DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE={flag}") + compile_run(self,code,flags=flags) + for invalid in (-1,2): + with self.subTest(invalid_hil_flag=invalid),self.assertRaisesRegex(AssertionError,"fast OFF debounce flag must be 0 or 1"): + compile_run(self,code,flags=("-DMESH_MORSE_MAX_WPM=240","-DMESH_MORSE_HIL=1", + f"-DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE={invalid}","-DEXPECTED_FAST_OFF=0")) + + def test_actual_retained_capture_and_trace_restart_with_only_safety_reserve(self): + source=(ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text(encoding="utf-8") + mocks=("static size_t total_free=0,largest_free=0;\nstatic unsigned largest_queries=0;\n" + "struct ESPMock { size_t getFreeHeap() const { return total_free; } } ESP;\n" + "constexpr int MALLOC_CAP_8BIT=1;\nsize_t heap_caps_get_largest_free_block(int) { ++largest_queries; return largest_free; }\n") + code="#include \n#include \n"+mocks+production_harness().split("int main() {",1)[0] + code=code.replace("virtual bool canAllocateMorseReceiver() const { return receiver_allocation_ok; }", + "virtual bool canAllocateMorseReceiver() const;") + code+=method(source,"bool RadioLibWrapper::canAllocateMorseReceiver(") + code+=(ROOT / "test/fixtures/morse_rx_driver.cpp").read_text(encoding="ascii").split("int main() {",1)[0] + code+=(ROOT / "test/fixtures/morse_rx_reuse_admission.cpp").read_text(encoding="ascii") + arduino=("#pragma once\n#include \n#include \n#include \n#include \n#include \n" + "inline uint32_t g_mock_millis=0;\ninline uint32_t millis() { return g_mock_millis; }\n") + for hil in (0,1): + with self.subTest(hil_trace=bool(hil)): + compile_run(self,code,flags=("-Wno-unused-parameter","-DESP32_PLATFORM",f"-DMESH_MORSE_HIL={hil}"), + headers={"Dispatcher.h":portable_radio_header(),"Arduino.h":arduino}) + + def test_actual_hil_cadence_isolated_from_production_wrap_stalls_and_no_spi_bursts(self): + code=production_harness().split("int main() {",1)[0] + code+=(ROOT / "test/fixtures/morse_rx_sample_cadence.cpp").read_text(encoding="ascii") + arduino=("#pragma once\n#include \n#include \n#include \n#include \n#include \n" + "inline uint32_t g_mock_millis=0;\ninline uint32_t millis() { return g_mock_millis; }\n") + for hil,requested,expected in ((1,1,1),(1,2,2),(1,10,10),(0,1,2),(0,10,2)): + with self.subTest(hil=hil,requested_ms=requested): + compile_run(self,code,flags=("-Wno-unused-parameter",f"-DMESH_MORSE_HIL={hil}", + f"-DMESH_MORSE_HIL_SAMPLE_INTERVAL_MS={requested}",f"-DEXPECTED_SAMPLE_MS={expected}"), + headers={"Dispatcher.h":portable_radio_header(),"Arduino.h":arduino}) + for invalid in (-1,0,11): + with self.subTest(invalid_hil_ms=invalid),self.assertRaisesRegex(AssertionError,"sample interval must be 1..10 milliseconds"): + compile_run(self,code,flags=("-Wno-unused-parameter","-DMESH_MORSE_HIL=1", + f"-DMESH_MORSE_HIL_SAMPLE_INTERVAL_MS={invalid}","-DEXPECTED_SAMPLE_MS=2"), + headers={"Dispatcher.h":portable_radio_header(),"Arduino.h":arduino}) + + def test_actual_four_main_loops_defer_optional_work_but_keep_radio_io_and_safety_live(self): + first=(ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {",1)[0] + fixture=(ROOT / "test/fixtures/morse_main_loop.cpp").read_text(encoding="ascii") + fs_mock=(ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + for role in ("companion_radio","simple_repeater","simple_room_server","simple_sensor"): + with self.subTest(role=role): + source=(ROOT / f"examples/{role}/main.cpp").read_text(encoding="utf-8") + code=first+fixture.replace("@LOOP@",method(source,"\nvoid loop() {")) + flags=["-Wno-unused-parameter","-Wno-sign-compare","-DNRF52_PLATFORM","-DDISPLAY_CLASS=1", + "-DENABLE_USB_INTERFACE=1","-DHAS_EXTERNAL_WATCHDOG=1","-DTBEAM_1W=1", + "-DPOWERSAVING_FIRSTSLEEP_SECS=1","-DPOWERSAVING_FIRST_SLEEP_SECS=1","-DBLE_PIN_CODE=123456"] + flags.append("-DROLE_"+({"companion_radio":"COMPANION","simple_repeater":"REPEATER", + "simple_room_server":"ROOM","simple_sensor":"SENSOR"}[role])+"=1") + compile_run(self,code,flags=flags,headers={"Dispatcher.h":portable_radio_header(), + "helpers/IdentityStore.h":fs_mock}) + + def test_actual_decoder_speed_noise_gaps_bounds_timeout_and_retention(self): + code = (ROOT / "test/fixtures/morse_rx_decoder.cpp").read_text(encoding="ascii") + output = compile_run(self, code) + self.assertIn("MorseRx size:", output) + print(output.strip()) + + def test_actual_seeded_estimator_floor_jump_fading_weak_noise_and_lab_speed_boundaries(self): + code = (ROOT / "test/fixtures/morse_rx_floor_step.cpp").read_text(encoding="ascii") + for extended in (False, True): + with self.subTest(lab_speed_sweep=extended): + flags = ["-DMORSE_ESTIMATOR_REQUIRE_PASS"] + if extended: + flags += ["-DMESH_MORSE_MAX_WPM=240", "-DMORSE_ESTIMATOR_EXTENDED"] + compile_run(self, code, flags=flags) + original = (ROOT / "src/helpers/MorseRx.h").read_text(encoding="utf-8") + old = legacy_estimator(original) + self.assertNotEqual(original, old, "negative control must remove the real floor-step repair") + with self.assertRaisesRegex(AssertionError, "Assertion|assert"): + compile_run(self, code, flags=("-DMORSE_ESTIMATOR_REQUIRE_PASS",), headers={ + "helpers/MorseRx.h": old, + "helpers/MorseTx.h": (ROOT / "src/helpers/MorseTx.h").read_text(encoding="utf-8")}) + + def test_actual_smooth_first_floor_margin_strong_fades_and_transition_spans(self): + code=(ROOT / "test/fixtures/morse_rx_mark_fade.cpp").read_text(encoding="ascii") + for extended in (False,True): + with self.subTest(lab_speed_sweep=extended): + flags=["-DMORSE_MARK_FADE_REQUIRE_PASS"] + if extended: + flags += ["-DMESH_MORSE_MAX_WPM=240","-DMORSE_HYBRID_MATRIX"] + compile_run(self,code,flags=flags) + source=(ROOT / "src/helpers/MorseRx.h").read_text(encoding="utf-8") + provisional=method(source,"if (_keyed && !_floor_settled)") + regressed=source.replace(provisional,"if (_keyed && threshold < _signal-5.0f) threshold=_signal-5.0f;") + self.assertNotEqual(source,regressed) + with self.assertRaisesRegex(AssertionError,"Assertion|assert"): + compile_run(self,code,flags=("-DMORSE_MARK_FADE_REQUIRE_PASS",),headers={ + "helpers/MorseRx.h":regressed,"helpers/MorseTx.h":(ROOT / "src/helpers/MorseTx.h").read_text(encoding="utf-8")}) + + def test_actual_off_decay_shoulder_does_not_hide_next_weak_word(self): + code = (ROOT / "test/fixtures/morse_rx_decay_shoulder.cpp").read_text(encoding="ascii") + compile_run(self, code, flags=("-DMORSE_SHOULDER_REQUIRE_PASS",)) + source = (ROOT / "src/helpers/MorseRx.h").read_text(encoding="utf-8") + quiet = method(source, "if (!_keyed && !raw_keyed && !_candidate_valid)") + regressed = quiet.replace(" _signal = _noise + 12.0f;\n", "") + self.assertNotEqual(quiet, regressed) + with self.assertRaisesRegex(AssertionError, "Assertion|assert"): + compile_run(self, code, flags=("-DMORSE_SHOULDER_REQUIRE_PASS",), headers={ + "helpers/MorseRx.h": source.replace(quiet, regressed), + "helpers/MorseTx.h": (ROOT / "src/helpers/MorseTx.h").read_text(encoding="utf-8")}) + + def test_actual_receiver_ownership_no_tx_and_fault_recovery(self): + code = production_harness().split("int main() {", 1)[0] + code = "#include \n" + code + code += (ROOT / "test/fixtures/morse_rx_driver.cpp").read_text(encoding="ascii") + arduino = ("#pragma once\n#include \n#include \n#include \n" + "#include \n#include \ninline uint32_t g_mock_millis=0;\n" + "inline uint32_t millis() { return g_mock_millis; }\n") + compile_run(self, code, flags=("-Wno-unused-parameter",), headers={ + "Dispatcher.h": portable_radio_header(), "Arduino.h": arduino}) + + def test_actual_lazy_capture_allocation_preserves_safety_reserve_on_every_backend(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text(encoding="utf-8") + code = (ROOT / "test/fixtures/morse_rx_admission.cpp").read_text(encoding="ascii").replace( + "@ADMISSION@", method(source, "bool RadioLibWrapper::canAllocateMorseReceiver(")) + for hil in (0, 1): + for platform in (None, "ESP32_PLATFORM", "NRF52_PLATFORM", "RP2040_PLATFORM", "STM32_PLATFORM"): + with self.subTest(platform=platform, hil=hil): + flags = ["-Wno-unused-variable", f"-DMESH_MORSE_HIL={hil}"] + if platform: + flags.append("-D" + platform) + if platform == "STM32_PLATFORM": + flags.append("-DMESH_ENABLE_MORSE_RX=1") + compile_run(self, code, flags=flags) + + def test_stm32_default_rx_gate_still_keeps_actual_morse_tx_and_opt_in_is_explicit(self): + code = production_harness().split("int main() {", 1)[0] + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text(encoding="utf-8") + code = code.replace("virtual bool canAllocateMorseReceiver() const { return receiver_allocation_ok; }", + "virtual bool canAllocateMorseReceiver() const;") + code = "#include \n#include \ncaddr_t _sbrk(int) { return reinterpret_cast(1); }\n" + code + code += "#if MESH_ENABLE_MORSE_RX\n"+method(source, "bool RadioLibWrapper::canAllocateMorseReceiver(")+"\n#endif\n" + stm = (ROOT / "src/helpers/radiolib/CustomSTM32WLxWrapper.h").read_text(encoding="utf-8") + code += "struct STMReceive : RadioLibWrapper {\n#if MESH_ENABLE_MORSE_RX\n"+method(stm,"bool supportsMorseRx(")+"\n#endif\n" + code += method(stm,"bool supportsMorseTx(")+"\n};\n" + code += """ +template struct HasCapturePointer : std::false_type {}; +template struct HasCapturePointer> : std::true_type {}; +template struct HasCaptureAllocator : std::false_type {}; +template struct HasCaptureAllocator> : std::true_type {}; +int main() { + static_assert(MESH_ENABLE_MORSE_RX==EXPECTED_RX,"STM32 RX opt-in must be explicit"); + static_assert(HasCapturePointer::value==bool(EXPECTED_RX),"disabled RX must exclude capture metadata"); + static_assert(HasCaptureAllocator::value==bool(EXPECTED_RX),"disabled RX must exclude allocation machinery"); + advance(0); + STMReceive w; +#if MESH_ENABLE_MORSE_RX + assert(w.supportsMorseRx() && w.canAllocateMorseReceiver()); + assert(w.startMorseRx(902.1f,25,1000)==mesh::MorseRxResult::Started); + assert(w._morse_rx && w.isMorseRxActive() && !w.chip.starts); + assert(w.stopMorseRx()==mesh::MorseRxResult::Stopped && !w.isMorseRxActive()); +#else + assert(!w.supportsMorseRx()); + assert(w.startMorseRx(902.1f,25,60000)==mesh::MorseRxResult::Unsupported); + assert(!w.chip.standby_calls && !w.board.awake); + assert(!w.serviceMorseRx()); + assert(w.stopMorseRx()==mesh::MorseRxResult::Unsupported); + mesh::MorseRxDiagnostics stats; + assert(!w.morseRxDiagnostics(stats) && !w.morseCaptureGeneration() && !w.morseCaptureLength()); + assert(!w.isMorseRxActive() && !w.morseRxRecovering() && !w.morseRxLearning()); + assert(!w.morseRxBandwidthKHz() && !w.morseRxRemainingMillis() && !w.morseCaptureTruncated()); + assert(w.morseRxFrequency()==mesh::MorseTx::DefaultFrequencyMHz && !strcmp(w.morseCaptureText(),"")); + w.clearMorseCapture(); assert(!w.morseCaptureGeneration()); + char empty[2]={'X','Y'}; + assert(w.readMorseCapture(0,empty,sizeof(empty))==0 && empty[0]==0 && empty[1]=='Y'); +#endif + assert(w.supportsMorseTx()); + assert(w.startMorseTx(902.1f,25,"EE")==mesh::MorseTxResult::Started); + while(w.isMorseTxActive()) { g_mock_millis=w._morse.nextDeadline(); w.serviceCarrierWave(); } + assert(w.chip.starts==2 && !w._cw_active && !w.chip.carrier && w.isInRecvMode()); +} +""" + arduino = ("#pragma once\n#include \n#include \n#include \n" + "#include \n#include \ninline uint32_t g_mock_millis=0;\n" + "inline uint32_t millis() { return g_mock_millis; }\n") + for enabled in (0,1): + with self.subTest(explicit_rx_opt_in=bool(enabled)): + flags = ["-Wno-unused-parameter", "-DSTM32_PLATFORM", f"-DEXPECTED_RX={enabled}"] + if enabled: + flags.append("-DMESH_ENABLE_MORSE_RX=1") + compile_run(self, code, flags=flags, headers={ + "Dispatcher.h": portable_radio_header(), "Arduino.h": arduino}) + + def test_hil_lazy_trace_checked_allocation_wrap_retention_and_clear(self): + allocator = ("#include \n#include \n" + "static unsigned nothrow_calls=0, fail_nothrow_call=0;\n" + "void* operator new(std::size_t size,const std::nothrow_t&) noexcept {\n" + " if (++nothrow_calls==fail_nothrow_call) return nullptr;\n" + " try { return ::operator new(size); } catch (...) { return nullptr; }\n}\n") + code = allocator + "#include \n" + production_harness().split("int main() {", 1)[0] + code += (ROOT / "test/fixtures/morse_rx_driver.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + code += (ROOT / "test/fixtures/morse_rx_hil_driver.cpp").read_text(encoding="ascii") + arduino = ("#pragma once\n#include \n#include \n#include \n" + "#include \n#include \ninline uint32_t g_mock_millis=0;\n" + "inline uint32_t millis() { return g_mock_millis; }\n") + compile_run(self, code, flags=("-Wno-unused-parameter", "-DMESH_MORSE_HIL=1"), headers={ + "Dispatcher.h": portable_radio_header(), "Arduino.h": arduino}) + + def test_actual_companion_owned_terminal_partial_writes_pause_and_final_capture(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") + code = (ROOT / "test/fixtures/morse_rx_terminal.cpp").read_text(encoding="ascii").replace( + "@TERMINAL@", method(source, "void MyMesh::serviceMorseTerminal(")) + compile_run(self, code) + loop = method(source, "void MyMesh::loop(") + self.assertIn("serviceMorseTerminal();", loop) + self.assertNotIn("serviceMorseTerminal();", method(source, "void MyMesh::sendTerminalTraceRoute(")) + + def test_native_receive_requires_restorable_chip_and_all_stm32_targets_register_real_init(self): + wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text(encoding="utf-8") + self.assertIn("return false", method(wrapper, "bool supportsMorseRx(")) + default_prepare = method(wrapper, "virtual int16_t prepareMorseReceiver(") + self.assertNotIn("applyParams", default_prepare) + self.assertNotIn("startReceive", default_prepare) + stm = (ROOT / "src/helpers/radiolib/CustomSTM32WLxWrapper.h").read_text(encoding="utf-8") + self.assertIn("_deep_init != nullptr", method(stm, "bool supportsRadioDeepInit(")) + self.assertIn("_deep_init && _deep_init()", method(stm, "bool radioDeepInit(")) + for board in ("rak3x72", "tiny_relay", "wio-e5-mini", "wio-e5-dev"): + with self.subTest(board=board): + source = (ROOT / f"variants/{board}/target.cpp").read_text(encoding="utf-8") + initialize = method(source, "bool radio_init(") + self.assertIn("radio_driver.setDeepInitCallback(radio_init)", initialize) + self.assertLess(initialize.index("radio.begin("), initialize.index("setDeepInitCallback(")) + + def test_actual_chip_rssi_reads_are_checked_and_never_restart_receiver(self): + code = (ROOT / "test/fixtures/morse_rx_chip_rssi.cpp").read_text(encoding="ascii") + sx1262 = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text(encoding="utf-8") + code = code.replace("@SX1262_GETTER@", method(sx1262, "float getMorseRssi(")) + wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text(encoding="utf-8") + code = code.replace("@DEFAULT_BANDWIDTH@", method(wrapper, "virtual float morseReceiverBandwidthKHz(")) + sx1276 = (ROOT / "src/helpers/radiolib/CustomSX1276.h").read_text(encoding="utf-8") + code = code.replace("@SX1276_GETTER@", method(sx1276, "float getMorseRssi(")) + for chip in ("SX1262", "SX1268", "LLCC68", "STM32WLx", "LR1110", "LR1121", "LR2021", "SX1276"): + source = (ROOT / f"src/helpers/radiolib/Custom{chip}Wrapper.h").read_text(encoding="utf-8") + methods = method(source, "float readMorseRssi(") + if "float morseReceiverBandwidthKHz(" in source: + methods += "\n" + method(source, "float morseReceiverBandwidthKHz(") + code = code.replace(f"@{chip}@", methods) + compile_run(self, code) + + def test_actual_native_fsk_receive_filters_reference_supply_rf_routes_and_checked_failures(self): + code = (ROOT / "test/fixtures/morse_rx_native.cpp").read_text(encoding="ascii") + for chip in ("LR1110", "LR1121", "LR2021", "SX1276"): + source = (ROOT / f"src/helpers/radiolib/Custom{chip}.h").read_text(encoding="utf-8") + code = code.replace(f"@{chip}@", method(source, "int16_t beginMorseReceive(")) + if chip == "LR2021": + code = code.replace("@LR2021_RESTORE@", method(source, "int16_t restoreRfSwitchTable(")) + sx1262 = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text(encoding="utf-8") + code = code.replace("@FAST_RSSI@", method(sx1262, "int16_t setMorseFastRssi(")) + for chip in ("SX1262", "SX1268", "LLCC68", "STM32WLx"): + source = (ROOT / f"src/helpers/radiolib/Custom{chip}.h").read_text(encoding="utf-8") + self.assertIn("setSX126xMorseRssiWindow(this)", method(source, "int16_t setMorseFastRssi(")) + for flags in ((), ("-DMESH_MORSE_HIL=1", "-DMESH_MORSE_HIL_RX_BITRATE_KBPS=20.0f"), + ("-DMESH_MORSE_HIL=0", "-DMESH_MORSE_HIL_RX_BITRATE_KBPS=20.0f"), + ("-DMESH_MORSE_HIL=1", "-DMESH_MORSE_HIL_RSSI_WINDOW=2"), + ("-DMESH_MORSE_HIL=0", "-DMESH_MORSE_HIL_RSSI_WINDOW=2"), + ("-DMESH_MORSE_HIL=1","-DMESH_MORSE_HIL_PACKET_GATING=0"), + ("-DMESH_MORSE_HIL=1","-DMESH_MORSE_HIL_PACKET_GATING=1"), + ("-DMESH_MORSE_HIL=0","-DMESH_MORSE_HIL_PACKET_GATING=1")): + with self.subTest(receiver_clock=flags): + compile_run(self, code, flags=flags, headers={"RadioLib.h": "#pragma once\n"}) + for invalid_window in (-1,8): + with self.subTest(invalid_rssi_window=invalid_window), self.assertRaisesRegex( + AssertionError,"RSSI averaging exponent must fit bits4:2"): + compile_run(self,code,flags=("-DMESH_MORSE_HIL=1",f"-DMESH_MORSE_HIL_RSSI_WINDOW={invalid_window}"), + headers={"RadioLib.h":"#pragma once\n"}) + for invalid in (-1,2): + with self.subTest(invalid_packet_gate=invalid),self.assertRaisesRegex(AssertionError,"packet gating must be 0 or 1"): + compile_run(self,code,flags=("-DMESH_MORSE_HIL=1",f"-DMESH_MORSE_HIL_PACKET_GATING={invalid}"), + headers={"RadioLib.h":"#pragma once\n"}) + + def test_actual_all_native_wrapper_admissions_partial_reset_and_delayed_lora_recovery(self): + code = production_harness().split("int main() {", 1)[0] + code = code.replace("struct Chip {", "struct Chip {\n unsigned invalidations=0;\n void invalidateMorseModemCache() { ++invalidations; }\n int beginMorseReceive(float freq) { ++fsk_starts; fsk=true; frequency=freq; return fsk_result; }\n") + code = code.replace(" bool awake=false;", " bool reset_prepare_ok=true,reset_finish_ok=true; unsigned reset_prepares=0,reset_finishes=0;\n bool finishRadioHardReset() { ++reset_finishes; return reset_finish_ok; }\n bool awake=false;") + code = code.replace(" uint8_t beginReconfigure();", " bool prepareRadioHardReset() { ++board.reset_prepares; return board.reset_prepare_ok; }\n uint8_t beginReconfigure();") + # Execute each actual wrapper admission; low-level FSK configuration is + # covered in the separate native-helper fixture above. + code += "\ntemplate int16_t prepareSX126xMorseReceiver(T* radio,float frequency) { return radio->beginMorseReceive(frequency); }\n" + fixture = (ROOT / "test/fixtures/morse_rx_chip_preparation.cpp").read_text(encoding="ascii") + for chip in ("SX1262", "SX1268", "LLCC68", "STM32WLx", "LR1110", "LR1121", "LR2021", "SX1276"): + source = (ROOT / f"src/helpers/radiolib/Custom{chip}Wrapper.h").read_text(encoding="utf-8") + extracted = "\n".join(method(source, signature) for signature in ( + "bool supportsMorseRx(", "float morseReceiverBandwidthKHz(", "int16_t prepareMorseReceiver(")) + if "int16_t startMorseReceiver(" in source: + extracted += "\n"+method(source,"int16_t startMorseReceiver(") + fixture = fixture.replace(f"@{chip}@", extracted) + code += fixture + arduino = ("#pragma once\n#include \n#include \n#include \n" + "#include \n#include \ninline uint32_t g_mock_millis=0;\n" + "inline uint32_t millis() { return g_mock_millis; }\n#define RADIOLIB_ERR_UNSUPPORTED -17\n#define RADIOLIB_ERR_CHIP_NOT_FOUND -2\n" + "#define RADIOLIB_LR11X0_RX_TIMEOUT_INF 0xffffffU\n#define RADIOLIB_LR2021_RX_TIMEOUT_INF 0xffffffU\n") + compile_run(self, code, flags=("-Wno-unused-parameter",), headers={ + "Dispatcher.h": portable_radio_header(), "Arduino.h": arduino}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_terminal_input.py b/test/test_morse_terminal_input.py new file mode 100644 index 000000000..c0aed4b90 --- /dev/null +++ b/test/test_morse_terminal_input.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Run real USB editing, Morse progress and the existing short-write queue.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +import test_companion_usb_input as usb +from test_radio_receive_contract import method +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class MorseTerminalInputTests(unittest.TestCase): + def test_actual_parser_editing_reply_and_physical_short_write_ownership(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text(encoding="ascii") + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="ascii") + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text(encoding="ascii") + functions = "\n".join(extract_braced(main, signature) for signature in ( + "static void clearUsbTerminalLine()", + "static bool acceptUsbBinaryStartupFrame(uint32_t completed_at)", + "static void enterUsbTerminalMode(bool show_banner", + "static void leaveUsbTerminalMode(bool acknowledge)", + "static void resetUsbMotaMode()", + "static void leaveUsbMotaMode(bool acknowledge)", + "static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin)", + "static void serviceUsbMota()", + "static bool serviceUsbMotaResponseBoundary()", + "static void resetUsbTerminalHostSession()", + "static void serviceUsbTerminal()", + "static void expireUsbBinaryStartupProbeBeforeDispatch()", + "static void syncUsbTerminalInputEditing()", + )).replace("static void enterUsbTerminalMode(bool show_banner)", + "static void enterUsbTerminalMode(bool show_banner = true)") + producer = method(mesh, "void MyMesh::serviceMorseTerminal(").replace("MyMesh::", "Mesh::") + setter = method(header, "void setTerminalInputEditing(") + code = usb.HARNESS.split("static void reset()", 1)[0] + code = code.replace('#include ', '#include \n#include \n#define COMPANION_FEATURE_TEXT_TERMINAL 1') + code = code.replace(" std::deque bytes;", " size_t write_capacity=64,write_limit=2;\n std::deque bytes;") + code = code.replace("int availableForWrite() override { return 4096; }", "int availableForWrite() override { return int(write_capacity); }") + code = code.replace("output.append(reinterpret_cast(p),n); return n;", + "const size_t count=std::min(n,std::min(write_capacity,write_limit)); output.append(reinterpret_cast(p),count); return count;") + code = code.replace("} input;", "} input;\nmesh::BufferedNonBlockingWriteStream<128> terminal(input);") + code = code.replace("static Stream& usbTerminalOutput() { return input; }", "static Stream& usbTerminalOutput() { return terminal; }") + code = code.replace("void discardUsbTerminalOutput() {}", "void discardUsbTerminalOutput() { terminal.discardPending(); }") + code = code.replace("Stream& usbCompanionPort() { return input; }", "Stream& usbCompanionPort() { return input; }\nStream& usbTerminalPort() { return terminal; }\nbool hasPendingUsbTerminalOutput() { return terminal.queuedByteCount()!=0; }") + radio = r''' +struct Radio { + bool receiving=true; uint32_t generation=1; std::string capture="E"; + bool isMorseRxActive() const { return receiving; } + uint32_t morseCaptureGeneration() const { return generation; } + size_t morseCaptureLength() const { return capture.size(); } + size_t readMorseCapture(size_t offset,char* out,size_t cap) const { + const size_t n=offset OK - Morse stopped\r\n"); + } +''') + # This lifecycle fixture used raw output as a mock before; transitions + # now operate on the actual queue, just as the production facade does. + code = code.replace("static void queueUsbTerminalControlReply(const char* reply) { input.output+=reply; }", + "static void queueUsbTerminalControlReply(const char* reply) { terminal.write(reinterpret_cast(reply),strlen(reply)); }") + code = code.replace("static void drainUsbTerminalOutputBeforeProtocolSwitch() {}", + "static void drainUsbTerminalOutputBeforeProtocolSwitch() { for(unsigned i=0;i<1000&&terminal.queuedByteCount();++i) terminal.service(); terminal.discardPending(); }") + code = code.replace("@FUNCTIONS@", producer + "\n" + functions) + accessor = extract_braced(usb.CONTEXT, "static SerialMotaSource& serialFolderSource()") + code = usb.bind_production_input_constants(code.replace("@SOURCE_ACCESSOR@", accessor)) + code += r''' +static void drain() { + unsigned loops=0; + while(terminal.queuedByteCount()) { terminal.service(); assert(++loops<1000); } +} +int main() { + g_mock_millis=100; enterUsbTerminalMode(true); input.output.clear(); + // One whole Morse record is admitted although the hardware accepts only + // two bytes per write. The existing queue owns its entire unwritten suffix. + the_mesh.serviceMorseTerminal(); assert(terminal.queuedByteCount()>0); + const auto prefix=input.output; input.write_capacity=0; + the_mesh.radio.capture="E SOS"; + input.push("morse st"); syncUsbTerminalInputEditing(); the_mesh.serviceMorseTerminal(); + serviceUsbTerminal(); syncUsbTerminalInputEditing(); + assert(usb_terminal_line_len==8 && !strcmp(usb_terminal_line,"morse st")); + assert(the_mesh._terminal_input_editing && input.output==prefix); + input.write_capacity=64; drain(); + const auto typed=input.output; + for(unsigned i=0;i<10;++i) { g_mock_millis+=250; the_mesh.serviceMorseTerminal(); } + assert(input.output==typed && the_mesh.commands.empty() && the_mesh._morse_terminal_offset==1); + // Immediate stop is parsed with a blocked/unread output endpoint. Its reply + // queues after the previous whole record, never inside it or typed text. + input.write_capacity=0; input.push("op\r"); syncUsbTerminalInputEditing(); + serviceUsbTerminal(); syncUsbTerminalInputEditing(); + assert(the_mesh.stops==1 && !the_mesh.radio.receiving); + assert(the_mesh.commands.size()==1 && the_mesh.commands[0]=="morse stop"); + assert(!the_mesh._terminal_input_editing && !usb_terminal_line_len); + the_mesh.serviceMorseTerminal(); assert(the_mesh._morse_terminal_offset==1); + input.write_capacity=64; drain(); + assert(input.output=="\r\n[Morse RX] E\r\n> morse stop\r\n -> OK - Morse stopped\r\n> "); + the_mesh.serviceMorseTerminal(); drain(); + assert(input.output.find(" -> OK - Morse stopped")\n#include \n#include \nnamespace mesh {\n" + + method(dispatcher, "enum class RadioParamApplyResult") + ";\n" + + method(dispatcher, "class Radio {") + ";\n}\n") + + +def compile_run(testcase, code, includes=(), flags=(), headers=None): + with tempfile.TemporaryDirectory(prefix="morse-tx-") as folder: + work = Path(folder) + source, binary = work / "test.cpp", work / "test" + source.write_text(code, encoding="utf-8") + for path, text in (headers or {}).items(): + target = work / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + command = [os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", + "-Werror", *SANITIZERS, *flags, *["-I" + str(path) for path in includes], + "-I" + str(work), "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + str(source), "-o", str(binary)] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + testcase.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + testcase.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + +class MorseTxTests(unittest.TestCase): + def test_production_encoder_alphabet_punctuation_limits_and_exact_timing(self): + code = (ROOT / "test/fixtures/morse_encoder.cpp").read_text(encoding="ascii") + compile_run(self, code) + + def test_actual_native_fsk_driver_and_fault_recovery(self): + code = production_harness().split("int main() {", 1)[0] + code += (ROOT / "test/fixtures/morse_driver.cpp").read_text(encoding="ascii") + arduino = ("#pragma once\n#include \n#include \n#include \n" + "#include \n#include \ninline uint32_t g_mock_millis=0;\n" + "inline uint32_t millis() { return g_mock_millis; }\n") + compile_run(self, code, flags=("-Wno-unused-parameter",), + headers={"Dispatcher.h": portable_radio_header(), "Arduino.h": arduino}) + + def test_actual_chip_specific_ramps_band_routes_power_mapping_and_restoration(self): + code = production_harness().split("int main() {", 1)[0] + chip = (ROOT / "src/helpers/radiolib/CustomLR2021.h").read_text(encoding="ascii") + routing = ("struct RoutedModule { unsigned switch_calls=0; uint8_t switch_mode=0; " + "int register_address=-1, register_value=-1, register_msb=-1, register_lsb=-1, register_result=0; " + "void setRfSwitchState(uint8_t value) { ++switch_calls; switch_mode=value; } " + "int SPIsetRegValue(int address,int value,int msb,int lsb) { register_address=address; " + "register_value=value; register_msb=msb; register_lsb=lsb; return register_result; } };\n" + "struct LR2021 { enum { MODE_TX=2, MODE_TX_HF=6 }; };\n") + code = code.replace("struct Chip {", routing + "struct Chip {\n RoutedModule routed_module; RoutedModule* mod=&routed_module; float freqMHz=0; int cw_test_mode=0;\n") + code = code.replace("int setFrequency(float value) { frequency=value; return frequency_result; }", + "int setFrequency(float value) { frequency=freqMHz=value; return frequency_result; }") + chip_methods = (method(chip, "uint8_t rfSwitchTxMode(") + "\n" + + method(chip, "int16_t transmitMorseCarrier(") + "\n" + + "int16_t setTxTestMode(int mode) { cw_test_mode=mode; return transmitDirect(); }\n") + code = code.replace("int clearIrqFlags(", chip_methods + "int clearIrqFlags(") + code = "#include \n#include \n" + code + fixture = (ROOT / "test/fixtures/morse_chip_hooks.cpp").read_text(encoding="ascii") + specs = { + "SX1262": ("bool supportsCarrierWave(", "int16_t prepareMorseCarrier(", "int16_t leaveMorseCarrier(", "bool restoreCarrierWaveModem(", "int16_t applyCachedTxPower("), + "SX1268": ("bool supportsMorseTx(", "bool validateMorseFrequency(", "int16_t prepareMorseCarrier(", "int16_t leaveMorseCarrier(", "bool restoreCarrierWaveModem("), + "LLCC68": ("bool supportsMorseTx(", "int16_t prepareMorseCarrier(", "int16_t leaveMorseCarrier(", "bool restoreCarrierWaveModem("), + "STM32WLx": ("bool supportsMorseTx(", "int16_t prepareMorseCarrier(", "int16_t leaveMorseCarrier(", "bool restoreCarrierWaveModem("), + "LR1110": ("bool supportsCarrierWave(", "int16_t prepareMorseCarrier(", "bool restoreCarrierWaveModem("), + "LR1121": ("bool supportsMorseTx(", "int16_t prepareMorseCarrier(", "bool restoreCarrierWaveModem("), + "LR2021": ("bool supportsMorseTx(", "bool validateMorseFrequency(", "int16_t prepareMorseCarrier(", "int16_t enterMorseCarrier(", "bool restoreCarrierWaveModem("), + } + for chip_name, signatures in specs.items(): + header = (ROOT / f"src/helpers/radiolib/Custom{chip_name}Wrapper.h").read_text(encoding="ascii") + fixture = fixture.replace(f"@{chip_name}@", "\n".join(method(header, signature) for signature in signatures)) + sx1276 = (ROOT / "src/helpers/radiolib/CustomSX1276.h").read_text(encoding="ascii") + fixture = fixture.replace("@SX1276_RAMP@", method(sx1276, "int16_t setMorsePaRamp(")) + code += fixture + arduino = ("#pragma once\n#include \n#include \n#include \n" + "#include \n#include \ninline uint32_t g_mock_millis=0;\n" + "inline uint32_t millis() { return g_mock_millis; }\n") + radio_lib = ("#pragma once\n#define RADIOLIB_SX126X_PA_RAMP_3400U 7\n" + "#define RADIOLIB_SX126X_PA_RAMP_10U 0\n#define RADIOLIB_SX126X_PA_RAMP_20U 1\n" + "#define RADIOLIB_SX126X_PA_RAMP_40U 2\n#define RADIOLIB_SX126X_PA_RAMP_80U 3\n" + "#define RADIOLIB_SX126X_PA_RAMP_800U 5\n#define RADIOLIB_SX126X_PA_RAMP_1700U 6\n" + "#define RADIOLIB_SX126X_PA_RAMP_200U 4\n" + "#define RADIOLIB_SX127X_REG_PA_RAMP 10\n" + "#define RADIOLIB_LR2021_TX_TEST_MODE_CW 2\n" + "#define RADIOLIB_SX126X_STANDBY_XOSC 1\n") + variants=[(None,False,None,None,7),(6,False,None,None,7),(None,True,None,1,7)] + variants += [(None,False,us,1,index) for index,us in enumerate((10,20,40,80,200,800,1700,3400))] + variants += [(6,False,20,1,6),(None,False,20,0,7),(None,True,20,1,1)] + for ramp,warm,requested,hil,expected in variants: + with self.subTest(board_packet_ramp=ramp, hil_warm_tx=warm,hil_pa_ramp_us=requested,hil=hil): + flags = ["-Wno-unused-parameter", "-DUSE_LR2021"] + flags.append(f"-DEXPECTED_MORSE_RAMP={expected}") + if hil is not None: + flags.append(f"-DMESH_MORSE_HIL={hil}") + if requested is not None: + flags.append(f"-DMESH_MORSE_HIL_PA_RAMP_US={requested}") + if warm: + flags.append("-DMESH_MORSE_HIL_WARM_TX=1") + if ramp is not None: + flags.append(f"-DSX126X_PA_RAMP_TIME={ramp}") + compile_run(self, code, flags=flags, headers={"Dispatcher.h": portable_radio_header(), + "Arduino.h": arduino, "RadioLib.h": radio_lib}) + for requested in (-1,0,30,3401): + with self.subTest(invalid_hil_ramp_us=requested),self.assertRaisesRegex(AssertionError,"HIL SX126x PA ramp must be"): + compile_run(self,code,flags=("-Wno-unused-parameter","-DUSE_LR2021","-DEXPECTED_MORSE_RAMP=7", + "-DMESH_MORSE_HIL=1",f"-DMESH_MORSE_HIL_PA_RAMP_US={requested}"),headers={ + "Dispatcher.h":portable_radio_header(),"Arduino.h":arduino,"RadioLib.h":radio_lib}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_usb_client.py b/test/test_nrf52_usb_client.py index 47e2b30a2..9289a82b2 100644 --- a/test/test_nrf52_usb_client.py +++ b/test/test_nrf52_usb_client.py @@ -294,6 +294,7 @@ struct Utils { class MyMesh { public: bool _terminal_mode = false, _terminal_usb_silent = false; + bool _terminal_morse_atomic = false; Stream* _terminal_output = nullptr; struct { const char* node_name = "USB regression"; } _prefs; struct { uint8_t pub_key[PUB_KEY_SIZE] = {}; } self_id; diff --git a/test/test_ota_channel_persistence.py b/test/test_ota_channel_persistence.py index dabf861b1..fef8a8451 100644 --- a/test/test_ota_channel_persistence.py +++ b/test/test_ota_channel_persistence.py @@ -223,6 +223,7 @@ def persistence_harness(): "static float defaultLoRaBandwidth(", "static void markDirectRetryPrefsValid(", "static void applyFloodRetryPreset(", "static void applyDirectRetryPreset(", "static void setDefaultDirectRetryPrefs(", "static bool directRetryPrefsValid(")) + helpers = '#include \n' + helpers methods = "\n".join(extract_braced(source, signature) for signature in ( "static const char* commonPrefsSaveResultName(", "bool CommonCLI::recoverCommonPrefsFiles(", diff --git a/test/test_radio_interrupt_recovery.py b/test/test_radio_interrupt_recovery.py index 0bd2affd6..b27ebf229 100644 --- a/test/test_radio_interrupt_recovery.py +++ b/test/test_radio_interrupt_recovery.py @@ -35,6 +35,8 @@ PRELUDE = r''' #define STATE_RX 1 #define STATE_TX_WAIT 3 #define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_FREQUENCY -2 +#define RADIOLIB_ERR_UNKNOWN -16 #define RADIOLIB_ERR_UNSUPPORTED -2 #define RADIOLIB_ERR_PACKET_TOO_SHORT -5 #define RADIOLIB_ERR_SPI_CMD_TIMEOUT -77 @@ -178,10 +180,15 @@ class RadioInterruptRecoveryTests(unittest.TestCase): #pragma once #include #define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_FREQUENCY -2 +#define RADIOLIB_ERR_UNKNOWN -16 #define RADIOLIB_NC 0xffffffff class PhysicalLayer { public: int setOutputPower(int8_t){return 0;} + int setFrequency(float){return 0;} + int standby(){return 0;} + int startReceive(){return 0;} int transmitDirect(){return 0;} int setPreambleLength(uint16_t){return 0;} uint8_t randomByte(){return 0;} diff --git a/test/test_radio_reply_mutation.py b/test/test_radio_reply_mutation.py index 277323a05..cc86e0ae5 100644 --- a/test/test_radio_reply_mutation.py +++ b/test/test_radio_reply_mutation.py @@ -44,6 +44,12 @@ struct Profiles { }; struct CLI { Profiles profiles; + struct NoMorseReply { + void complete(const void*) {} + void fail(const void*) {} + void copy(const void*, const void*) {} + } morse; + NoMorseReply& morseReply() { return morse; } Profiles& radioProfiles() { return profiles; } }; struct Manager { diff --git a/test/test_remote_cli_command_identity.py b/test/test_remote_cli_command_identity.py index 1f9c75f4b..cd36dfa5e 100644 --- a/test/test_remote_cli_command_identity.py +++ b/test/test_remote_cli_command_identity.py @@ -8,6 +8,7 @@ import tempfile import unittest from test_replay_reset_integration import extract_braced +from test_morse_tx import portable_radio_header ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / "examples/simple_repeater/MyMesh.cpp" @@ -46,6 +47,12 @@ class RemoteCliCommandIdentityTest(unittest.TestCase): "Packet::Packet()", "bool Packet::isValidPathLen(")) + "\n}\n" with tempfile.TemporaryDirectory(prefix="meshcore-cli-identity-") as directory: work = Path(directory) + (work / "Dispatcher.h").write_text(portable_radio_header(), encoding="utf-8") + identity = work / "helpers/IdentityStore.h" + identity.parent.mkdir() + identity.write_text((ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii"), encoding="ascii") + morse = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + (work / "production_morse.inc").write_text(morse, encoding="ascii") (work / "normalization.inc").write_text(generated, encoding="utf-8") (work / "acl_delete.inc").write_text(acl[delete_start:delete_end], encoding="utf-8") (work / "production.inc").write_text("\n".join( @@ -59,7 +66,7 @@ class RemoteCliCommandIdentityTest(unittest.TestCase): os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *sanitizer, f"-DMESH_ENABLE_HOST_CLI={host_enabled}", - f"-I{work}", f"-I{ROOT / 'src'}", str(FIXTURE), "-o", str(binary), + f"-I{work}", f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}", str(FIXTURE), "-o", str(binary), ], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 6c66ffab8..deae5034d 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -102,6 +102,9 @@ struct CLI { bool finishReplyMutation(bool) { return true; } }; struct Radio { uint32_t last_rx = 0; uint32_t getLastRecvMillis() { return last_rx; } }; +// This fixture exercises packet-radio scheduling, not a Morse session. The +// separate Morse loop-policy suite executes the real ownership predicate. +namespace mesh { bool morseTimingActive(const Radio*) { return false; } } struct Barrier { bool held = false; bool waiting() const { return held; } void clear() { held = false; } }; struct ScheduledRadioSetting { bool active = false, temporary = false, started = false; diff --git a/test/test_room_login_integration.py b/test/test_room_login_integration.py index f4835682d..ca107de08 100644 --- a/test/test_room_login_integration.py +++ b/test/test_room_login_integration.py @@ -149,6 +149,17 @@ struct FakeRng { static uint32_t ticks = 0; static uint32_t millis() { return ticks; } struct MyMesh { + struct NoMorseReply { + uint32_t generation() const { return 0; } + void beginCommand(const uint8_t*) {} + void endCommand() {} + void prepareReply(const void*) {} + void armReply(const void*,bool) {} + } inactive_morse_reply; + struct CLI { + NoMorseReply* reply; + NoMorseReply& morseReply() { return *reply; } + } _cli{&inactive_morse_reply}; FakeACL acl; mesh::Identity self_id{}; PostInfo retained_posts[MAX_UNSYNCED_POSTS]{}; diff --git a/test/test_rs232_driver_lifecycle.py b/test/test_rs232_driver_lifecycle.py index e012483e9..64f6c91ed 100644 --- a/test/test_rs232_driver_lifecycle.py +++ b/test/test_rs232_driver_lifecycle.py @@ -39,19 +39,41 @@ struct RS232Bridge : BridgeBase { int16_t _rx_pin, _tx_pin; uint16_t _rx_buffer_pos = 37; bool _rx_packet_ready = true; + uint16_t _tx_size = 19, _tx_pos = 3; + bool _tx_wire_pending = true, _tx_is_ping = true; + uint32_t _tx_ping_challenge = 4, _outstanding_challenge = 5; + uint64_t _pong_target = 6; + uint32_t _pong_challenge = 7; + bool _ping_due = false, _partial_suspend_grace = true; RS232Bridge(NodePrefs& prefs, HardwareSerial& serial) : _prefs(&prefs), _serial(&serial), _rx_pin(5), _tx_pin(6) {} void begin(); void end(); + void resetTransportState(); }; +@RESET@ @BEGIN@ @END@ int main() { NodePrefs prefs; HardwareSerial serial; RS232Bridge bridge(prefs, serial); - auto dirty_parser = [&]() { bridge._rx_buffer_pos = 37; bridge._rx_packet_ready = true; }; - auto assert_parser_reset = [&]() { assert(bridge._rx_buffer_pos == 0 && !bridge._rx_packet_ready); }; + auto dirty_parser = [&]() { + bridge._rx_buffer_pos = 37; bridge._rx_packet_ready = true; + bridge._tx_size = 19; bridge._tx_pos = 3; + bridge._tx_wire_pending = bridge._tx_is_ping = true; + bridge._tx_ping_challenge = 4; bridge._outstanding_challenge = 5; + bridge._pong_target = 6; bridge._pong_challenge = 7; + bridge._ping_due = false; bridge._partial_suspend_grace = true; + }; + auto assert_parser_reset = [&]() { + assert(bridge._rx_buffer_pos == 0 && !bridge._rx_packet_ready); + assert(bridge._tx_size == 0 && bridge._tx_pos == 0); + assert(!bridge._tx_wire_pending && !bridge._tx_is_ping); + assert(bridge._tx_ping_challenge == 0 && bridge._outstanding_challenge == 0); + assert(bridge._pong_target == 0 && bridge._pong_challenge == 0); + assert(bridge._ping_due && !bridge._partial_suspend_grace); + }; assert(!bridge.isRunning()); serial.pins_ok = false; bridge.begin(); @@ -98,6 +120,7 @@ class RS232DriverLifecycleTests(unittest.TestCase): .replace("BridgeBase::", "").replace(" override", "")) code = code.replace("@BEGIN@", extract_braced(driver, "void RS232Bridge::begin()")) code = code.replace("@END@", extract_braced(driver, "void RS232Bridge::end()")) + code = code.replace("@RESET@", extract_braced(driver, "void RS232Bridge::resetTransportState()")) with tempfile.TemporaryDirectory(prefix="rs232-driver-lifecycle-") as directory: work = Path(directory) source, binary = work / "main.cpp", work / "test" diff --git a/test/test_rs232_heartbeat.py b/test/test_rs232_heartbeat.py new file mode 100644 index 000000000..da42cacc9 --- /dev/null +++ b/test/test_rs232_heartbeat.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Sanitized tests of the actual UART driver, wire codec and standby policy. + +No PlatformIO or connected hardware is used. The two-peer model ends/begins +physical mock UARTs, drops bytes arriving while either UART is stopped, and +never wakes a peer merely because another peer transmitted. The deterministic +phase sweep and long-window inequality exercise rendezvous without RNG luck. +""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "test/fixtures/rs232_heartbeat" + + +class RS232HeartbeatTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise AssertionError("a host C++ compiler is required") + cls.temporary = tempfile.TemporaryDirectory(prefix="rs232-heartbeat-") + cls.binary = Path(cls.temporary.name) / "heartbeat" + built = subprocess.run([ + cls.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-DWITH_RS232_BRIDGE=1", "-DESP32=1", + "-I" + str(FIXTURES), "-I" + str(ROOT / "src"), + str(FIXTURES / "main.cpp"), + str(ROOT / "src/helpers/bridges/RS232Bridge.cpp"), + "-o", str(cls.binary), + ], capture_output=True, text=True, timeout=60) + if built.returncode: + cls.temporary.cleanup() + raise AssertionError(built.stdout + built.stderr) + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def run_case(self, name): + checked = subprocess.run([str(self.binary), name], capture_output=True, + text=True, timeout=60) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(name + " passed", checked.stdout) + + def test_control_codec_rejects_invalid_bodies_and_keeps_old_framing(self): + self.run_case("codec") + + def test_policy_idle_transitions_wake_deadlines_retry_and_clock_wrap(self): + self.run_case("policy") + + def test_default_wire_and_old_new_data_compatibility(self): + self.run_case("compatibility") + + def test_peer_proof_rejects_reflection_noise_replays_and_wrong_session(self): + self.run_case("peer-proof") + + def test_partial_tx_backpressure_rx_pool_starvation_and_timeouts(self): + self.run_case("bounded-io") + + def test_pair_recovers_from_both_sleeping_with_deterministic_phase_sweep(self): + self.run_case("rendezvous") + + def test_pair_loss_boot_stagger_restart_and_failed_resume_recover(self): + self.run_case("recovery") + + def test_expired_presence_does_not_resurrect_after_full_millis_counter_cycle(self): + self.run_case("counter-cycle") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rs232_heartbeat_owner.py b/test/test_rs232_heartbeat_owner.py new file mode 100644 index 000000000..1ce345cd5 --- /dev/null +++ b/test/test_rs232_heartbeat_owner.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Execute RS-232 UART/GPS ownership and whole-second standby sleep gates.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#define WITH_BRIDGE 1 +#define WITH_RS232_BRIDGE Serial2 +#define WITH_RS232_BRIDGE_UART 2 +#define WITH_RS232_BRIDGE_RX 16 +#define WITH_RS232_BRIDGE_TX 17 +#define WITH_RS232_BRIDGE_ALT Serial1 +#define WITH_RS232_BRIDGE_ALT_UART 1 +#define WITH_RS232_BRIDGE_ALT_RX 8 +#define WITH_RS232_BRIDGE_ALT_TX 9 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +static uint32_t fake_millis = 1000; +static uint32_t millis() { return fake_millis; } +struct StrHelper { + static void strncpy(char* out, const char* in, size_t size) { + assert(size); std::strncpy(out, in, size - 1); out[size - 1] = 0; + } +}; +struct NodePrefs { + uint8_t bridge_enabled = 0, rs232_bridge_enabled = 0, bridge_uart = 2; + uint8_t powersaving_enabled = 1, rs232_autosleep = 1, rs232_heartbeat = 1; + uint16_t rs232_idle_seconds = 300; + bool battery_alert_enabled = false; +}; +struct FakeUart { unsigned begins = 0, ends = 0; bool running = false; } Serial1, Serial2; +struct FakeSensors { + unsigned claims = 0, releases = 0; + bool owned = false, fail_claim = false, fail_release = false; + bool gpsUsesSerialUart(uint8_t uart) const { return uart == 1; } + bool gpsSerialTransportMayConflict(uint8_t uart) const { return uart == 1; } + bool gpsSerialTransportCanYield(uint8_t) const { return true; } + bool setGpsSerialTransportBlocked(uint8_t uart, bool blocked) { + assert(uart == 1); + if (blocked) { ++claims; if (fail_claim) return false; } + else { ++releases; if (fail_release) return false; } + owned = blocked; return true; + } +}; +struct FakeRng { + uint64_t next = 1; + void random(uint8_t* out, size_t size) { + assert(size == sizeof(next)); std::memcpy(out, &next, size); ++next; + } +}; +class AbstractBridge { +public: + virtual ~AbstractBridge() = default; + virtual bool isRunning() const = 0; +}; +class RS232Bridge : public AbstractBridge { + FakeUart& uart; +public: + enum class PowerAction { None, Suspend, Resume }; + inline static unsigned live = 0, fail_starts = 0; + uint64_t session = 0; + unsigned loops = 0, completions = 0; + PowerAction requested = PowerAction::None; + bool completion_success = false, suspended = false, last_allow = false; + bool heartbeat_enabled = false, capability_armed = false, last_force = false; + uint32_t wake_delay = UINT32_MAX, last_idle_ms = 0, last_now = 0; + RS232Bridge(NodePrefs*, FakeUart& selected, int, int, void*, void*) : uart(selected) { ++live; } + ~RS232Bridge() override { --live; } + void setHeartbeatSession(uint64_t value) { assert(value); session = value; } + void setHeartbeatEnabled(bool value) { heartbeat_enabled = value; } + void begin() { + assert(session); ++uart.begins; + if (fail_starts) { --fail_starts; return; } + uart.running = true; + } + void end() { assert(uart.running); ++uart.ends; uart.running = false; } + bool isRunning() const override { return uart.running; } + void loop() { assert(isRunning()); ++loops; } + PowerAction servicePowerPolicy(bool allow, uint32_t idle, uint32_t now, bool force = false) { + last_allow = allow && (capability_armed || force); + last_force = force; last_idle_ms = idle; last_now = now; + return requested == PowerAction::Suspend && !last_allow ? PowerAction::None : requested; + } + void completePowerAction(PowerAction action, bool success, uint32_t now) { + assert(action == requested && action != PowerAction::None); + ++completions; completion_success = success; last_now = now; + if (success) { + requested = PowerAction::None; suspended = action == PowerAction::Suspend; + wake_delay = suspended ? 30000 : UINT32_MAX; + } else wake_delay = 5000; + } + uint32_t powerWakeDelayMs(uint32_t) const { return wake_delay; } + bool isPowerSuspended() const { return suspended; } + void formatHeartbeatStatus(char* out, size_t capacity, uint32_t) const { + std::snprintf(out, capacity, "phase=%s peer=unknown rx=never probe=30s", + suspended ? "standby" : "active"); + } +}; +struct MQTTBridge : AbstractBridge { + bool running = false, paused = false; + bool isRunning() const override { return running; } + bool isNetworkSuspended() const { return paused; } + bool isStopping() const { return false; } +}; +struct Callbacks { + virtual ~Callbacks() = default; + virtual bool isRs232BridgeRunning() const = 0; + virtual bool setRs232BridgeState(bool) = 0; + virtual bool restartRs232Bridge() = 0; + virtual void formatRs232Status(char*) = 0; +}; +struct MyMesh : Callbacks { + NodePrefs _prefs; + RS232Bridge* bridge = nullptr; + MQTTBridge* mqtt_bridge = nullptr; + uint8_t active_rs232_bridge_uart = 0; + FakeSensors sensors; + FakeRng rng; + FakeRng* getRNG() { return &rng; } + void* _mgr = nullptr; + void* getRTCClock() { return nullptr; } + void configureBridgeFilter(AbstractBridge*) {} + const AbstractBridge* activeBridge() const { +#ifdef WITH_MQTT_BRIDGE + return mqtt_bridge; +#else + return bridge; +#endif + } + struct { bool pending = false; } deferred_cli_command; + struct { bool hasActiveUserGpioTimer() const { return false; } } _cli; + struct { + bool isWatchdogObserving() const { return false; } + bool isCalibratingNoiseFloor() const { return false; } + } radio_driver; + bool pending_self_advert = false; + unsigned long next_flood_advert = 0, next_local_advert = 0; + unsigned long dirty_contacts_expiry = 0, next_recent_repeater_sweep = 0; + unsigned long next_battery_alert_check = 0; + bool isDualRadioActive() const { return false; } + bool hasPendingOtaApply() const { return false; } + bool hasQueuedWorkDue() const { return false; } + bool hasRetryWorkDue() const { return false; } + bool hasScheduledRadioWorkDue() const { return false; } + bool isMillisTimerDue(unsigned long) const { return false; } + bool getNextQueueWakeDelay(uint32_t&) const { return false; } + bool getNextRetryWakeDelay(uint32_t&) const { return false; } + uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; } + uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; } + bool hasPendingWork() const; + uint32_t getPowerSaveSleepSeconds(uint32_t) const; + @OWNER@ + ~MyMesh() override { assert(setRs232BridgeState(false)); } +}; +@SLEEP@ +static void enable(MyMesh& node) { + node._prefs.bridge_enabled = node._prefs.rs232_bridge_enabled = 1; + assert(node.setRs232BridgeState(true)); +} +static void request(MyMesh& node, RS232Bridge::PowerAction action) { + node.bridge->requested = action; node.bridge->wake_delay = 0; + node.serviceRs232BridgePower(); +} +int main() { + { + MyMesh node; + assert(node.getPowerSaveSleepSeconds(30) == 30); + char status[161]; std::memset(status, '!', sizeof(status)); + node.formatRs232Status(status); assert(status[160] == '!'); + assert(std::strstr(status, "saved=off running=off")); + enable(node); const auto session = node.bridge->session; + assert(node.getPowerSaveSleepSeconds(30) == 0); + node.serviceRs232BridgePower(); + assert(node.bridge->loops == 1 && !node.bridge->last_allow); + assert(node.bridge->heartbeat_enabled); + assert(node.bridge->last_idle_ms == 300000); + // Negotiated default never suspends an unconfirmed stock peer. + request(node, RS232Bridge::PowerAction::Suspend); + assert(node.isRs232BridgeRunning() && node.bridge->completions == 0); + node.bridge->requested = RS232Bridge::PowerAction::None; + node.bridge->wake_delay = UINT32_MAX; + node._prefs.rs232_autosleep = 1; node._prefs.powersaving_enabled = 0; + node.serviceRs232BridgePower(); assert(!node.bridge->last_allow); + node._prefs.powersaving_enabled = 1; + node._prefs.rs232_autosleep = 255; node.serviceRs232BridgePower(); + assert(!node.bridge->last_allow); + node._prefs.rs232_autosleep = 1; + node._prefs.rs232_heartbeat = 255; node.serviceRs232BridgePower(); + assert(!node.bridge->heartbeat_enabled && !node.bridge->last_allow); + node._prefs.rs232_heartbeat = 0; node.serviceRs232BridgePower(); + assert(!node.bridge->heartbeat_enabled && !node.bridge->last_allow); + node._prefs.rs232_heartbeat = 1; + node.bridge->capability_armed = true; + request(node, RS232Bridge::PowerAction::Suspend); + assert(node.bridge->last_allow && node.bridge->isPowerSuspended()); + assert(!node.isRs232BridgeRunning() && node.active_rs232_bridge_uart == 0); + assert(node._prefs.bridge_enabled && node._prefs.rs232_bridge_enabled); + assert(node.bridge->session == session && RS232Bridge::live == 1); + const unsigned loops = node.bridge->loops; + node.serviceRs232BridgePower(); assert(node.bridge->loops == loops); + assert(node.getPowerSaveSleepSeconds(30) == 30); + node.bridge->wake_delay = 29999; assert(node.getPowerSaveSleepSeconds(30) == 29); + node.bridge->wake_delay = 999; assert(node.getPowerSaveSleepSeconds(30) == 0); + node.bridge->wake_delay = 0; assert(node.hasPendingWork()); +#ifdef WITH_MQTT_BRIDGE + MQTTBridge broker; node.mqtt_bridge = &broker; + node.bridge->wake_delay = 30000; broker.running = true; + assert(node.hasPendingWork()); broker.paused = true; + assert(!node.hasPendingWork()); node.mqtt_bridge = nullptr; +#endif + request(node, RS232Bridge::PowerAction::Resume); + assert(node.isRs232BridgeRunning() && node.bridge->session == session); + request(node, RS232Bridge::PowerAction::Suspend); + assert(node.setRs232BridgeState(true)); + assert(node.isRs232BridgeRunning() && node.bridge->session != session); + node.formatRs232Status(status); assert(status[160] == '!'); + assert(std::strstr(status, "saved=on running=on heartbeat=on autosleep=on idle=300s")); + assert(node.setRs232BridgeState(false) && !node.bridge); + } + assert(RS232Bridge::live == 0); + { + MyMesh node; enable(node); node._prefs.rs232_autosleep = 2; + assert(!node.bridge->capability_armed); + request(node, RS232Bridge::PowerAction::Suspend); + assert(node.bridge->last_force && !node.isRs232BridgeRunning()); + char status[160]; node.formatRs232Status(status); + assert(std::strstr(status, "autosleep=force")); + request(node, RS232Bridge::PowerAction::Resume); + assert(node.isRs232BridgeRunning()); + } + assert(RS232Bridge::live == 0); +#if ENV_INCLUDE_GPS == 1 + { + MyMesh node; node._prefs.bridge_uart = 1; enable(node); + const auto session = node.bridge->session; + assert(node.sensors.owned && node.active_rs232_bridge_uart == 1); + node._prefs.rs232_autosleep = 2; + node.sensors.fail_release = true; + const unsigned ends = Serial1.ends; + request(node, RS232Bridge::PowerAction::Suspend); + assert(!node.bridge->completion_success && !node.isRs232BridgeRunning()); + assert(node.active_rs232_bridge_uart == 1 && node.sensors.owned); + assert(node.hasPendingWork() && node.getPowerSaveSleepSeconds(30) == 0); + assert(Serial1.ends == ends + 1); + node.sensors.fail_release = false; node.serviceRs232BridgePower(); + assert(node.bridge->completion_success && node.active_rs232_bridge_uart == 0); + assert(!node.sensors.owned && Serial1.ends == ends + 1); + // No end() on the already stopped/unstarted UART during resume cleanup. + RS232Bridge::fail_starts = 1; node.sensors.fail_release = true; + request(node, RS232Bridge::PowerAction::Resume); + assert(!node.bridge->completion_success && node.active_rs232_bridge_uart == 1); + const unsigned begins = Serial1.begins; + node.serviceRs232BridgePower(); assert(Serial1.begins == begins); + node.sensors.fail_release = false; node.serviceRs232BridgePower(); + assert(node.isRs232BridgeRunning() && node.bridge->session == session); + assert(Serial1.ends == ends + 1); + request(node, RS232Bridge::PowerAction::Suspend); + node.sensors.fail_claim = true; + request(node, RS232Bridge::PowerAction::Resume); + assert(!node.bridge->completion_success && node.active_rs232_bridge_uart == 0); + assert(!node.hasPendingWork() && node.getPowerSaveSleepSeconds(30) == 5); + node.sensors.fail_claim = false; node.serviceRs232BridgePower(); + assert(node.isRs232BridgeRunning()); + } +#endif + assert(RS232Bridge::live == 0); +} +''' + + +class RS232HeartbeatOwnerTests(unittest.TestCase): + def test_owned_uart_standby_retry_and_sleep_deadlines(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii") + implementation = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii") + owner = "\n".join(extract_braced(header, signature) for signature in ( + "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", + "bool endRS232Bridge()", "bool rs232BridgeEnabled() const", + "bool isRs232BridgeRunning() const override", "void serviceRs232BridgePower()", + "void formatRs232Status(char* reply) override", "bool setRs232BridgeState(bool enable)", + "bool restartRs232Bridge()", + )) + sleep = "\n".join(extract_braced(implementation, signature) for signature in ( + "bool MyMesh::hasPendingWork() const", + "uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const", + )) + source = HARNESS.replace("@OWNER@", owner).replace("@SLEEP@", sleep) + for gps in (0, 1): + for mqtt in (False, True): + with self.subTest(gps=gps, mqtt=mqtt), tempfile.TemporaryDirectory(prefix="rs232-owner-") as directory: + work = Path(directory) + cpp, binary = work / "test.cpp", work / "test" + cpp.write_text(source, encoding="ascii") + flags = [f"-DENV_INCLUDE_GPS={gps}"] + if mqtt: + flags.append("-DWITH_MQTT_BRIDGE=1") + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + *flags, str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + def test_room_role_does_not_claim_rs232_support(self): + for filename in ("MyMesh.h", "MyMesh.cpp"): + source = (ROOT / "examples/simple_room_server" / filename).read_text(encoding="utf-8") + self.assertNotIn("RS232Bridge", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rs232_mqtt_runtime.py b/test/test_rs232_mqtt_runtime.py index c56f49425..b0247b8fe 100644 --- a/test/test_rs232_mqtt_runtime.py +++ b/test/test_rs232_mqtt_runtime.py @@ -37,6 +37,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase): lifecycle = "\n".join(extract_braced(processed_header, signature) for signature in ( "AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {", "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()", + "void serviceRs232BridgePower()", "void formatRs232Status(char* reply)", "bool isBridgeRunning() const override", "bool rs232BridgeEnabled() const", "bool isRs232BridgeRunning()", "bool setRs232BridgeState(bool enable)", "bool restartRs232Bridge()", "bool startSharedEspNowBridgeIfReady()", "bool isEspNowBridgeRunning()", @@ -135,6 +136,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase): lifecycle = "\n".join(extract_braced(processed_header, signature) for signature in ( "AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {", "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()", + "void serviceRs232BridgePower()", "void formatRs232Status(char* reply)", "bool rs232BridgeEnabled() const", "bool isBridgeRunning() const override", "bool isRs232BridgeRunning()", "bool setRs232BridgeState(bool enable)", "bool restartRs232Bridge()", "bool isMqttBridgeRunning()", "bool requestMqttBridgeStop()", diff --git a/test/test_rs232_power_settings.py b/test/test_rs232_power_settings.py new file mode 100644 index 000000000..47e893708 --- /dev/null +++ b/test/test_rs232_power_settings.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Execute checked RS-232 power preferences and exact production CLI branches.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_ota_channel_persistence import persistence_harness +from test_replay_reset_integration import extract_braced +from test_rs232_heartbeat_owner import HARNESS as OWNER_HARNESS + +ROOT = Path(__file__).resolve().parents[1] +SANITIZERS = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + + +class RS232PowerSettingsTests(unittest.TestCase): + def compile_run(self, code, flags, persistence=False): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="rs232-power-settings-") as directory: + work = Path(directory) + if persistence: + transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text(encoding="ascii") + (work / "ContactFileTransaction.h").write_text(transaction.replace( + '#include "IdentityStore.h"', '#include '), encoding="ascii") + cpp, binary = work / "test.cpp", work / "test" + cpp.write_text(code, encoding="ascii") + built = subprocess.run([compiler, "-std=c++17", *SANITIZERS, *flags, + "-I" + str(work), "-I" + str(ROOT / "test/fixtures/radio_profiles/mocks"), + "-I" + str(ROOT / "src"), "-I" + str(ROOT / "src/helpers"), str(cpp), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + def test_actual_writers_loaders_checked_tail_and_failed_transactions(self): + base = persistence_harness().replace("#define WITH_RS232_BRIDGE_UART 1", "") + code = base[:base.index("struct Capture {")] + code += (ROOT / "test/fixtures/rs232_power_settings_prefs.cpp").read_text(encoding="ascii") + for platform in ("NRF52_PLATFORM", "ESP32_PLATFORM"): + for mqtt in (False, True): + with self.subTest(platform=platform, mqtt=mqtt): + flags = ["-D" + platform, "-DENABLE_OTA=1", "-DWITH_RS232_BRIDGE=Serial2", + "-DWITH_RS232_BRIDGE_UART=2", "-DRS232_BRIDGE_MERGED=1"] + if mqtt: + flags.append("-DWITH_MQTT_BRIDGE=1") + self.compile_run(code, flags, persistence=True) + + def test_exact_cli_settings_and_standby_save_failure_recovery(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii") + owner = "\n".join(extract_braced(header, signature) for signature in ( + "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()", + "bool rs232BridgeEnabled() const", "bool isRs232BridgeRunning() const override", + "void serviceRs232BridgePower()", "void formatRs232Status(char* reply) override", + "bool setRs232BridgeState(bool enable)", "bool restartRs232Bridge()")) + code = OWNER_HARNESS[:OWNER_HARNESS.index("static void enable(")] + code = code.replace("@OWNER@", owner).replace("@SLEEP@", "") + code = '#include \n#include \n#include \n#define BRIDGE_MAX_BAUD 115200\n' + code + code = code.replace("bool battery_alert_enabled = false;", "bool battery_alert_enabled = false; uint32_t bridge_baud = 115200;") + code = code.replace("NodePrefs _prefs;", "NodePrefs _prefs;\n bool setBridgeState(bool) { return true; }") + code += (ROOT / "test/fixtures/rs232_power_settings_cli.cpp").read_text(encoding="ascii") + parsers = "\n".join(extract_braced(source, signature) for signature in ( + "static bool parseOnOffStrict(", "static bool configKeyEquals(")) + code = code.replace("@PARSERS@", parsers) + commands = "\n".join(extract_braced(source, signature) for signature in ( + "void CommonCLI::handleSetCmd(", "void CommonCLI::handleGetCmd(")) + for platform in ("NRF52_PLATFORM", "ESP32_PLATFORM"): + for mqtt in (False, True): + with self.subTest(platform=platform, mqtt=mqtt): + flags = ["-D" + platform, "-DWITH_BRIDGE=1", "-DWITH_RS232_BRIDGE=Serial2", + "-DWITH_RS232_BRIDGE_UART=2", "-DWITH_RS232_BRIDGE_ALT=Serial1", + "-DWITH_RS232_BRIDGE_ALT_UART=1", + "-DENV_INCLUDE_GPS=1"] + if mqtt: + flags.append("-DWITH_MQTT_BRIDGE=1") + processed = subprocess.run([compiler, "-E", "-P", "-x", "c++", *flags, "-"], + input=commands, capture_output=True, text=True, check=True).stdout + sets = "\n".join(extract_braced(processed, signature) for signature in ( + 'if (strncmp(config, "rs232.heartbeat ", 16) == 0)', + 'if (strncmp(config, "rs232.autosleep ", 16) == 0)', + 'if (strncmp(config, "rs232.idle ", 11) == 0)', + 'if (strncmp(config, "rs232.enabled ", 14) == 0)')) + sets += "\n" + "\nelse ".join(extract_braced(processed, signature) for signature in ( + 'if (memcmp(config, "bridge.enabled ", 15) == 0)', + 'if (memcmp(config, "bridge.baud ", 12) == 0)', + 'if (memcmp(config, "bridge.uart ", 12) == 0)')) + "\nreturn;" + gets = "\nelse ".join(extract_braced(processed, signature) for signature in ( + 'if (configKeyEquals(config, "rs232.heartbeat"))', + 'if (configKeyEquals(config, "rs232.autosleep"))', + 'if (configKeyEquals(config, "rs232.idle"))', + 'if (configKeyEquals(config, "rs232.status"))')) + self.compile_run(code.replace("@SETS@", sets).replace("@GETS@", gets), flags) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rs232_runtime.py b/test/test_rs232_runtime.py index cd10e51a2..f53942acb 100644 --- a/test/test_rs232_runtime.py +++ b/test/test_rs232_runtime.py @@ -149,7 +149,7 @@ class RS232RuntimeTests(unittest.TestCase): for uart in (0, 1): image = work / f"{origin}-{uart}.bin" run(origin, "write", image, 1, 0, uart) - self.assertEqual(len(image.read_bytes()), 877) + self.assertEqual(len(image.read_bytes()), 887) for target in ("normal", "dedicated"): with self.subTest(origin=origin, target=target, uart=uart): run(target, "read", image, uart, 0, uart) @@ -208,6 +208,7 @@ class RS232RuntimeTests(unittest.TestCase): "AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {", "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()", "bool rs232BridgeEnabled() const", + "void serviceRs232BridgePower()", "void formatRs232Status(char* reply)", "bool isRs232BridgeRunning() const override", "bool setRs232BridgeState(bool enable)", "bool restartRs232Bridge()", "bool isBridgeRunning() const override", "bool setBridgeState(bool enable) override", "bool restartBridge() override", diff --git a/test/test_temp_radio_reply_delivery_contract.py b/test/test_temp_radio_reply_delivery_contract.py index 41dc03905..f2f9214ee 100644 --- a/test/test_temp_radio_reply_delivery_contract.py +++ b/test/test_temp_radio_reply_delivery_contract.py @@ -68,7 +68,8 @@ class TempRadioReplyDeliveryContractTest(unittest.TestCase): self.assertIn('primary_radio_mutation_generation != primary_mutation_before', command) self.assertIn('replyMutationGeneration() != secondary_mutation_before', command) self.assertNotIn('strncmp(deferred_cli_command.command, "tempradio ', command) - self.assertIn("arms_temp_radio ? &queued_reply : NULL", command) + self.assertIn("generation() != morse_mutation_before", command) + self.assertIn("(arms_temp_radio || arms_morse) ? &queued_reply : NULL", command) # A packet-level retry can arrive after the success text was cached but # before the authoritative reply drains. It must not create a second, diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index 99d2c153e..674ccfcc5 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -33,6 +33,8 @@ HARNESS = r''' #include #include #include +#include +#include #define BRIDGE_DEBUG_PRINTLN(...) ((void)0) #define PUB_KEY_SIZE 32 #define PAYLOAD_TYPE_ADVERT 4 @@ -65,7 +67,7 @@ struct Manager { void free(mesh::Packet*) {++freed;} void queueInbound(mesh::Packet*,unsigned) {++queued;} }; -struct Prefs {unsigned bridge_delay=10;}; +struct Prefs {unsigned bridge_delay=10;unsigned bridge_baud=115200;}; struct BridgeBase : AbstractBridge { bool _initialized=true; Seen _seen_packets; @@ -79,9 +81,27 @@ struct BridgeBase : AbstractBridge { void handleReceivedPacket(mesh::Packet*); uint16_t fletcher16(const uint8_t*,size_t) {return 0;} }; -struct Serial {unsigned writes=0;size_t write(const uint8_t*,size_t length) {++writes;return length;}}; +struct Serial { + unsigned writes=0; + size_t limit=300; + int capacity=300; + std::vector bytes; + int availableForWrite() {return capacity;} + size_t write(const uint8_t* source,size_t length) { + ++writes; + if(length>limit)length=limit; + bytes.insert(bytes.end(),source,source+length); + return length; + } +}; struct RS232Bridge : BridgeBase { Serial serial; Serial* _serial=&serial; + uint8_t _tx_buffer[MAX_SERIAL_PACKET_SIZE] = {}; + uint16_t _tx_size=0,_tx_pos=0; + uint32_t _tx_wire_until=0,_tx_ping_challenge=0; + uint32_t _outstanding_challenge=0,_outstanding_since=0; + bool _tx_wire_pending=false,_tx_is_ping=false; + void drainTransmit(uint32_t,bool blocking=false); void sendPacket(mesh::Packet*) override; }; struct ESPNowBridge : BridgeBase { @@ -128,6 +148,37 @@ int main() { assert(inbound.manager.queued==1 && inbound._seen_packets.marks==1); assert(serial.serial.writes==1 && espnow.queued==2 && mqtt.queued==2); assert(serial._seen_packets.marks==1 && espnow._seen_packets.marks==2); + // Exercise the actual production pending-write path, not a fake drain helper: + // a denied frame never enters the owned queue; an accepted partial frame is + // retained byte-for-byte and later data cannot interleave into its wire bytes. + RS232Bridge backed_up; + backed_up.setPacketFilter(filter,&allowed); + backed_up.serial.limit=3; + backed_up.sendPacket(&p); + assert(backed_up._tx_size==38 && backed_up._tx_pos==3); + assert(backed_up._seen_packets.marks==1); + backed_up.sendPacket(&p); + assert(backed_up._tx_size==38 && backed_up._tx_pos==6); + assert(backed_up._seen_packets.marks==1); + allowed=false; + backed_up.sendPacket(&p); + assert(backed_up._tx_pos==6 && backed_up.serial.bytes.size()==6); + allowed=true; + backed_up.serial.limit=300; + backed_up.drainTransmit(millis()); + assert(backed_up._tx_size==0 && backed_up.serial.bytes.size()==38); + assert(backed_up.serial.bytes[0]==0xab && backed_up.serial.bytes[1]==0xcd); + assert(backed_up.serial.bytes[2]==0 && backed_up.serial.bytes[3]==32); + assert(memcmp(backed_up.serial.bytes.data()+4,p.payload,32)==0); + // A small hardware FIFO is ordinary backpressure, not a failed data write. + // Data keeps the legacy blocking acceptance behavior, even though heartbeat + // loop service uses availableForWrite() and never blocks. + RS232Bridge small_fifo; + small_fifo.serial.capacity=3; + small_fifo.sendPacket(&p); + small_fifo.sendPacket(&p); + assert(small_fifo._tx_size==0 && small_fifo.serial.bytes.size()==76); + assert(small_fifo._seen_packets.marks==2 && small_fifo.serial.writes==2); // Once teardown is requested, neither RX nor TX admits more queue work, // even while the bridge remains initialized until the worker acknowledges. mqtt._stop_requested=true; @@ -181,7 +232,7 @@ int main() { methods = [] for name, signatures in { 'BridgeBase': ['void BridgeBase::handleReceivedPacket('], - 'RS232Bridge': ['void RS232Bridge::sendPacket('], + 'RS232Bridge': ['void RS232Bridge::drainTransmit(', 'void RS232Bridge::sendPacket('], 'ESPNowBridge': ['void ESPNowBridge::sendPacket('], 'MQTTBridge': ['void MQTTBridge::sendPacket(', 'void MQTTBridge::onPacketReceived('], }.items(): diff --git a/tools/hil/morse_bench.ini b/tools/hil/morse_bench.ini new file mode 100644 index 000000000..4bc7d4ea4 --- /dev/null +++ b/tools/hil/morse_bench.ini @@ -0,0 +1,79 @@ +; Companion applications, with no automatic RF at boot. LoRa control uses the +; lab tuple 909.5/500/SF5/CR5; Morse uses its separate 902.100 MHz default. +; The 255 WPM override is EXPERIMENTAL, never a release or measured guarantee. +[env:morse_bench_rak4631_companion] +extends = env:RAK_4631_companion_radio_usb +build_unflags = + ${nrf52_base.build_unflags} + -D LORA_FREQ=869.618 + -D ENABLE_ADVERT_ON_BOOT=1 +build_flags = ${env:RAK_4631_companion_radio_usb.build_flags} + -DLORA_FREQ=909.5 + -DENABLE_ADVERT_ON_BOOT=0 + -DMESH_MORSE_MAX_WPM=255 + -DMESH_MORSE_HIL=1 + -DMESH_MORSE_HIL_WARM_TX=1 + -DMESH_MORSE_HIL_RSSI_WINDOW=2 + -DMESH_MORSE_HIL_PA_RAMP_US=800 + -DMESH_MORSE_HIL_SAMPLE_INTERVAL_MS=1 + -DMESH_MORSE_HIL_PACKET_GATING=0 + -DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=1 + -DCOMPANION_FEATURE_TEMP_RADIO=1 +build_src_filter = ${env:RAK_4631_companion_radio_usb.build_src_filter} + + + +[env:morse_bench_t096_companion] +extends = env:Heltec_t096_companion_radio_usb_femon +build_unflags = + ${nrf52_base.build_unflags} + -D LORA_FREQ=869.618 + -D ENABLE_ADVERT_ON_BOOT=1 +build_flags = ${env:Heltec_t096_companion_radio_usb_femon.build_flags} + -DMESH_CONTACT_CACHE=1 + -DLORA_FREQ=909.5 + -DENABLE_ADVERT_ON_BOOT=0 + -DMESH_MORSE_MAX_WPM=255 + -DMESH_MORSE_HIL=1 + -DMESH_MORSE_HIL_WARM_TX=1 + -DMESH_MORSE_HIL_RSSI_WINDOW=2 + -DMESH_MORSE_HIL_PA_RAMP_US=800 + -DMESH_MORSE_HIL_PACKET_GATING=0 + -DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=1 + -DCOMPANION_FEATURE_TEMP_RADIO=1 + +; Additional identity-bound SX1262 pair with its normal display retained. +[env:morse_bench_pocket_companion] +extends = env:Mesh_pocket_companion_radio_usb +build_unflags = + ${nrf52_base.build_unflags} + -D LORA_FREQ=869.618 + -D ENABLE_ADVERT_ON_BOOT=1 +build_flags = ${env:Mesh_pocket_companion_radio_usb.build_flags} + -DMESH_CONTACT_CACHE=1 + -DLORA_FREQ=909.5 + -DENABLE_ADVERT_ON_BOOT=0 + -DMESH_MORSE_MAX_WPM=255 + -DMESH_MORSE_HIL=1 + -DMESH_MORSE_HIL_WARM_TX=1 + -DMESH_MORSE_HIL_RSSI_WINDOW=2 + -DMESH_MORSE_HIL_PA_RAMP_US=800 + -DMESH_MORSE_HIL_SAMPLE_INTERVAL_MS=1 + -DMESH_MORSE_HIL_PACKET_GATING=0 + -DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=1 + -DCOMPANION_FEATURE_TEMP_RADIO=1 + +; Remote ACK-first capture/retrieval test; every packet uses the lab tuple. +[env:morse_bench_rak4631_repeater] +extends = env:RAK_4631_repeater +build_unflags = + ${nrf52_base.build_unflags} + -D LORA_FREQ=869.618 + -D LORA_BW=62.5 + -D LORA_SF=8 + -D ENABLE_ADVERT_ON_BOOT=1 +build_flags = ${env:RAK_4631_repeater.build_flags} + -DLORA_FREQ=909.5 + -DLORA_BW=500 + -DLORA_SF=5 + -DENABLE_ADVERT_ON_BOOT=0 + -DMESH_MORSE_MAX_WPM=255 diff --git a/tools/hil/morse_bench.py b/tools/hil/morse_bench.py new file mode 100644 index 000000000..441866d86 --- /dev/null +++ b/tools/hil/morse_bench.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Identity-bound Morse TX/RX trial at 902.1 MHz, low TX power. + +No flashing, erasure, service stopping or power cycling. Received text comes +from the actual firmware's CLI capture pages, never a host-side decoder. +The active LoRa packet profile stays at 909.5 MHz, 500 kHz, SF5, CR5. +""" +import argparse +import json +import math +from pathlib import Path +import re +import time +import serial +from serial.tools import list_ports + +SERIALS = {"rak": "9AB3B64C641BA927", "t096": "651F8E496197F882", + "pocket": "93DA83ADF7B2092B"} +DEFAULT_CW_FREQUENCY = 902.1 +PACKET_RADIO = "909.5,500,5,5" +MESSAGES = ( + "SOS TEST DE N0CALL 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "E T I M S O H Z 5 0 PARIS QUICK BROWN FOX JUMPS OVER LAZY DOG", + ". , ? ' ! / ( ) & : ; = + - _ \" $ @", +) + + +def parse_cw_frequency(text): + """Keep CW in the lab band, clear of the 500 kHz packet RX bandwidth.""" + try: + frequency = round(float(text), 6) + except (ValueError, TypeError, OverflowError): + raise argparse.ArgumentTypeError("CW frequency must be a finite MHz value") from None + if not math.isfinite(frequency) or not 902 <= frequency <= 928: + raise argparse.ArgumentTypeError("CW frequency must be between 902 and 928 MHz") + if abs(frequency - 909.5) <= .260 + 1e-9: + raise argparse.ArgumentTypeError("CW frequency overlaps the 909.5 MHz packet radio and guard") + return frequency + + +def live_temporary_profile(reply): + """Parse only confirmed live profiles, never saved/pending radio settings.""" + fields = r"(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)" + companion = re.search(r"TempRadio active:\s*" + fields + r"(?=\s|$)", reply) + infrastructure = re.search( + r"(?m)^\s*(?:->\s*)?>\s*" + fields + r"(?:,\d+)?\s*$", reply) + match = companion or infrastructure + if match is None: + return None + return float(match[1]), float(match[2]), int(match[3]), int(match[4]) + + +class Console: + def __init__(self, serial_number, companion=True): + matches = [p for p in list_ports.comports() + if (p.serial_number or "").upper() == serial_number + and p.interface != "MeshCore Logging"] + if len(matches) != 1: + raise RuntimeError(f"Missing/ambiguous application USB identity {serial_number}") + self.serial_number = serial_number + self.companion = companion + self.port = serial.Serial(port=None, baudrate=115200, timeout=.015) + self.port.dtr, self.port.rts = True, False + self.port.port = matches[0].device + self.port.open() + self.commands = [] + try: + hello = self.read_quiet(1) + if companion and "WELCOME" not in hello: + self.port.write(b"+++MESHCORE-TERM-START\r\n") + self.read_quiet(1) + self.command("ver") + self.command("board") + except BaseException: + self.port.close() + raise + + def read_quiet(self, timeout=2): + data = bytearray() + end = quiet = time.monotonic() + timeout + while time.monotonic() < end and time.monotonic() < quiet: + chunk = self.port.read(self.port.in_waiting or 1) + if chunk: + data.extend(chunk) + quiet = time.monotonic() + .12 + return data.decode("utf-8", errors="replace") + + def command(self, text, timeout=3, allow_error=False): + if self.port.in_waiting: + self.read_quiet(.15) + self.port.write((text + "\r\n").encode()) + data = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + data.extend(self.port.read(self.port.in_waiting or 1)) + reply = data.decode("utf-8", errors="replace") + # Flash writes/display callbacks can outlast a quiet interval. + # Await the command response AND its closing owned-terminal prompt, + # not merely an echoed command or asynchronous RX prompt redraw. + complete = reply.endswith("\r\n> ") if self.companion else reply.endswith("\r\n") + if (" -> " in reply or " ERROR:" in reply + or re.search(r"(?:^|\r\n) OK(?:\r\n| -)", reply)) and complete: + break + else: + raise RuntimeError(("Incomplete CLI response", self.serial_number, text, + data.decode("utf-8", errors="replace"))) + self.commands.append({"command": text, "reply": reply}) + if not reply or (not allow_error and re.search(r"\b(?:Error|ERROR|ERR)\b", reply)): + raise RuntimeError((self.serial_number, text, reply)) + return reply + + def setup(self, cw_frequency=DEFAULT_CW_FREQUENCY): + self.command("morse stop") + self.start_when_idle("set radio.rxps off") + self.start_when_idle("set tx -9") + self.start_when_idle("set radio2 off") + self.command(f"set morse.freq {cw_frequency:.6f}") + self.command("set morse.wpm 25") + self.apply_live_temporary(PACKET_RADIO) + + def apply_live_temporary(self, profile, timeout=12): + expected = tuple(float(value) for value in profile.split(",")) + if len(expected) != 4: + raise ValueError("Expected frequency, bandwidth, SF and CR") + self.start_when_idle("set tempradio " + profile + ",30") + deadline = time.monotonic() + timeout + while True: + reply = self.command("get tempradio") + if live_temporary_profile(reply) == expected: + return reply + if time.monotonic() >= deadline: + raise RuntimeError(("Live temporary radio did not apply", self.serial_number, + profile, reply)) + time.sleep(.1) + + def start_when_idle(self, text): + deadline = time.monotonic() + 10 + while True: + reply = self.command(text, allow_error=True) + if "Error: radio busy" not in reply: + if re.search(r"\b(?:Error|ERROR|ERR)\b", reply): + raise RuntimeError((self.serial_number, text, reply)) + return reply + if time.monotonic() >= deadline: + raise RuntimeError(("Radio remained busy", self.serial_number, text, reply)) + time.sleep(.1) + + def capture(self): + text, offset = "", 0 + for _ in range(100): + # A valid text capture can itself contain ERROR; only the framed + # page parser can distinguish that payload from a CLI failure. + reply = self.command(f"morse read {offset}", allow_error=True) + page = re.search( + r"> (\d{4})/(\d{4}) next=(\d{4}|END ) truncated=(\d) ([^\r\n]*)", + reply) + if not page or int(page[1]) != offset or int(page[4]): + raise RuntimeError(("Malformed/truncated capture page", reply)) + text += page[5] + if page[3].strip() == "END": + return text + following = int(page[3]) + if following != len(text) or following <= offset: + raise RuntimeError(("Non-progressing capture", reply)) + offset = following + raise RuntimeError("Capture paging did not finish") + + def trace(self): + rows, offset, total = [], 0, None + for _ in range(100): + reply = self.command(f"morse trace {offset}") + page = re.search(r"> (\d{3})/(\d{3}) t=(\d+) n=(\d{3}|END) x=([01]) ([^\r\n]*)", reply) + if not page or int(page[1]) != offset: + raise RuntimeError(("Malformed trace page", reply)) + if total is not None and total != int(page[3]): + raise RuntimeError("Trace changed during retrieval") + total = int(page[3]) + for item in page[6].split(";"): + if item: + at, rssi, flags = map(int, item.split(",")) + rows.append({"ms": at, "dbm": rssi, "flags": flags}) + if page[4] == "END": + if len(rows) != int(page[2]): + raise RuntimeError("Incomplete retained trace") + return {"total": total, "overwritten": bool(int(page[5])), "rows": rows} + following = int(page[4]) + if following != len(rows) or following <= offset: + raise RuntimeError("Non-progressing trace") + offset = following + raise RuntimeError("Trace paging did not finish") + + def close(self): + try: + self.command("morse stop", allow_error=True) + self.command("set morse.wpm 25", allow_error=True) + finally: + self.port.close() + + +def trial(tx, rx, wpm, message, seconds=60, trace=False): + tx.command("morse stop") + rx.command("morse stop") + tx.command(f"set morse.wpm {wpm}") + rx.command(f"set morse.wpm {wpm}") + rx.start_when_idle(f"morse listen {seconds / 60:.4f}") + # Establish a real quiet RSSI floor before any carrier transmission. + time.sleep(.8) + started = time.monotonic() + tx.start_when_idle("morse send " + message) + while time.monotonic() - started < 180: + if "> off;" in tx.command("get morse"): + break + time.sleep(.15) + else: + raise RuntimeError("Transmitter did not stop") + tx_finished = time.monotonic() + time.sleep(.6) + rx_status = rx.command("get morse") + stats = rx.command("morse stats") + rx.command("morse stop") + decoded = rx.capture() + result = {"wpm": wpm, "nominal_dot_ms": (1200 + wpm // 2) // wpm, + "tx_serial": tx.serial_number, "rx_serial": rx.serial_number, + "sent": message, "decoded": decoded, "passed": decoded.strip() == message, + "tx_elapsed_s": round(tx_finished - started, 3), + "rx_status": rx_status, "rx_stats": stats} + if trace: + result["trace"] = rx.trace() + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--cw-frequency", type=parse_cw_frequency, default=DEFAULT_CW_FREQUENCY, + help="CW MHz (default 902.1); must not overlap the packet radio") + parser.add_argument("--rates", default="25,40,60,75,80,100,120,150,200,240") + parser.add_argument("--repeats", type=int, default=1) + parser.add_argument("--messages", type=int, default=3, choices=(1, 2, 3)) + parser.add_argument("--partner", choices=("t096", "pocket"), default="t096", + help="Exact USB partner to pair with the RAK; no other radio is opened") + parser.add_argument("--direction", choices=("both", "to-rak", "from-rak"), default="both", + help="Test both directions, partner TX to RAK, or RAK TX to partner") + parser.add_argument("--trace", action="store_true", help="Requires a MESH_MORSE_HIL trace build") + parser.add_argument("--quiet-seconds", type=int, default=0, + help="No-transmitter receive control, 1-30 seconds") + parser.add_argument("--t096-rxgain", choices=("on", "off"), + help="Temporary diagnostic gain setting; restored afterward") + args = parser.parse_args() + if args.output.exists(): + parser.error("Refusing to overwrite an existing result") + rates = [int(value) for value in args.rates.split(",")] + if not rates or any(rate < 1 or rate > 255 for rate in rates) or not 1 <= args.repeats <= 10: + parser.error("Rates 1-255 and repeats 1-10 only") + if args.quiet_seconds and not 1 <= args.quiet_seconds <= 30: + parser.error("Quiet control duration must be 1-30 seconds") + if args.t096_rxgain and args.partner != "t096": + parser.error("--t096-rxgain requires --partner t096") + results = {"frequency_mhz": args.cw_frequency, "packet_radio": PACKET_RADIO, + "requested_chip_power_dbm": -9, + "partner": args.partner, "direction": args.direction, + "scope": "Actual production applications on this pair, not all radio families.", + "trials": [], "finished": False} + devices = {} + original_gain = None + try: + for name in ("rak", args.partner): + devices[name] = Console(SERIALS[name]) + for device in devices.values(): + device.setup(args.cw_frequency) + if args.t096_rxgain: + gain_reply = devices["t096"].command("get radio.fem.rxgain") + match = re.search(r"> (on|off)\b", gain_reply) + if not match: + raise RuntimeError(("Unrecognized gain status", gain_reply)) + original_gain = match[1] + devices["t096"].command("set radio.fem.rxgain " + args.t096_rxgain) + if args.quiet_seconds: + for device in devices.values(): + device.start_when_idle(f"morse listen {args.quiet_seconds / 60:.4f}") + time.sleep(args.quiet_seconds + .2) + for name, device in devices.items(): + result = {"kind": "quiet_control", "receiver": name, + "rx_stats": device.command("morse stats")} + device.command("morse stop") + result["decoded"] = device.capture() + result["passed"] = not result["decoded"] + if args.trace: + result["trace"] = device.trace() + results["trials"].append(result) + print(json.dumps({key: value for key, value in result.items() + if key != "trace"}), flush=True) + results["finished"] = True + return + directions = [] + if args.direction in ("both", "to-rak"): + directions.append((args.partner, "rak")) + if args.direction in ("both", "from-rak"): + directions.append(("rak", args.partner)) + for rate in rates: + for tx_name, rx_name in directions: + for repeat in range(args.repeats): + for message in MESSAGES[:args.messages]: + result = trial(devices[tx_name], devices[rx_name], rate, message, trace=args.trace) + result["repeat"] = repeat + results["trials"].append(result) + printable = {key: value for key, value in result.items() if key != "trace"} + if "trace" in result: + printable["trace_rows"] = len(result["trace"]["rows"]) + print(json.dumps(printable), flush=True) + args.output.write_text(json.dumps(results, indent=2) + "\n") + results["finished"] = True + except BaseException as error: + results["error"] = str(error) + raise + finally: + if original_gain is not None: + try: + devices["t096"].command("morse stop", allow_error=True) + devices["t096"].command("set radio.fem.rxgain " + original_gain) + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + for device in devices.values(): + try: + device.close() + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + results["commands"] = {name: device.commands for name, device in devices.items()} + args.output.write_text(json.dumps(results, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/hil/morse_nrf_flash.sh b/tools/hil/morse_nrf_flash.sh new file mode 100644 index 000000000..2f2f008cb --- /dev/null +++ b/tools/hil/morse_nrf_flash.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Identity-bound nRF52 bench adapter for the unchanged mcfirmware.sh DFU path. +# No startup, dependency installation, service stopping, erase, or port recovery. +set -euo pipefail +umask 077 +[[ $# == 7 ]] || { echo 'Usage: morse_nrf_flash.sh FLASHER BY_ID SERIAL BOARD ZIP INVENTORY WORKDIR' >&2; exit 2; } +bench_flasher=$(realpath -e -- "$1") +bench_link=$2 bench_serial=$3 bench_board=$4 +bench_image=$(realpath -e -- "$5") +bench_inventory=$(realpath -e -- "$6") +bench_workdir=$(realpath -e -- "$7") +[[ "$bench_link" == /dev/serial/by-id/* && -L "$bench_link" ]] +[[ "$bench_workdir" == /home/mikec/hwtest/runs/morse-* ]] +bench_port=$(readlink -f -- "$bench_link") +morse_assert_unowned() { + local output status + if output=$(sudo -n fuser "$1" 2>&1); then + echo 'Selected port is busy; no owner will be stopped.' >&2 + return 1 + else status=$?; fi + [[ "$status" == 1 && -z "$output" ]] || { + echo 'Cannot verify that the selected port is unowned.' >&2 + return 1 + } +} +python3 "$(dirname "$bench_inventory")/verified_nrf_flash.py" \ + --inventory "$bench_inventory" --serial "$bench_serial" --board "$bench_board" \ + --artifact "$bench_image" --port "$bench_port" +morse_assert_unowned "$bench_port" +sha256sum -- "$bench_image" +bench_state=$(mktemp -d "$bench_workdir/mcfirmware-session.XXXXXX") +bench_functions="$bench_state/functions.sh" +awk ' + { sub(/\r$/, "") } + /^[A-Za-z_][A-Za-z0-9_]*\(\)[[:space:]]*\{[[:space:]]*$/ { + if (capture) exit 2 + capture=1; count++ + } + capture { print } + capture && /^}[[:space:]]*$/ { capture=0; print "" } + END { if (capture || count < 100) exit 3 } +' "$bench_flasher" > "$bench_functions" +bash -n "$bench_functions" +source "$bench_functions" +# Do not source the interactive program's globals or unattended cleanup. +PWD_SCRIPT=$(dirname "$bench_flasher") +FIRMWARE_ROOT=$bench_state +DOWNLOAD_DIR="$bench_state/downloads" +mkdir -- "$DOWNLOAD_DIR" +DEVICE_PORT=$bench_port +DEVICE_PORT_FILE="$bench_state/selected-port" +DEVICE_PORT_NAME_FILE="$bench_state/selected-port-name" +MESHFIRMWARE_SERIAL_BY_ID_FILE="$bench_state/by-id" +printf '%s\n' "$bench_link" > "$MESHFIRMWARE_SERIAL_BY_ID_FILE" +NRF52_BOARD_GUARD_PASSED=1 +NRF52_SELECTED_BY_ID=$bench_link +NRF52_RUNTIME_SERIAL=$(udev_device_property "$bench_port" ID_SERIAL_SHORT) +NRF52_RUNTIME_PATH_STEM=$(nrf52_usb_path_stem "$(udev_device_property "$bench_port" ID_PATH)") +NRF52_RUNTIME_INSTANCE=$(nrf52_port_instance "$bench_port") +NRF52_LAST_DFU_PORT='' NRF52_LAST_DFU_INSTANCE='' +NRF52_ALLOW_UNMATCHED_DFU=0 +NRF52_DFU_REENUMERATE_TIMEOUT_SECONDS=30 +NRF52_DFU_REENUMERATE_POLL_SECONDS=0.25 +MCFIRMWARE_NO_SUDO=1 +# A mismatch, occupied port or failed flash must not trigger broad recovery. +nrf52_confirm_unmatched_dfu_override() { return 1; } +prepare_serial_port_for_flash() { morse_assert_unowned "$1"; } +recover_busy_serial_port() { return 1; } +offer_serial_port_recovery() { return 1; } +offer_identity_safe_1200_touch() { return 1; } +manual_reboot_choice() { return 1; } +run_nrf52_dfu_package_buttonless "$bench_image" "$bench_port" +printf 'Application-only mcfirmware DFU completed for %s (%s).\n' "$bench_board" "$bench_serial" diff --git a/tools/hil/morse_pi_inventory.py b/tools/hil/morse_pi_inventory.py new file mode 100644 index 000000000..7802db556 --- /dev/null +++ b/tools/hil/morse_pi_inventory.py @@ -0,0 +1,68 @@ +"""Read-only CLI/tool inventory for the identity-bound Mercerwood Morse bench.""" +import json +import os +from pathlib import Path +import subprocess +import time + +import serial +from serial.tools import list_ports + +EXPECTED = {"9AB3B64C641BA927": "RAK4631", "651F8E496197F882": "Heltec T096"} + + +def read_quiet(port, limit=2.0): + data = bytearray() + until = time.monotonic() + limit + quiet = time.monotonic() + 0.2 + while time.monotonic() < until and time.monotonic() < quiet: + chunk = port.read(port.in_waiting or 1) + if chunk: + data.extend(chunk) + quiet = time.monotonic() + 0.2 + return data.decode("utf-8", errors="replace") + + +def main(): + for command in (["hostname"], ["sudo", "-n", "true"], + ["find", "/home/mikec/meshfirmware", "-maxdepth", "2", "-iname", "*custom*"], + ["ls", "-la", "/home/mikec/meshfirmware/.customfirmware"]): + result = subprocess.run(command, capture_output=True, text=True, timeout=15) + print(json.dumps({"command": command, "rc": result.returncode, + "out": result.stdout, "err": result.stderr}), flush=True) + for item in list_ports.comports(): + ident = (item.serial_number or "").upper() + if ident not in EXPECTED or item.interface == "MeshCore Logging": + continue + owner = subprocess.run(["sudo", "-n", "fuser", "-v", item.device], + capture_output=True, text=True, timeout=10) + if owner.stdout.strip(): + print(json.dumps({"board": EXPECTED[ident], "serial": ident, + "port": item.device, "busy": True}), flush=True) + continue + port = serial.Serial(port=None, baudrate=115200, timeout=0.03) + port.dtr = True + port.rts = False + port.port = item.device + entered = False + try: + port.open() + read_quiet(port) + port.write(b"+++MESHCORE-TERM-START\n") + hello = read_quiet(port) + entered = "WELCOME" in hello + results = {} + for command in ("ver", "board", "get name", "get radio", "get tx", "get radio2.status"): + port.write((command + "\n").encode()) + results[command] = read_quiet(port) + print(json.dumps({"board": EXPECTED[ident], "serial": ident, + "port": item.device, "hello": hello, + "commands": results}), flush=True) + finally: + if entered: + port.write(b"+++MESHCORE-TERM-STOP\n") + port.close() + + +if __name__ == "__main__": + main() diff --git a/tools/hil/morse_remote.py b/tools/hil/morse_remote.py new file mode 100644 index 000000000..323c3d3d2 --- /dev/null +++ b/tools/hil/morse_remote.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +"""Real LoRa ACK-first Morse RX/capture trial on Mercerwood Mesh. + +Identity-bound RAK4631 repeater/T096 or Mesh Pocket Companion only. No flashing, erasure, +reboot, power control or service changes. Packets stay on 909.5 MHz, 500 kHz, +SF5, CR5. Keyed CW defaults to the user's 902.1 MHz, isolated from packet RX. +""" +import argparse +import asyncio +import json +from pathlib import Path +import re +import time + +if __package__: + from .morse_bench import (Console, SERIALS, live_temporary_profile, + DEFAULT_CW_FREQUENCY, parse_cw_frequency) +else: + from morse_bench import (Console, SERIALS, live_temporary_profile, + DEFAULT_CW_FREQUENCY, parse_cw_frequency) + +PACKET_RADIO = "909.5,500,5,5" +DEFAULT_MESSAGE = "REMOTE MORSE TEST 0123456789" +MORSE_CODES = dict(zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", ( + ".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---", + "-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.", "...", "-", + "..-", "...-", ".--", "-..-", "-.--", "--..", "-----", ".----", "..---", + "...--", "....-", ".....", "-....", "--...", "---..", "----."))) +MORSE_CODES.update(dict(zip(".,?'!/()&:;=+-_\"$@", ( + ".-.-.-", "--..--", "..--..", ".----.", "-.-.--", "-..-.", "-.--.", "-.--.-", + ".-...", "---...", "-.-.-.", "-...-", ".-.-.", "-....-", "..--.-", ".-..-.", + "...-..-", ".--.-.")))) +ERROR = re.compile(r"\b(?:Error|ERROR|ERR|Err)\b") +CAPTURE_PAGE = re.compile( + r"> (\d{4})/(\d{4}) next=(\d{4}|END ) truncated=([01]) ([^\r\n]*)") + + +def parse_capture_page(reply, offset): + """Validate production paging, including its padded END marker.""" + match = CAPTURE_PAGE.search(reply) + if not match or int(match[1]) != offset: + raise RuntimeError(("Malformed capture page", offset, reply)) + total, fragment = int(match[2]), match[5] + if int(match[4]) or total > 4096 or offset + len(fragment) > total: + raise RuntimeError(("Truncated/inconsistent capture", reply)) + if match[3] == "END ": + if offset + len(fragment) != total: + raise RuntimeError(("Incomplete final capture page", reply)) + following = None + else: + following = int(match[3]) + if following != offset + len(fragment) or following <= offset or following >= total: + raise RuntimeError(("Non-progressing capture page", reply)) + return fragment, following, total + + +async def read_capture(command): + text, offset, expected_total, pages = "", 0, None, [] + for _ in range(100): + reply = await command(f"morse read {offset}") + fragment, following, total = parse_capture_page(reply, offset) + if expected_total is not None and expected_total != total: + raise RuntimeError("Capture changed during paging") + expected_total = total + pages.append({"offset": offset, "reply": reply}) + text += fragment + if following is None: + return text, pages + offset = following + raise RuntimeError("Capture paging did not finish") + + +async def apply_live_temporary(command, profile, timeout=12): + """Wait for the immediate radio change, not a saved-only settings ACK.""" + expected = tuple(float(value) for value in profile.split(",")) + if len(expected) != 4: + raise ValueError("Expected frequency, bandwidth, SF and CR") + await command("set tempradio " + profile + ",30") + deadline = time.monotonic() + timeout + while True: + reply = await command("get tempradio") + if live_temporary_profile(reply) == expected: + return reply + if time.monotonic() >= deadline: + raise RuntimeError(("Live temporary radio did not apply", profile, reply)) + await asyncio.sleep(.1) + + +def capture_evidence(expected, local_text, local_pages, remote_text=None, remote_pages=None): + """Retain RF decoding and exact LoRa retrieval as independent evidence.""" + return {"expected": expected, "local": local_text, "remote": remote_text, + "local_pages": local_pages, "remote_pages": remote_pages or [], + "exact_remote_match": None if remote_text is None else remote_text == local_text, + "exact_rf_match": local_text.strip() == expected} + + +def public_key(reply): + keys = re.findall(r"(?= len(alphabet) ** 2: + raise RuntimeError("Two-character nonce space exhausted") + value = self.counter + self.counter += 1 + return alphabet[value // len(alphabet)] + alphabet[value % len(alphabet)] + + async def local(self, command, timeout=5, allow_error=False): + async with self.lock: + prefix = self.nonce() + "|" + started = time.monotonic() + # This return only proves USB admission, never receipt of reply29. + await self.mc.commands.send(b"\x42" + (prefix + command).encode("ascii"), + expected_events=None) + while True: + remaining = timeout - (time.monotonic() - started) + if remaining <= 0: + raise RuntimeError(("Local Binary CLI timeout", command)) + reply = await asyncio.wait_for(self.replies.get(), remaining) + if reply.startswith(prefix): + reply = reply[len(prefix):] + break + self.record("source_local", command=command, reply=reply) + if not allow_error and ERROR.search(reply): + raise RuntimeError(("Source CLI error", command, reply)) + return reply + + async def local_when_idle(self, command): + deadline = time.monotonic() + 10 + while True: + reply = await self.local(command, allow_error=True) + if "Error: radio busy" not in reply: + if ERROR.search(reply): + raise RuntimeError(("Source CLI error", command, reply)) + return reply + if time.monotonic() >= deadline: + raise RuntimeError(("Source remained busy", command, reply)) + await asyncio.sleep(.1) + + async def remote(self, key, command, timeout=5, allow_error=False): + async with self.lock: + prefix = self.nonce() + "|" + future = asyncio.get_running_loop().create_future() + peer_prefix = key[:12].lower() + + def receive(event): + payload = event.payload + text = payload.get("text", "") + if (payload.get("pubkey_prefix", "").lower() == peer_prefix + and payload.get("txt_type") == 1 and text.startswith(prefix) + and not future.done()): + future.set_result(text[len(prefix):]) + + # Subscribe before send: a fast LoRa reply can precede MSG_SENT. + subscription = self.mc.subscribe(self.contact_event, receive) + try: + # Unique real UTC seconds avoid pushing the peer's anti-replay + # floor into the future when paging rapidly, including next + # runs. Cancellation during this wait must retire the listener. + clock_deadline = time.monotonic() + 2 + while int(time.time()) <= self.last_timestamp: + if time.monotonic() >= clock_deadline: + raise RuntimeError("UTC clock stopped/backstepped during remote test") + await asyncio.sleep(.02) + self.last_timestamp = int(time.time()) + started = time.monotonic() + event = await self.mc.commands.send_cmd(key, prefix + command, + timestamp=self.last_timestamp) + if event.is_error(): + raise RuntimeError(("Remote command not admitted", command, event.payload)) + remaining = timeout - (time.monotonic() - started) + if remaining <= 0: + raise RuntimeError(("Remote CLI timeout", command)) + reply = await asyncio.wait_for(future, remaining) + finally: + subscription.unsubscribe() + if not future.done(): + future.cancel() + elapsed = time.monotonic() - started + self.record("remote", command=command, nonce=prefix[:2], reply=reply, + latency_seconds=round(elapsed, 4)) + if not allow_error and ERROR.search(reply): + raise RuntimeError(("Remote CLI error", command, reply)) + return reply, elapsed + + +async def run(args, results, record): + from meshcore import MeshCore, EventType + from meshcore.reader import MessageReader + + peer = source_console = bridge = mc = None + source_binary = False + try: + peer = await asyncio.to_thread(Console, SERIALS["rak"], False) + source_console = await asyncio.to_thread(Console, SERIALS[args.source]) + + async def peer_command(command, allow_error=False): + reply = await asyncio.to_thread(peer.command, command, 5, allow_error) + record("peer_local", command=command, reply=reply) + return reply + + peer_key = public_key(await peer_command("get public.key")) + source_key = public_key(await asyncio.to_thread(source_console.command, "get public.key")) + results["public_keys"] = {"peer": peer_key, "source": source_key} + for device in (peer, source_console): + for command in ("morse stop", "set radio.rxps off", "set tx -9", "set radio2 off", + f"set morse.freq {args.cw_frequency:.6f}", f"set morse.wpm {args.wpm}"): + await asyncio.to_thread(device.start_when_idle, command) + await asyncio.to_thread(device.apply_live_temporary, PACKET_RADIO) + await peer_command("setperm " + source_key + " 3") + await asyncio.to_thread(source_console.command, "set radio.fem.rxgain on", 5, True) + source_port = source_console.port.port + record("configured", peer_usb_serial=SERIALS["rak"], source_usb_serial=SERIALS[args.source], + peer_commands=peer.commands, source_commands=source_console.commands) + + source_console.port.write(b"+++MESHCORE-TERM-STOP\r\n") + source_binary = True + await asyncio.to_thread(source_console.read_quiet, 1) + # Never Console.close() after STOP: that would inject ASCII into Binary. + source_console.port.close() + mc = await MeshCore.create_serial(source_port, baudrate=115200, + only_error=True, default_timeout=5, auto_reconnect=False) + if mc is None: + raise RuntimeError("Could not start source Binary Companion session") + bridge = BinaryCLI(mc, MessageReader, EventType.CONTACT_MSG_RECV, record) + if public_key(await bridge.local("get public.key")) != source_key: + raise RuntimeError("Source identity changed at transport handoff") + contact = {"public_key": peer_key, "type": 2, "flags": 0, + "out_path_len": 0, "out_path_hash_mode": 0, "out_path": "", + "adv_name": "Morse remote repeater", "last_advert": int(time.time()), + "adv_lat": 0, "adv_lon": 0} + event = await mc.commands.add_contact(contact) + if event.is_error(): + raise RuntimeError(("Contact admission failed", event.payload)) + await mc.start_auto_message_fetching() + record("binary_ready", source_port=source_port) + + denied, _ = await bridge.remote(peer_key, "morse listen 6", allow_error=True) + if not ERROR.search(denied): + raise RuntimeError((">5 minute listen was not rejected", denied)) + if "> off;" not in await peer_command("get morse"): + raise RuntimeError("Rejected duration unexpectedly started RX") + + source_live = await bridge.local("get tempradio") + peer_live = await peer_command("get tempradio") + if any(live_temporary_profile(reply) != (909.5, 500, 5, 5) + for reply in (source_live, peer_live)): + raise RuntimeError(("Packet profile changed before listen", source_live, peer_live)) + record("packet_profiles_verified", source=source_live, peer=peer_live) + + ack, latency = await bridge.remote(peer_key, f"morse listen {args.minutes:g}") + if latency >= 5 or "Morse RX queued" not in ack: + raise RuntimeError(("Listen was not acknowledged before RX", ack, latency)) + active_deadline = time.monotonic() + 10 + while True: + status = await peer_command("get morse") + if re.search(r"> RX(?:\s| learning)", status): + break + if time.monotonic() >= active_deadline: + raise RuntimeError(("Peer never entered Morse RX", status)) + await asyncio.sleep(.1) + rx_observed = time.monotonic() + record("listen_active", status=status, acknowledgement_seconds=round(latency, 4)) + await asyncio.sleep(.8) + for index in range(args.repeat): + await bridge.local_when_idle("morse send " + args.message) + tx_deadline = time.monotonic() + 185 + while "> off;" not in await bridge.local("get morse"): + if time.monotonic() >= tx_deadline: + raise RuntimeError("Source failed to finish Morse TX") + await asyncio.sleep(.15) + if index + 1 < args.repeat: + await asyncio.sleep(.5) + record("cw_complete", transmissions=args.repeat, + source_live_packet_radio=PACKET_RADIO) + + expiry_deadline = rx_observed + args.minutes * 60 + 15 + while True: + status = await peer_command("get morse") + if "> off;" in status: + break + if time.monotonic() >= expiry_deadline: + raise RuntimeError(("Timed receiver did not recover", status)) + await asyncio.sleep(1) + record("listen_expired", elapsed_since_observed_seconds=round(time.monotonic() - rx_observed, 3), + status=status) + await apply_live_temporary(bridge.local_when_idle, PACKET_RADIO) + record("source_packet_radio_verified", live_packet_radio=PACKET_RADIO) + async def local_capture_command(command): + return await peer_command(command, allow_error=True) + + local_text, local_pages = await read_capture(local_capture_command) + expected = " ".join([args.message] * args.repeat) + results["capture"] = capture_evidence(expected, local_text, local_pages) + record("local_capture_received", captured_characters=len(local_text), + local_pages=len(local_pages), exact_rf_match=results["capture"]["exact_rf_match"]) + + async def remote_command(command): + return (await bridge.remote(peer_key, command))[0] + + async def remote_capture_command(command): + # Captured Morse may literally contain ERROR/ERR; the production + # page parser, not a keyword in the decoded payload, validates it. + return (await bridge.remote(peer_key, command, allow_error=True))[0] + + remote_text, remote_pages = await read_capture(remote_capture_command) + results["capture"] = capture_evidence(expected, local_text, local_pages, remote_text, remote_pages) + record("capture_received", captured_characters=len(local_text), remote_pages=len(remote_pages), + exact_remote_match=results["capture"]["exact_remote_match"], + exact_rf_match=results["capture"]["exact_rf_match"]) + radio = await remote_command("get tempradio") + saved_radio = await remote_command("get radio") + secondary = await remote_command("get radio2") + recovered = await remote_command("get morse") + results["recovery"] = {"live_radio": radio, "saved_radio": saved_radio, + "radio2": secondary, "morse": recovered} + recovery_ok = (live_temporary_profile(radio) == (909.5, 500, 5, 5) + and "> off" in secondary and "> off;" in recovered) + results["recovery"]["passed"] = recovery_ok + record("recovery_checked", passed=recovery_ok, live_radio=radio, + saved_radio=saved_radio, radio2=secondary, morse=recovered) + if not recovery_ok: + raise RuntimeError(("Packet profile/Morse recovery failed", radio, secondary, recovered)) + if not results["capture"]["exact_remote_match"]: + raise RuntimeError(("Remote/local capture mismatch", local_text, remote_text)) + if not results["capture"]["exact_rf_match"]: + raise RuntimeError(("RF decoded text differs from sent text", expected, local_text)) + results["passed"] = True + record("passed", captured_characters=len(local_text), remote_pages=len(remote_pages)) + finally: + if bridge is not None: + for command in ("morse stop", "set morse.wpm 25"): + try: + await bridge.local_when_idle(command) + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + try: + await apply_live_temporary(bridge.local_when_idle, PACKET_RADIO) + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + if mc is not None: + try: + await mc.disconnect() + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + if bridge is not None: + bridge.restore_reader() + if source_console is not None: + if not source_binary and source_console.port.is_open: + try: + source_console.command("morse stop", allow_error=True) + await asyncio.to_thread(source_console.apply_live_temporary, PACKET_RADIO) + source_console.close() + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + source_console.port.close() + if source_binary and bridge is None: + # A failed SDK handoff cannot use the already-closed Console. + # Reacquire this exact identity only after disconnecting SDK. + recovery = None + try: + recovery = await asyncio.to_thread(Console, SERIALS[args.source]) + for command in ("morse stop", "set morse.wpm 25"): + await asyncio.to_thread(recovery.command, command, 5, True) + await asyncio.to_thread(recovery.apply_live_temporary, PACKET_RADIO) + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + finally: + if recovery is not None: + recovery.port.close() + if peer is not None: + try: + for command in ("morse stop", "set morse.wpm 25"): + await asyncio.to_thread(peer.command, command, 5, True) + await asyncio.to_thread(peer.apply_live_temporary, PACKET_RADIO) + peer.close() + except Exception as error: + results.setdefault("cleanup_errors", []).append(str(error)) + finally: + peer.port.close() + results["peer_commands"] = peer.commands + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--source", choices=("t096", "pocket"), default="t096") + parser.add_argument("--cw-frequency", type=parse_cw_frequency, default=DEFAULT_CW_FREQUENCY, + help="CW MHz (default 902.1); must not overlap the packet radio") + parser.add_argument("--minutes", type=float, default=.5) + parser.add_argument("--wpm", type=int, default=25) + parser.add_argument("--message", default=DEFAULT_MESSAGE) + parser.add_argument("--repeat", type=int, default=1, + help="For multiple pages use --minutes 2 --repeat 8 --message 'REMOTE MORSE TEST'") + args = parser.parse_args() + if not 1 / 60 <= args.minutes <= 5 or not 1 <= args.wpm <= 255 or not 1 <= args.repeat <= 10: + parser.error("Listen 1 second-5 minutes, WPM 1-255, repeats 1-10 only") + if not 1 <= len(args.message) <= 80 or not args.message.isascii() or args.message != args.message.upper(): + parser.error("Use 1-80 uppercase ASCII Morse characters") + try: + duration = nominal_seconds(args.message, args.wpm) + except (ValueError, KeyError): + parser.error("Use supported Morse letters/digits/punctuation and single spaces") + if duration > 180 or args.repeat * duration + (args.repeat - 1) * .5 + 3 > args.minutes * 60: + parser.error("Message/repeats do not fit the requested listening window; shorten text or extend --minutes") + results = {"source": args.source, "packet_radio": PACKET_RADIO, + "cw_frequency_mhz": args.cw_frequency, + "source_packet_radio_during_cw": PACKET_RADIO, + "requested_chip_power_dbm": -9, "minutes": args.minutes, + "wpm": args.wpm, "message": args.message, "repeat": args.repeat, + "passed": False, "finished": False, "events": []} + try: + output = args.output.open("x", encoding="utf-8") + except FileExistsError: + parser.error("Refusing to overwrite an existing result") + with output: + def save(): + output.seek(0) + json.dump(results, output, indent=2) + output.write("\n") + output.truncate() + output.flush() + + def record(kind, **fields): + item = {"kind": kind, "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), **fields} + results["events"].append(item) + save() + print(json.dumps(item), flush=True) + + save() + try: + asyncio.run(run(args, results, record)) + results["finished"] = True + except BaseException as error: + results["error"] = str(error) + raise + finally: + save() + + +if __name__ == "__main__": + main() diff --git a/variants/muzi_base/target.cpp b/variants/muzi_base/target.cpp index 5bd846c98..fb2f331c4 100644 --- a/variants/muzi_base/target.cpp +++ b/variants/muzi_base/target.cpp @@ -132,6 +132,9 @@ bool radio_init() { radio.setRxBoostedGainMode(RX_BOOSTED_GAIN); #endif + // Preserve this board's TCXO and DIO RF routing during full modem recovery. + radio_driver.setDeepInitCallback(radio_init); + return true; // success #else // USE_SX1262 // CustomSX1262::std_init() configures the SPI pins, runs begin() with the diff --git a/variants/rak3x72/target.cpp b/variants/rak3x72/target.cpp index e55543a32..9c1bba69c 100644 --- a/variants/rak3x72/target.cpp +++ b/variants/rak3x72/target.cpp @@ -51,6 +51,7 @@ bool radio_init() { #endif radio.setCRC(1); + radio_driver.setDeepInitCallback(radio_init); return true; // success } diff --git a/variants/tiny_relay/target.cpp b/variants/tiny_relay/target.cpp index aed8a67c6..958651e1e 100644 --- a/variants/tiny_relay/target.cpp +++ b/variants/tiny_relay/target.cpp @@ -53,6 +53,7 @@ bool radio_init() #endif radio.setCRC(1); + radio_driver.setDeepInitCallback(radio_init); return true; // success } diff --git a/variants/wio-e5-dev/target.cpp b/variants/wio-e5-dev/target.cpp index 680da631a..bc6ccffee 100644 --- a/variants/wio-e5-dev/target.cpp +++ b/variants/wio-e5-dev/target.cpp @@ -48,6 +48,7 @@ bool radio_init() { #endif radio.setCRC(1); + radio_driver.setDeepInitCallback(radio_init); return true; // success } diff --git a/variants/wio-e5-mini/target.cpp b/variants/wio-e5-mini/target.cpp index 1a911777c..43981a4c0 100644 --- a/variants/wio-e5-mini/target.cpp +++ b/variants/wio-e5-mini/target.cpp @@ -52,6 +52,7 @@ bool radio_init() { #endif radio.setCRC(1); + radio_driver.setDeepInitCallback(radio_init); return true; // success }