From b1198b6e3e9b3368fa4decebf86f4bf564b48755 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 13 Oct 2026 03:02:52 -0700 Subject: [PATCH] Improve Morse timing adaptation and repair CI regression coverage --- .github/workflows/run-unit-tests.yml | 3 + docs/test-results/morse_rx_hil_2026-10-12.md | 48 ++++ src/helpers/MorseCLI.h | 76 +++++- src/helpers/MorseRx.h | 37 ++- src/helpers/MorseTx.h | 136 +++++++++-- src/helpers/radiolib/RadioLibWrappers.cpp | 24 +- src/helpers/radiolib/RadioLibWrappers.h | 13 +- test/fixtures/morse_hil_timing.cpp | 108 +++++++++ test/fixtures/morse_rx_hil_human.cpp | 53 +++++ test/fixtures/morse_rx_human.cpp | 99 ++++++++ test/fixtures/morse_rx_timing.cpp | 175 ++++++++++++++ test/test_bridge_recovery.py | 23 +- test/test_carrier_wave.py | 10 +- test/test_morse_hil_timing.py | 237 +++++++++++++++++++ test/test_morse_remote.py | 10 +- test/test_morse_rx.py | 38 ++- test/test_morse_rx_human.py | 50 ++++ test/test_morse_rx_timing.py | 24 ++ test/test_morse_tx.py | 26 ++ tools/hil/morse_bench.py | 124 ++++++++-- 20 files changed, 1240 insertions(+), 74 deletions(-) create mode 100644 test/fixtures/morse_hil_timing.cpp create mode 100644 test/fixtures/morse_rx_hil_human.cpp create mode 100644 test/fixtures/morse_rx_human.cpp create mode 100644 test/fixtures/morse_rx_timing.cpp create mode 100644 test/test_morse_hil_timing.py create mode 100644 test/test_morse_rx_human.py create mode 100644 test/test_morse_rx_timing.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 7b84b987b..0d25de8b9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -337,6 +337,9 @@ jobs: 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_rx_timing.py -v + python3 -B test/test_morse_rx_human.py -v + python3 -B test/test_morse_hil_timing.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 diff --git a/docs/test-results/morse_rx_hil_2026-10-12.md b/docs/test-results/morse_rx_hil_2026-10-12.md index 0f81f5649..b67f37b37 100644 --- a/docs/test-results/morse_rx_hil_2026-10-12.md +++ b/docs/test-results/morse_rx_hil_2026-10-12.md @@ -1,3 +1,7 @@ + + + + # Morse receive hardware checkpoint - 12 October 2026 Status: **incomplete hardware validation**. The release default remains @@ -118,3 +122,47 @@ 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. + +## Follow-up: 13 October 2026 + +The original checkpoint above is historical. Its implementation and Reader / +RS232 integration were subsequently pushed as `0be4c7488` and `396304822`. + +- The receiver now keeps its timing estimate in Q5 rather than whole + milliseconds, eliminating the rounding deadband without changing its + 4168-byte production allocation. Letter/word split thresholds of 2.25/5.5 + dot units tolerate ordinary +/-20% gap jitter; doubled letter spacing + remains explicitly ambiguous, not a supported exact-decoding result. +- A volatile, default-off HIL transmitter injects seeded phase jitter, + gradual speed drift and modest extra spacing. Its exact pre-key duration + check retains the 180-second ceiling. Shipping images contain neither + the HIL configuration nor its command branch. +- Fresh focused verification passed 108/108 tests. The real HIL scheduler + and receiver passed 154 exact human-timing vectors through experimental + 240 WPM; 84 additional supported synthetic vectors passed. These are + software tests, not physical RF speed qualification. +- The CI stability group passed 106/106. The bridge recovery fixture now + includes the actual RS232 power/heartbeat helpers and verifies byte-exact + retained short writes. Source/published documentation links passed 7/7 + tests plus browser-script checks. +- Compact STM32 USB Companion links at 228868/229376 flash bytes with + unchanged commands and ITU character support. An exhaustive 256-byte + comparison verifies the compact ASCII lookup against the original. +- All four nRF52 HIL targets compiled. Fresh application-only DFU packages + were installed on the identity-bound RAK4631, MeshPocket and T096 without + changing their bootloaders or SoftDevices. +- A five-minute remote receive session expired and restored LoRa. All 424 + captured characters were returned exactly in four LoRa pages, but the + decoded RF text did not match the nine transmitted messages: this is a + transport/recovery pass and a decoding failure, not an overall pass. +- Six-second no-intentional-TX controls at 902.1 and 903.1 MHz were empty + on the RAK, but produced false characters on the MeshPocket. A subsequent + 25-WPM alphabet/digit message decoded exactly from Pocket to RAK; the + reverse direction had errors. One clean message does not establish a + zero-error speed limit. + +RF investigation and repeated standard/uneven-timing speed qualification +remain in progress. Default 25 WPM, public ceiling 60 WPM, radio bandwidth, +and shipping RF filtering have not been changed. Packet control stays at +909.5 MHz / 500 kHz / SF5 / CR5; CW remains separate at 902.100 MHz by default. +Raw results remain in `out/morse-rx/` and the Pi run directory above. diff --git a/src/helpers/MorseCLI.h b/src/helpers/MorseCLI.h index 3002c1cea..e9c32bede 100644 --- a/src/helpers/MorseCLI.h +++ b/src/helpers/MorseCLI.h @@ -17,6 +17,29 @@ inline const char* morseUsageError() { #endif } +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +inline bool parseMorseHilTiming(const char* argument, MorseHilTimingConfig& timing) { + if (!argument || strlen(argument) >= 64) return false; + char fields[64]; + strcpy(fields, argument); + char* values[4] = {fields, nullptr, nullptr, nullptr}; + for (unsigned index = 0; index < 3; ++index) { + char* separator = strchr(values[index], ','); + if (!separator) return false; + *separator = 0; values[index + 1] = separator + 1; + } + if (strchr(values[3], ',')) return false; + uint32_t jitter = 0, gap = 0, seed = 0; + int32_t drift = 0; + if (!cli::parseUnsignedIntegerStrict(values[0], jitter) || jitter > 20 + || !cli::parseIntegerStrict(values[1], drift) || drift < -20 || drift > 20 + || !cli::parseUnsignedIntegerStrict(values[2], gap) || gap > 50 + || !cli::parseUnsignedIntegerStrict(values[3], seed) || !seed) return false; + timing = MorseHilTimingConfig(uint8_t(jitter), int8_t(drift), uint8_t(gap), seed); + return true; +} +#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, @@ -184,12 +207,55 @@ inline bool handleMorseCommand(Radio* radio, const char* command, 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())) { +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + const bool hil_timing = length == 16 && !strncmp(key, "morse.hil.timing", 16); +#endif + if (!base && !frequency && !speed +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + && !hil_timing +#endif + ) return false; + if (!radio || (!radio->supportsMorseTx() +#if MESH_ENABLE_MORSE_RX + && !radio->supportsMorseRx() +#endif + )) { snprintf(reply, capacity, "Error: Morse unsupported on this radio"); return true; } const char* args = cli::skipRecentRepeaterSpaces(key + length); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + if (hil_timing) { + if (query) { + if (*args) snprintf(reply, capacity, "Error: unexpected argument"); + else { + const auto& timing = morseHilTiming(); + snprintf(reply, capacity, "> jitter=%u%%; drift=%d%%; extraGap=%u%%; seed=%lu; volatile HIL TX only", + unsigned(timing.jitter_percent), int(timing.drift_percent), + unsigned(timing.extra_gap_percent), (unsigned long)timing.seed); + } + return true; + } + if (!setting) { + snprintf(reply, capacity, "Error: use get/set morse.hil.timing"); + return true; + } + if (radio->isCarrierWaveActive() || radio->isMorseTxActive() || radio->isMorseRxActive() + || !radio->isInRecvMode() || radio->isReceiving()) { + snprintf(reply, capacity, "Error: radio busy; HIL timing changes require idle RX"); + return true; + } + MorseHilTimingConfig timing; + if (!parseMorseHilTiming(args, timing) || !setMorseHilTiming(timing)) { + snprintf(reply, capacity, "Error: use jitter0-20,drift-20..20,extraGap0-50,seed1..4294967295"); + return true; + } + snprintf(reply, capacity, "OK - HIL Morse timing %u,%d,%u,%lu; volatile TX only", + unsigned(timing.jitter_percent), int(timing.drift_percent), + unsigned(timing.extra_gap_percent), (unsigned long)timing.seed); + return true; + } +#endif const auto& current = morseSettings(); if ((query || (base && !*args && !setting)) && !morseSettingsUsable()) { snprintf(reply, capacity, "Error: saved Morse settings unavailable; verify and save morse.freq"); @@ -210,7 +276,11 @@ inline bool handleMorseCommand(Radio* radio, const char* command, snprintf(reply, capacity, "Error: verify and save morse.freq before changing Morse WPM"); return true; } - if (radio->isCarrierWaveActive() || radio->isMorseTxActive() || radio->isMorseRxActive()) { + if (radio->isCarrierWaveActive() || radio->isMorseTxActive() +#if MESH_ENABLE_MORSE_RX + || radio->isMorseRxActive() +#endif + ) { snprintf(reply, capacity, "Error: radio busy; stop Morse/CW before changing settings"); return true; } diff --git a/src/helpers/MorseRx.h b/src/helpers/MorseRx.h index 3d702e0ad..0fc9d4c14 100644 --- a/src/helpers/MorseRx.h +++ b/src/helpers/MorseRx.h @@ -152,7 +152,8 @@ public: clear(); _started = _last_edge = now; _until = now + duration_ms; - _nominal_unit = _unit = MorseTx::unitMillis(wpm); + _nominal_unit = MorseTx::unitMillis(wpm); + _unit_q5 = uint16_t(uint32_t(_nominal_unit) * UnitScale); _noise = _signal = 0; _samples = 0; _last_sample_at = now; @@ -180,7 +181,7 @@ public: 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; } + uint16_t unitMillis() const { return uint16_t((_unit_q5 + UnitScale / 2) / UnitScale); } void diagnostics(MorseRxDiagnostics& out) const { out.noise_dbm = _have_noise ? _noise : NAN; out.last_dbm = _last_rssi; @@ -188,7 +189,7 @@ public: out.maximum_dbm = _maximum_rssi; out.samples = _samples; out.max_sample_gap_ms = _max_sample_gap_ms; - out.unit_ms = _unit; + out.unit_ms = unitMillis(); out.read_errors = 0; } @@ -289,7 +290,7 @@ public: traceSample(rssi_dbm, now, raw_keyed); return; } - uint16_t debounce = _unit / 4; + uint16_t debounce = _unit_q5 / (4 * UnitScale); uint16_t minimum_debounce = 2; #if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL \ && defined(MESH_MORSE_HIL_FAST_OFF_DEBOUNCE) \ @@ -331,13 +332,19 @@ public: } private: + // Keep fractional timing in the existing 16-bit field: a whole-ms 1/8 EMA + // has a four-ms rounding deadband and cannot adapt at fast Morse speeds. + // Q5 supports the full 1 WPM, +25% range without adding receiver memory. + static constexpr uint16_t UnitScale = 32; + static_assert((1200UL * 5 / 4) * UnitScale <= UINT16_MAX, + "fractional Morse timing must fit its existing 16-bit field"); 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; + uint16_t _length = 0, _unit_q5 = 0, _nominal_unit = 0; uint8_t _code = 1, _symbols = 0; bool _active = false, _keyed = false, _have_noise = false; bool _floor_settled = false; @@ -393,20 +400,26 @@ private: // 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) { + const uint16_t nominal_q5 = uint16_t(uint32_t(_nominal_unit) * UnitScale); + const uint16_t gap_unit_q5 = _unit_q5 < nominal_q5 ? _unit_q5 : nominal_q5; + // Ordinary +/-20% gap jitter still separates an intra-letter gap (<=1.2 + // units) from a letter gap (>=2.4), and letters (<=3.6) from words (>=5.6). + // Keep some margin at 2.25/5.5 units rather than merging shortened gaps. + // This is not automatic Farnsworth-spacing or unknown-speed acquisition. + if (gap * 4UL * UnitScale >= uint32_t(gap_unit_q5) * 9UL && !_letter_flushed) flushLetter(); + if (gap * 2UL * UnitScale >= uint32_t(gap_unit_q5) * 11UL && !_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) { + const uint32_t duration_q5 = duration_ms * UnitScale; + if (duration_q5 * 3UL < _unit_q5) return; // sub-third-dot glitches + if (duration_q5 > uint32_t(_unit_q5) * 6UL || _symbols >= 7) { _bad_pattern = true; return; } - const bool dash = duration_ms >= uint32_t(_unit) * 2UL; + const bool dash = duration_q5 >= uint32_t(_unit_q5) * 2UL; _code = uint8_t((_code << 1) | (dash ? 1 : 0)); ++_symbols; uint32_t observed = dash ? (duration_ms + 1) / 3 : duration_ms; @@ -414,7 +427,7 @@ private: 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); + _unit_q5 = uint16_t((uint32_t(_unit_q5) * 7 + observed * UnitScale + 4) / 8); } }; diff --git a/src/helpers/MorseTx.h b/src/helpers/MorseTx.h index 58b458233..e63199219 100644 --- a/src/helpers/MorseTx.h +++ b/src/helpers/MorseTx.h @@ -17,6 +17,69 @@ enum class MorseTxResult : uint8_t { InvalidText, InvalidSpeed, TooLong, Failed }; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +// Lab-only, volatile transmitter timing. Nothing is persisted or enabled by +// boot, and no production encoder fields or timing paths depend on this. +struct MorseHilTimingConfig { + uint8_t jitter_percent; + int8_t drift_percent; + uint8_t extra_gap_percent; + uint32_t seed; + MorseHilTimingConfig(uint8_t jitter = 0, int8_t drift = 0, + uint8_t extra_gap = 0, uint32_t random_seed = 1) + : jitter_percent(jitter), drift_percent(drift), + extra_gap_percent(extra_gap), seed(random_seed) {} + bool valid() const { + return jitter_percent <= 20 && drift_percent >= -20 && drift_percent <= 20 + && extra_gap_percent <= 50 && seed != 0; + } +}; +inline MorseHilTimingConfig& morseHilTimingStorage() { + static MorseHilTimingConfig timing; + return timing; +} +inline const MorseHilTimingConfig& morseHilTiming() { return morseHilTimingStorage(); } +inline bool setMorseHilTiming(const MorseHilTimingConfig& timing) { + if (!timing.valid()) return false; + morseHilTimingStorage() = timing; + return true; +} + +// The pre-key duration planner and live scheduler use exactly this sequence. +// Carry fractional milliseconds between phases so fast dots do not silently +// lose all sub-ms jitter. Native hardware transitions still rebase deadlines. +class MorseHilTimingSequence { +public: + void begin(const MorseHilTimingConfig& config, uint16_t unit, uint8_t characters) { + _config = config; _unit = unit; _characters = characters; + _random = config.seed; _fraction = 5000; + } + uint16_t phase(uint8_t units, bool mark, uint8_t character) { + if (!_config.jitter_percent && !_config.drift_percent && !_config.extra_gap_percent) + return uint16_t(units * _unit); + int32_t basis_points = 10000; + if (_characters > 1) + basis_points += int32_t(_config.drift_percent) * 100 * character / (_characters - 1); + if (_config.jitter_percent) { + _random ^= _random << 13; _random ^= _random >> 17; _random ^= _random << 5; + const uint32_t spread = uint32_t(_config.jitter_percent) * 100; + basis_points += int32_t(_random % (spread * 2 + 1)) - int32_t(spread); + } + uint64_t scaled = uint64_t(units) * _unit * uint32_t(basis_points); + if (!mark && units >= 3) scaled = scaled * (100 + _config.extra_gap_percent) / 100; + scaled += _fraction; + const uint32_t milliseconds = uint32_t(scaled / 10000); + _fraction = uint16_t(scaled % 10000); + return uint16_t(milliseconds ? milliseconds : 1); + } +private: + MorseHilTimingConfig _config; + uint32_t _random = 1; + uint16_t _unit = 0, _fraction = 5000; + uint8_t _characters = 0; +}; +#endif + // 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; @@ -46,21 +109,18 @@ public: 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 + // A single flash-resident ASCII table avoids a punctuation switch/jump + // table and keeps the complete alphabet on compact targets. Zero entries + // remain unsupported; lowercase still uses its uppercase ITU code. + static const uint8_t codes[] = { + 107,82,0,137,0,40,94,54,109,0,42,115,97,85,50, // ! through / + 63,47,39,35,33,32,48,56,60,62, // 0 through 9 + 120,106,0,49,0,76,90, // : through @ + 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, + 0,0,0,0,77 // [ through _ }; - 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; - } + const uint8_t index = uint8_t(uint8_t(character) - uint8_t('!')); + return index < sizeof(codes) ? codes[index] : 0; } static uint8_t symbolCount(uint8_t code) { @@ -97,7 +157,30 @@ public: } if (length > MaxTextLength) return MorseTxResult::TooLong; if (!have_character) return MorseTxResult::InvalidText; - const uint32_t duration = units * unit; + uint32_t duration = units * unit; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + // A fixed seed is restarted for every message, independent of prior sends. + const auto config = morseHilTiming(); + if (!config.valid()) return MorseTxResult::Failed; + uint8_t characters = 0; + for (size_t index = 0; text[index]; ++index) if (text[index] != ' ') ++characters; + MorseHilTimingSequence sequence; + sequence.begin(config, unit, characters); + duration = 0; + uint8_t character = 0; + have_character = have_space = false; + for (size_t index = 0; text[index]; ++index) { + if (text[index] == ' ') { if (have_character) have_space = true; continue; } + if (have_character) duration += sequence.phase(have_space ? 7 : 3, false, character); + const uint8_t code = codeFor(text[index]); + const uint8_t count = symbolCount(code); + for (uint8_t bit = count; bit; --bit) { + duration += sequence.phase((code & (1U << (bit - 1))) ? 3 : 1, true, character); + if (bit > 1) duration += sequence.phase(1, false, character); + } + ++character; have_character = true; have_space = false; + } +#endif if (duration > MaxDurationMillis) return MorseTxResult::TooLong; if (duration_ms) *duration_ms = duration; return MorseTxResult::Started; @@ -125,7 +208,15 @@ public: _count = symbolCount(_code); _active = _keyed = true; _remaining = duration; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + uint8_t characters = 0; + for (size_t index = 0; _text[index]; ++index) if (_text[index] != ' ') ++characters; + _hil_timing.begin(morseHilTiming(), _unit, characters); + _hil_character = 0; + _phase_ms = _hil_timing.phase(markUnits(), true, _hil_character); +#else _phase_ms = markUnits() * _unit; +#endif _next = now + _phase_ms; _hard_end = now + MaxDurationMillis; return MorseTxResult::Started; @@ -166,14 +257,25 @@ public: _symbol = 0; _code = codeFor(_text[_character]); _count = symbolCount(_code); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + ++_hil_character; +#endif } +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + _phase_ms = _hil_timing.phase(gap, false, _hil_character); +#else _phase_ms = gap * _unit; +#endif // Rebase on actual service time. Never key a burst of missed edges. _next = now + _phase_ms; return Edge::KeyUp; } _keyed = true; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + _phase_ms = _hil_timing.phase(markUnits(), true, _hil_character); +#else _phase_ms = markUnits() * _unit; +#endif _next = now + _phase_ms; return Edge::KeyDown; } @@ -184,6 +286,10 @@ private: uint16_t _unit = 0, _phase_ms = 0; uint8_t _character = 0, _symbol = 0, _code = 0, _count = 0; bool _active = false, _keyed = false; +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + MorseHilTimingSequence _hil_timing; + uint8_t _hil_character = 0; +#endif uint8_t markUnits() const { return (_code & (1U << (_count - 1 - _symbol))) ? 3 : 1; } }; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index c6b8d0fa2..e48001619 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -235,7 +235,11 @@ 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 ((_morse_owned +#if MESH_ENABLE_MORSE_RX + || _morse_rx_owned +#endif + ) && _cw_active) return false; if (_cw_active) { if (_cw_stopping) return false; // Permit meter sweeps through the board's normal power/PA calibration. @@ -1006,7 +1010,9 @@ void RadioLibWrapper::checkReceiveMode(uint32_t now) { uint32_t RadioLibWrapper::carrierWaveRemainingMillis() const { if (!_cw_active || _cw_stopping) return 0; if (_morse_owned) return morseTxRemainingMillis(); +#if MESH_ENABLE_MORSE_RX if (_morse_rx_owned) return morseRxRemainingMillis(); +#endif const int32_t remaining = static_cast(_cw_until - millis()); return remaining > 0 ? static_cast(remaining) : 0; } @@ -1015,13 +1021,21 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin 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 (!duration_ms && (_morse_owned +#if MESH_ENABLE_MORSE_RX + || _morse_rx_owned +#endif + )) 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 (_morse_owned || _morse_rx_owned || _cw_stopping || profile != _cw_profile + if (_morse_owned +#if MESH_ENABLE_MORSE_RX + || _morse_rx_owned +#endif + || _cw_stopping || profile != _cw_profile || _cw_generation != _profiles.generation[profile]) return Result::BUSY; _cw_until = static_cast(millis()) + duration_ms; return Result::APPLIED; @@ -1402,13 +1416,17 @@ bool RadioLibWrapper::stopCarrierWave() { return false; } _morse_owned = false; +#if MESH_ENABLE_MORSE_RX _morse_rx_owned = false; +#endif _board->setRadioTestActive(false); return true; } bool RadioLibWrapper::serviceCarrierWave() { +#if MESH_ENABLE_MORSE_RX if (serviceMorseRx()) return true; +#endif if (serviceMorseTx()) return true; if (_cw_active) { const uint32_t now = millis(); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index c9e7db8c6..e12108468 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -123,10 +123,15 @@ protected: #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; +#endif +#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) + // Non-STM wrappers retain their native hook declarations for an explicit + // RX-disabled build; those hooks may still compile but cannot be admitted. + bool _morse_rx_fsk_mode = false; +#endif + // End Morse RX state. bool stopCarrierWave(); bool serviceCarrierWave(); bool serviceMorseTx(); @@ -247,6 +252,9 @@ protected: } virtual int16_t enterMorseCarrier() { return _radio->transmitDirect(); } virtual int16_t leaveMorseCarrier() { return _radio->standby(); } +#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) + // Compact integrated-STM builds cannot receive Morse. Do not pin unused + // native RX thunks and vtable slots in their already full application slot. virtual float morseReceiverBandwidthKHz() const { return 0.0f; } virtual int16_t prepareMorseReceiver(float frequency_mhz) { (void)frequency_mhz; @@ -258,6 +266,7 @@ protected: virtual float readMorseRssi() { return getCurrentRSSI(); } #if MESH_ENABLE_MORSE_RX virtual bool canAllocateMorseReceiver() const; +#endif #endif // Native-CW radios keep their modem. Reapply cached power in case a meter // sweep failed halfway through; SX127x also rebuilds its LoRa configuration. diff --git a/test/fixtures/morse_hil_timing.cpp b/test/fixtures/morse_hil_timing.cpp new file mode 100644 index 000000000..8c720bd4d --- /dev/null +++ b/test/fixtures/morse_hil_timing.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include + +using mesh::MorseTx; +using Result = mesh::MorseTxResult; + +static std::vector phases(const char* text, uint8_t wpm, uint32_t start, + bool hardware_delay = false) { + uint32_t planned = 0; + const auto valid = MorseTx::validate(text, wpm, &planned); + MorseTx timer; + if (valid != Result::Started) { + assert(valid == Result::TooLong && timer.start(text, wpm, start) == valid); + assert(!timer.active() && !timer.keyed()); + return {}; + } + assert(timer.start(text, wpm, start) == Result::Started); + std::vector result; + uint32_t now = start, total = 0; + while (timer.active()) { + const uint32_t phase = timer.nextDeadline() - now; + assert(phase > 0 && phase < 65536); + assert(timer.remainingMillis(now) == planned - total); + assert(timer.advance(now + phase - 1) == MorseTx::Edge::None); + now += phase; total += phase; result.push_back(phase); + const auto edge = timer.advance(now); + assert(edge != MorseTx::Edge::None && edge != MorseTx::Edge::Expired); + if (timer.active() && hardware_delay) { + now += 3; timer.edgeApplied(now); + assert(timer.nextDeadline() > now); // The native transition owns its delay. + } + assert(result.size() < 2000); + } + assert(total == planned && timer.remainingMillis(now) == 0); + return result; +} + +int main() { +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL + using mesh::MorseHilTimingConfig; + assert(mesh::morseHilTiming().jitter_percent == 0); + const auto baseline = phases("SOS E T", 25, 100); + for (const auto config : {MorseHilTimingConfig(), MorseHilTimingConfig(20,0,0,1), + MorseHilTimingConfig(0,20,0,1), MorseHilTimingConfig(0,-20,0,1), + MorseHilTimingConfig(0,0,50,1), MorseHilTimingConfig(15,10,25,12345), + MorseHilTimingConfig(20,-20,50,0xffffffff)}) { + assert(mesh::setMorseHilTiming(config)); + for (const uint8_t wpm : {uint8_t(1),uint8_t(25),uint8_t(60),uint8_t(200),uint8_t(255)}) { + for (const char* text : {"E", "SOS E T", " a B ", ". , ? ' ! / ( ) & : ; = + - _ \" $ @"}) { + const auto first = phases(text, wpm, 100); + assert(first == phases(text, wpm, 0xffffffe0)); + } + } + } + assert(mesh::setMorseHilTiming(MorseHilTimingConfig(15,10,25,12345))); + const auto expected = phases("SOS E T",25,100); + assert(expected != baseline); + assert(expected == phases("SOS E T",25,100,true)); + MorseTx running; + assert(running.start("SOS E T",25,100) == Result::Started); + assert(mesh::setMorseHilTiming(MorseHilTimingConfig(20,-20,50,54321))); + uint32_t now=100; + std::vector actual; + while(running.active()) { + const uint32_t phase=running.nextDeadline()-now; + actual.push_back(phase); now+=phase; running.advance(now); + } + assert(actual == expected); // Active settings are a snapshot, not live globals. + + assert(mesh::setMorseHilTiming(MorseHilTimingConfig(10,0,0,12345))); + const auto fast = phases(std::string(80,'E').c_str(),255,100); + bool changed_mark=false; + for(size_t index=0;index({48,48,48,48,48,144,144,48,144,48,144,144,48,48,48,48,48})); +#endif +} diff --git a/test/fixtures/morse_rx_hil_human.cpp b/test/fixtures/morse_rx_hil_human.cpp new file mode 100644 index 000000000..bcceb3572 --- /dev/null +++ b/test/fixtures/morse_rx_hil_human.cpp @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include + +#if !defined(MESH_MORSE_HIL) || !MESH_MORSE_HIL +#error "This end-to-end fixture requires the actual HIL transmitter" +#endif + +int main() { + const struct { const char* name; mesh::MorseHilTimingConfig timing; } profiles[] = { + {"normal", {0, 0, 0, 1}}, + {"bench_preset", {15, 10, 25, 12345}}, + {"jitter20", {20, 0, 0, 1}}, + {"speeding20", {0, -20, 0, 2}}, + {"slowing20", {0, 20, 0, 2}}, + {"spacing50", {0, 0, 50, 3}}, + {"mixed_speeding", {15, -10, 25, 6789}} + }; + constexpr const char* message = "CQ DE TEST 73 SOS"; + unsigned failures = 0; + // Rates above production's 60 WPM ceiling are explicit lab-only stress + // vectors, including the fractional 5 ms phase quantization at 240 WPM. + for (uint8_t wpm : {5, 10, 15, 20, 25, 30, 40, 60, 120, 200, 240}) { + for (uint32_t cadence : {1U, 2U}) { + for (const auto& profile : profiles) { + assert(mesh::setMorseHilTiming(profile.timing)); + mesh::MorseTx sender; + mesh::MorseRx receiver; + assert(receiver.start(wpm, mesh::MorseRx::MaxDurationMillis, 0) == mesh::MorseRxResult::Started); + uint32_t now = 0; + for (; now < 600; ++now) if (now % cadence == 0) receiver.sample(-120, now); + assert(sender.start(message, wpm, now) == mesh::MorseTxResult::Started); + while (sender.active()) { + const auto edge = sender.advance(now); + if (edge != mesh::MorseTx::Edge::None) sender.edgeApplied(now); + if (now % cadence == 0) receiver.sample(sender.keyed() ? -60 : -120, now); + ++now; + } + const uint32_t finish = now + mesh::MorseTx::unitMillis(wpm) * 8U; + for (; now < finish; ++now) if (now % cadence == 0) receiver.sample(-120, now); + receiver.stop(now); + const bool exact = strcmp(receiver.text(), "CQ DE TEST 73 SOS ") == 0; + printf("%u\t%u\t%s\t%s\t%s\n", wpm, cadence, profile.name, + exact ? "PASS" : "FAIL", receiver.text()); + if (!exact) ++failures; + } + } + } + assert(mesh::setMorseHilTiming({})); + return failures ? 1 : 0; +} diff --git a/test/fixtures/morse_rx_human.cpp b/test/fixtures/morse_rx_human.cpp new file mode 100644 index 000000000..6d4551f7a --- /dev/null +++ b/test/fixtures/morse_rx_human.cpp @@ -0,0 +1,99 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +struct Segment { bool keyed; uint32_t duration; }; +enum class Style { Standard, Jitter, Slowing, Speeding, Mixed, Farnsworth, WideFarnsworth }; + +static uint32_t scaled(double millis) { return std::max(1U, uint32_t(std::lround(millis))); } + +static std::vector waveform(const char* text, uint32_t nominal, Style style) { + constexpr double dot_jitter[] = {.85, 1.10, .95, 1.15, 1.0}; + constexpr double dash_ratio[] = {2.5, 3.1, 3.5, 2.8, 3.2}; + constexpr double intra_jitter[] = {.80, 1.10, 1.20, .95}; + constexpr double letter_jitter[] = {2.8, 3.2, 3.5, 3.0}; + constexpr double word_jitter[] = {6.5, 7.0, 8.0}; + std::vector result; + unsigned marks = 0, letters = 0, words = 0; + const size_t size = strlen(text); + for (size_t index = 0; index < size; ++index) { + if (text[index] == ' ') continue; + const double progress = double(index) / double(size - 1); + double unit = nominal; + if (style == Style::Slowing || style == Style::Mixed) unit *= 1.0 + .20 * progress; + if (style == Style::Speeding) unit *= 1.0 - .20 * progress; + const bool jitter = style == Style::Jitter || style == Style::Mixed; + const uint8_t code = mesh::MorseTx::codeFor(text[index]); + assert(code); + const uint8_t count = mesh::MorseTx::symbolCount(code); + for (uint8_t bit = count; bit; --bit) { + const bool dash = ((code >> (bit - 1)) & 1) != 0; + const double mark = dash ? (jitter ? dash_ratio[marks % 5] : 3.0) + : (jitter ? dot_jitter[marks % 5] : 1.0); + result.push_back({true, scaled(unit * mark)}); + ++marks; + if (bit > 1) result.push_back({false, scaled(unit * (jitter ? intra_jitter[marks % 4] : 1.0))}); + } + const bool word = index + 1 < size && text[index + 1] == ' '; + const double spacing = style == Style::Farnsworth ? 1.5 : style == Style::WideFarnsworth ? 2.0 : 1.0; + const double gap = word ? (jitter ? word_jitter[words++ % 3] : 7.0) + : (jitter ? letter_jitter[letters++ % 4] : 3.0); + result.push_back({false, scaled(unit * gap * spacing)}); + } + return result; +} + +static std::string run(uint8_t wpm, Style style, uint32_t cadence, uint16_t& learned) { + constexpr const char* text = "CQ DE TEST 73 SOS"; + mesh::MorseRx decoder; + assert(decoder.start(wpm, mesh::MorseRx::MaxDurationMillis, 0) == mesh::MorseRxResult::Started); + const auto segments = waveform(text, mesh::MorseTx::unitMillis(wpm), style); + uint32_t elapsed = 0; + for (; elapsed < 600; elapsed += cadence) decoder.sample(-120.0f, elapsed); + size_t position = 0; + uint32_t end = elapsed + segments[0].duration; + while (position < segments.size()) { + while (elapsed >= end && ++position < segments.size()) end += segments[position].duration; + if (position == segments.size()) break; + decoder.sample(segments[position].keyed ? -60.0f : -120.0f, elapsed); + elapsed += cadence; + } + const uint32_t finish = elapsed + mesh::MorseTx::unitMillis(wpm) * 8U; + for (; elapsed < finish; elapsed += cadence) decoder.sample(-120.0f, elapsed); + assert(decoder.active() && !decoder.truncated()); + decoder.stop(elapsed); + learned = decoder.unitMillis(); + return decoder.text(); +} + +int main() { + const struct { const char* name; Style style; } styles[] = { + {"standard", Style::Standard}, {"jitter", Style::Jitter}, + {"slowing", Style::Slowing}, {"speeding", Style::Speeding}, + {"mixed", Style::Mixed}, {"farnsworth_1_5", Style::Farnsworth}, + {"farnsworth_2_0", Style::WideFarnsworth} + }; + unsigned failures = 0; + for (uint8_t wpm : {5, 10, 15, 20, 25, 30, 40}) { + for (uint32_t cadence : {1U, 2U}) { + for (const auto& style : styles) { + uint16_t learned = 0; + const auto actual = run(wpm, style.style, cadence, learned); + // The final quiet word gap intentionally leaves one trailing space. + // Compare every captured byte; do not normalize extra spaces or noise. + const bool exact = actual == "CQ DE TEST 73 SOS "; + printf("%u\t%u\t%s\t%u\t%s\t%s\n", wpm, cadence, style.name, learned, + exact ? "PASS" : "FAIL", actual.c_str()); +#ifdef REQUIRE_HUMAN_PASS + if (!exact) ++failures; +#endif + } + } + } + return failures ? 1 : 0; +} diff --git a/test/fixtures/morse_rx_timing.cpp b/test/fixtures/morse_rx_timing.cpp new file mode 100644 index 000000000..9c4956870 --- /dev/null +++ b/test/fixtures/morse_rx_timing.cpp @@ -0,0 +1,175 @@ +#include +#include +#include +#include +#include +#include +#include +// Exercise the actual timing methods independently of RF threshold acquisition. +// Production gains no test-only public API or state. +#define private public +#include +#undef private + +using mesh::MorseRx; +static_assert(std::is_same::value, + "fractional timing must reuse the 16-bit timing field"); +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +static_assert(sizeof(MorseRx) == 4168 + sizeof(void*), "HIL decoder size changed"); +#else +static_assert(sizeof(MorseRx) == 4168, "production decoder must remain 4168 bytes"); +#endif + +static void begin(MorseRx& rx, uint8_t wpm, uint32_t now = 100) { + assert(rx.start(wpm, 300000, now) == mesh::MorseRxResult::Started); + assert(rx._unit_q5 == uint32_t(rx._nominal_unit) * 32); + assert(rx.unitMillis() == rx._nominal_unit); +} + +static void mark(MorseRx& rx, uint32_t duration) { + rx.recordMark(duration); + assert(!rx._bad_pattern && rx._symbols == 1); + rx.flushLetter(); +} + +static void fractional_drift_and_integer_negative_control() { + for (uint8_t wpm : {1, 25, 60, 120, 200, 240}) { + for (int difference : {-1, 1}) { + for (bool dash : {false, true}) { + MorseRx rx; + begin(rx, wpm); + const uint16_t nominal = rx._nominal_unit; + const uint16_t observed = uint16_t(int(nominal) + difference); + uint16_t old_unit = nominal; + for (unsigned count = 0; count < 64; ++count) { + old_unit = uint16_t((uint32_t(old_unit) * 7 + observed + 4) / 8); + mark(rx, uint32_t(observed) * (dash ? 3 : 1)); + } + assert(old_unit == nominal); // the former whole-ms update never moves + assert(rx.unitMillis() == observed); + const int error = int(rx._unit_q5) - int(observed) * 32; + assert(error >= -4 && error <= 4); + mesh::MorseRxDiagnostics stats; + rx.diagnostics(stats); + assert(stats.unit_ms == observed); // API still reports rounded ms + } + } + } +} + +static void first_ambiguous_and_warmed_borderline_marks() { + MorseRx first; + begin(first, 240); + first.recordMark(10); + first.flushLetter(); + assert(std::strcmp(first.text(), "T") == 0); // no history can identify this first ambiguous mark + + MorseRx trained; + begin(trained, 240); + mark(trained, 6); + assert(trained._unit_q5 == 164 && trained.unitMillis() == 5); // 5.125 ms, not 5 ms + trained.clear(); // retain timing, clear only the already captured training E + trained.recordMark(10); + trained.flushLetter(); + assert(std::strcmp(trained.text(), "E") == 0); + assert(trained._unit_q5 == 168); // observed 10 is still clamped to the existing 6-ms upper limit +} + +static void unchanged_bounds_and_checked_duration_limits() { + for (uint8_t wpm : {1, 25, 60, 120, 200, 240}) { + MorseRx rx; + begin(rx, wpm); + const uint16_t nominal = rx._nominal_unit; + const uint32_t lower = uint32_t(nominal) * 3 / 4; + const uint32_t upper = uint32_t(nominal) * 5 / 4; + for (unsigned count = 0; count < 64; ++count) { + mark(rx, 3 * (upper + 1)); + assert(rx._unit_q5 >= lower * 32 && rx._unit_q5 <= upper * 32); + } + assert(rx.unitMillis() == upper); + for (unsigned count = 0; count < 96; ++count) { + mark(rx, nominal / 2); + assert(rx._unit_q5 >= lower * 32 && rx._unit_q5 <= upper * 32); + } + assert(rx.unitMillis() == lower); + begin(rx, wpm); + const uint16_t before = rx._unit_q5; + rx.recordMark(uint32_t(nominal) * 7); + assert(rx._bad_pattern && rx._symbols == 0 && rx._unit_q5 == before); + rx.clear(); + rx.recordMark(nominal / 4); + assert(rx._symbols == 0 && rx._unit_q5 == before); // rejected sub-third-dot must not train timing + } +} + +static void fractional_gap_comparisons() { + MorseRx rx; + begin(rx, 240); + rx._unit_q5 = 132; // 4.125 ms: faster than the nominal five-ms unit + rx._last_edge = 100; + rx._code = 2; + rx._symbols = 1; + rx.flushGap(109); + assert(rx.length() == 0); // nine ms is still shorter than 2.25 fractional units + rx.flushGap(110); + assert(std::strcmp(rx.text(), "E") == 0); + rx.flushGap(122); + assert(std::strcmp(rx.text(), "E") == 0); // 22 ms is shorter than 5.5 fractional units + rx.flushGap(123); + assert(std::strcmp(rx.text(), "E ") == 0); + + begin(rx, 240); + rx._unit_q5 = 192; // a slower learned unit must not inflate the gap thresholds + rx._last_edge = 100; + rx._code = 2; + rx._symbols = 1; + rx.flushGap(111); + assert(rx.length() == 0); + rx.flushGap(112); + assert(std::strcmp(rx.text(), "E") == 0); + rx.flushGap(127); + assert(std::strcmp(rx.text(), "E") == 0); + rx.flushGap(128); + assert(std::strcmp(rx.text(), "E ") == 0); +} + +struct SampledReceiver { + MorseRx rx; + uint32_t now; + explicit SampledReceiver(uint32_t started) : now(started) { + begin(rx, 240, now); + level(-110, 520); + } + void level(float dbm, uint32_t duration) { + for (uint32_t sample = 0; sample < duration; ++sample) rx.sample(dbm, now++); + } +}; + +static void actual_envelope_and_rollover() { + for (uint32_t started : {100U, 0xffffff00U}) { + SampledReceiver first(started); + first.level(-80, 10); + first.level(-110, 40); + first.rx.stop(first.now); + assert(std::strcmp(first.rx.text(), "T ") == 0); + + SampledReceiver trained(started); + trained.level(-80, 6); + trained.level(-110, 20); + assert(trained.rx._unit_q5 == 164); + trained.level(-80, 10); + trained.level(-110, 40); + trained.rx.stop(trained.now); + assert(std::strcmp(trained.rx.text(), "EE ") == 0); + assert(trained.rx._unit_q5 == 168 && !trained.rx.active()); + } +} + +int main() { + fractional_drift_and_integer_negative_control(); + first_ambiguous_and_warmed_borderline_marks(); + unchanged_bounds_and_checked_duration_limits(); + fractional_gap_comparisons(); + actual_envelope_and_rollover(); + std::printf("Q5 timing checks passed; MorseRx size: %zu bytes\n", sizeof(MorseRx)); +} diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py index 1d30a2074..b6df979a8 100644 --- a/test/test_bridge_recovery.py +++ b/test/test_bridge_recovery.py @@ -14,6 +14,8 @@ MOCKS = r''' #include #include #include +#include +#include #define MAX_TRANS_UNIT 255 using portMUX_TYPE=int; #define portMUX_INITIALIZER_UNLOCKED 0 @@ -84,6 +86,7 @@ class Stream { public: std::vector input,output;size_t position=0,write_limit=1000;unsigned reads=0; int available(){return input.size()-position;} + int availableForWrite(){return static_cast(write_limit);} int read(){++reads;return position& frame){ static mesh::Packet packet(uint8_t id,size_t length=4){ mesh::Packet p;p.length=length;memset(p.data,id,sizeof(p.data));return p; } +static std::vector uart_frame(const mesh::Packet& p){ + const uint16_t sum=BridgeBase::fletcher16(p.data,p.length); + std::vector result={0xc0,0x3e,uint8_t(p.length>>8),uint8_t(p.length)}; + result.insert(result.end(),p.data,p.data+p.length); + result.push_back(uint8_t(sum>>8));result.push_back(uint8_t(sum));return result; +} static void complete(ESPNowBridge& bridge,int status=ESP_NOW_SEND_SUCCESS){ const uint8_t mac[6]={255,255,255,255,255,255}; bridge.onDataSent(mac,static_cast(status));bridge.loop(); @@ -172,9 +181,17 @@ int main(){ serial.position=serial.input.size();append(serial,uart_frame(5));mgr.exhausted=true; bridge.loop();assert(bridge._rx_packet_ready);bridge.end();mgr.exhausted=false;bridge.begin(); bridge.loop();assert((mgr.received==std::vector{1,2,4})); // held frame cannot cross restart - auto outgoing=packet(6);serial.write_limit=2;bridge.sendPacket(&outgoing); - assert(!bridge._seen_packets.wasSeen(&outgoing)); // failed admission can retry - serial.write_limit=1000;bridge.sendPacket(&outgoing);assert(bridge._seen_packets.wasSeen(&outgoing)); + auto outgoing=packet(6),waiting=packet(8);serial.write_limit=2;bridge.sendPacket(&outgoing); + assert(bridge._seen_packets.wasSeen(&outgoing)&&bridge._tx_size==10&&bridge._tx_pos==2); + // A short hardware write retains the accepted complete frame. A caller's + // additional packet cannot overwrite or interleave it, and remains retryable. + bridge.sendPacket(&waiting);assert(!bridge._seen_packets.wasSeen(&waiting)); + assert(bridge._tx_size==10&&bridge._tx_pos==4); + serial.write_limit=1000;bridge.loop();assert(serial.output==uart_frame(outgoing)&&bridge._tx_size==0); + bridge.sendPacket(&outgoing);assert(serial.output==uart_frame(outgoing)); // duplicate does not replay the short-written prefix + bridge.sendPacket(&waiting);assert(bridge._seen_packets.wasSeen(&waiting)); + auto expected=uart_frame(outgoing);auto tail=uart_frame(waiting);expected.insert(expected.end(),tail.begin(),tail.end()); + assert(serial.output==expected); // retry preserves byte-exact ordering bridge.allowed=false;auto blocked=packet(7);auto total=serial.output.size(); bridge.sendPacket(&blocked);assert(serial.output.size()==total); bridge.allowed=true;serial.input.clear();serial.position=0; diff --git a/test/test_carrier_wave.py b/test/test_carrier_wave.py index 1f16d749c..d6d906f2a 100644 --- a/test/test_carrier_wave.py +++ b/test/test_carrier_wave.py @@ -127,8 +127,6 @@ struct RadioLibWrapper : mesh::Radio { 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; @@ -187,6 +185,7 @@ struct RadioLibWrapper : mesh::Radio { virtual float getCurrentRSSI() { ++rssi_reads; g_mock_millis+=rssi_latency; return rssi; } bool setTxPower(int8_t dbm); @NATIVE_HOOKS@ +#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) @RX_HOOKS@ @RX_START_HOOKS@ // Explicit stand-in for a proven native-RSSI modem. Concrete production @@ -197,6 +196,7 @@ struct RadioLibWrapper : mesh::Radio { if (!applyParams(freq,morseReceiverBandwidthKHz(),7,5)) return RADIOLIB_ERR_UNKNOWN; return RADIOLIB_ERR_NONE; } +#endif virtual bool supportsRadioDeepInit() const { return deep_supported; } uint8_t beginReconfigure(); void endReconfigure(bool resume) { if (resume) startRecv(); } @@ -236,6 +236,9 @@ struct RadioLibWrapper : mesh::Radio { void onSendFinished() override {} }; @METHODS@ +#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) +// SX1276/DAC declarations are exercised on their actual non-STM targets; +// compact integrated-STM builds do not compile these other native chips. struct CustomSX1276Wrapper : RadioLibWrapper { CustomSX1276Wrapper() { reset_ok=true; } @SX1276_METHODS@ @@ -251,6 +254,7 @@ struct DacCarrier : CustomSX1276Wrapper { void writeGainControl(uint8_t value) { gain=value; } @DAC_METHODS@ }; +#endif static void advance(uint32_t ms) { g_mock_millis+=ms; } int main() { using Result=mesh::RadioParamApplyResult; @@ -472,7 +476,7 @@ def production_harness(): 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) + fields_end = header.index(' // End Morse RX state.',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 ( diff --git a/test/test_morse_hil_timing.py b/test/test_morse_hil_timing.py new file mode 100644 index 000000000..90421d054 --- /dev/null +++ b/test/test_morse_hil_timing.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Execute lab-only device TX timing, command ownership and runner metadata.""" +from pathlib import Path +import argparse +import importlib.util +import sys +import types +import unittest +from unittest.mock import patch + +from test_morse_tx import compile_run, portable_radio_header + +ROOT = Path(__file__).resolve().parents[1] + + +class MorseHilTimingTests(unittest.TestCase): + def test_console_correlates_delayed_echo_response_and_closes_failed_session_safely(self): + serial = types.ModuleType("serial") + serial_tools = types.ModuleType("serial.tools") + serial_tools.list_ports = types.SimpleNamespace(comports=lambda: []) + with patch.dict(sys.modules, {"serial":serial,"serial.tools":serial_tools}): + spec = importlib.util.spec_from_file_location("timed_console", ROOT / "tools/hil/morse_bench.py") + bench = importlib.util.module_from_spec(spec) + spec.loader.exec_module(bench) + class Clock: + now=0 + def monotonic(self): return self.now + class Port: + in_waiting=0 + def __init__(self,clock,chunks): + self.clock=clock; self.chunks=list(chunks); self.writes=[]; self.closed=False + def write(self,data): self.writes.append(data) + def read(self,length): + if self.chunks and self.clock.now >= self.chunks[0][0]: + return self.chunks.pop(0)[1] + self.clock.now += .5 + return b"" + def close(self): self.closed=True + def console(companion,chunks): + clock=Clock(); device=object.__new__(bench.Console) + device.serial_number="verified-test"; device.companion=companion + device.commands=[]; device.failed=False; device.port=Port(clock,chunks) + return device,clock + command="set morse.hil.timing 0,0,0,1" + stale=b"set morse.wpm 25\r\n -> OK - morse.wpm 25\r\n> " + own_echo=(command+"\r\n").encode() + device,clock=console(True,[(0,stale),(4,own_echo),(6,b" -> OK - HIL timing\r\n"),(7,b"> ")]) + with patch.object(bench,"time",clock): + reply=device.command(command) + self.assertEqual(reply,"> "+command+"\r\n -> OK - HIL timing\r\n> ") + self.assertGreaterEqual(clock.now,7) + self.assertNotIn("morse.wpm",reply) + self.assertEqual(device.port.writes,[(command+"\r\n").encode()]) + self.assertFalse(device.failed) + # An infrastructure reply has no owned ASCII prompt; its response must + # still follow this request's exact echo rather than an unrelated line. + device,clock=console(False,[(0,b"old\r\n -> stale\r\n"), + (4,b"get morse\r\n\r\n -> > off;\r\n")]) + with patch.object(bench,"time",clock): + self.assertEqual(device.command("get morse"),"get morse\r\n\r\n -> > off;\r\n") + for chunks in ([(0,stale)],[(0,own_echo+b" -> delayed\r\n")], + [(0,own_echo+b"> ")]): + with self.subTest(chunks=chunks): + device,clock=console(True,chunks) + with patch.object(bench,"time",clock): + with self.assertRaisesRegex(RuntimeError,"Incomplete CLI response"): + device.command(command) + self.assertTrue(device.failed) + device.port.chunks.append((clock.now,b" -> late\r\n> ")) + with self.assertRaisesRegex(RuntimeError,"refusing further"): + device.command("morse stop") + device.close() + self.assertTrue(device.port.closed) + self.assertEqual(device.port.writes,[(command+"\r\n").encode()]) + self.assertTrue(device.commands[-1]["failed"]) + self.assertIn("+00:00",device.commands[-1]["utc"]) + # A complete CLI error is synchronized and can safely be followed up. + device,clock=console(True,[(0,b"set tx -9\r\n -> Error: radio busy\r\n> ")]) + with patch.object(bench,"time",clock): + with self.assertRaisesRegex(RuntimeError,"radio busy"): + device.command("set tx -9") + self.assertFalse(device.failed) + calls=[] + device.command=lambda text,**options: (calls.append((text,options)),"OK")[1] + with patch.object(bench,"time",clock): device.start_when_idle("set tx -9") + self.assertEqual(calls,[("set tx -9",{"timeout":15,"allow_error":True})]) + + def test_actual_scheduler_exact_planner_snapshot_wrap_and_shipping_layout(self): + fixture = (ROOT / "test/fixtures/morse_hil_timing.cpp").read_text(encoding="ascii") + for hil in (0, 1): + with self.subTest(hil=hil): + compile_run(self, fixture, flags=(f"-DMESH_MORSE_HIL={hil}", "-DMESH_MORSE_MAX_WPM=255")) + + def test_shared_cli_bounds_busy_replies_no_persistence_and_disabled_error(self): + prefix = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + prefix = prefix.replace("struct TestRadio : mesh::Radio {", "struct TestRadio : mesh::Radio {\n bool packet=false; bool isReceiving() override { return packet; }") + code = prefix + r''' +int main() { + TestRadio radio; + struct Guard { char reply[157]; uint32_t after=0xaabbccdd; } out; + const auto run=[&](const char* text) { + assert(mesh::handleMorseCommand(&radio,text,out.reply,sizeof(out.reply))); + assert(out.after==0xaabbccdd && strlen(out.reply) jitter=15%; drift=-10%; extraGap=25%; seed=12345; volatile HIL TX only")[1] + self.assertIn("seed=12345",device.set_hil_timing(human)) + self.assertEqual(commands,["set morse.hil.timing 15,-10,25,12345","get morse.hil.timing"]) + device.command=lambda command: "> jitter=0%; drift=0%; extraGap=0%; seed=1" + with self.assertRaisesRegex(RuntimeError,"readback mismatch"): + device.set_hil_timing(human) + device.start_when_idle=lambda command: (_ for _ in ()).throw(RuntimeError("Error: HIL-only")) + self.assertEqual(device.set_hil_timing(),"production image: standard timing only") + with self.assertRaisesRegex(RuntimeError,"HIL-only"): + device.set_hil_timing(human) + device.start_when_idle=lambda command: (_ for _ in ()).throw(RuntimeError("Error: radio busy")) + with self.assertRaisesRegex(RuntimeError,"busy"): + device.set_hil_timing() + device.start_when_idle=lambda command: (_ for _ in ()).throw(RuntimeError("set morse.hil.timing 0,0,0,1: Error: unknown setting: morse.hil.timing")) + self.assertEqual(device.set_hil_timing(),"production image: standard timing only") + with self.assertRaisesRegex(RuntimeError,"unknown setting"): + device.set_hil_timing(human) + device.failed=True + with self.assertRaisesRegex(RuntimeError,"unknown setting"): + device.set_hil_timing() + + def test_actual_trial_records_device_profile_without_relaxing_exact_text(self): + serial = types.ModuleType("serial") + serial_tools = types.ModuleType("serial.tools") + serial_tools.list_ports = types.SimpleNamespace(comports=lambda: []) + with patch.dict(sys.modules, {"serial":serial,"serial.tools":serial_tools}): + spec = importlib.util.spec_from_file_location("human_trial", ROOT / "tools/hil/morse_bench.py") + bench = importlib.util.module_from_spec(spec) + spec.loader.exec_module(bench) + actions=[] + class Device: + def __init__(self,name,decoded="SOS"): + self.serial_number=name; self.decoded=decoded + def command(self,command): + actions.append((self.serial_number,command)) + return "> off;" if command=="get morse" else "OK" + def set_hil_timing(self,timing): + actions.append((self.serial_number,"device timing",timing)) + return "> jitter=15%; drift=10%; extraGap=25%; seed=12345" + def start_when_idle(self,command): return self.command(command) + def capture(self): return self.decoded + human=bench.parse_human_timing("15,10,25,12345") + with patch.object(bench.time,"sleep"): + result=bench.trial(Device("tx"),Device("rx"," SOS "),25,"SOS",human_timing=human) + self.assertTrue(result["passed"]) + self.assertEqual(result["human_timing"],human) + self.assertIn("seed=12345",result["tx_timing_status"]) + self.assertLess(actions.index(("tx","device timing",human)),actions.index(("tx","morse send SOS"))) + for decoded in ("E SOS", "SOS E", "S OS", "SO?"): + with self.subTest(decoded=decoded),patch.object(bench.time,"sleep"): + result=bench.trial(Device("tx"),Device("rx",decoded),25,"SOS",human_timing=human) + self.assertFalse(result["passed"]) + self.assertEqual(result["decoded"],decoded) + class Output: + text=None + def exists(self): return False + def write_text(self,text): self.text=text + output=Output(); opened=[]; supplied=[] + class Console: + def __init__(self,identity): self.commands=[]; opened.append(identity) + def setup(self,frequency): pass + def close(self): pass + def trial(*args,**kwargs): supplied.append(kwargs); return {"passed":True} + with patch.object(bench,"Console",Console),patch.object(bench,"trial",trial), \ + patch.object(bench,"Path",lambda path:output),patch("builtins.print"), \ + patch.object(sys,"argv",["bench","--output","memory","--rates","25","--messages","1","--human-timing"]): + bench.main() + self.assertEqual(len(opened),2) + self.assertEqual([call["human_timing"] for call in supplied],[human,human]) + import json + self.assertEqual(json.loads(output.text)["human_timing"],human) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_remote.py b/test/test_morse_remote.py index 3a7f607a9..ca0acdc5f 100644 --- a/test/test_morse_remote.py +++ b/test/test_morse_remote.py @@ -120,19 +120,19 @@ class BenchControlTests(unittest.TestCase): 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"> ")): + for command,parts in (("set tempradio 909.5,500,5,5,30",(b"set tempradio 909.5,500,5,5,30\r\n",b" OK\r\n",b"> ")), + ("get morse",(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") + reply=receiver.command(command) 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"]) + self.assertEqual(receiver.port.writes,[(command+"\r\n").encode()]) 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)) + times=iter((0,0,11)) 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") diff --git a/test/test_morse_rx.py b/test/test_morse_rx.py index 46f8e824f..14342853f 100644 --- a/test/test_morse_rx.py +++ b/test/test_morse_rx.py @@ -162,10 +162,36 @@ template struct HasCapturePointer : std::false_type {} template struct HasCapturePointer> : std::true_type {}; template struct HasCaptureAllocator : std::false_type {}; template struct HasCaptureAllocator> : std::true_type {}; +template struct HasCaptureOwner : std::false_type {}; +template struct HasCaptureOwner> : std::true_type {}; +template struct HasCaptureReadErrors : std::false_type {}; +template struct HasCaptureReadErrors> : std::true_type {}; +template struct HasFskState : std::false_type {}; +template struct HasFskState> : std::true_type {}; +template struct HasReceivePreparation : std::false_type {}; +template struct HasReceivePreparation> : std::true_type {}; +template struct HasReceiveStart : std::false_type {}; +template struct HasReceiveStart> : std::true_type {}; +template struct HasReceiveRead : std::false_type {}; +template struct HasReceiveRead> : std::true_type {}; +template struct HasReceiveBandwidth : std::false_type {}; +template struct HasReceiveBandwidth> : 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"); + static_assert(HasCaptureOwner::value==bool(EXPECTED_RX),"disabled RX must exclude ownership state"); + static_assert(HasCaptureReadErrors::value==bool(EXPECTED_RX),"disabled RX must exclude read-error state"); +#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) + constexpr bool expected_native_hooks=true; +#else + constexpr bool expected_native_hooks=false; +#endif + static_assert(HasFskState::value==expected_native_hooks,"compact STM must exclude the unused FSK byte"); + static_assert(HasReceivePreparation::value==expected_native_hooks,"compact STM must exclude native RX preparation"); + static_assert(HasReceiveStart::value==expected_native_hooks,"compact STM must exclude native RX start"); + static_assert(HasReceiveRead::value==expected_native_hooks,"compact STM must exclude native RSSI read"); + static_assert(HasReceiveBandwidth::value==expected_native_hooks,"compact STM must exclude native bandwidth hook"); advance(0); STMReceive w; #if MESH_ENABLE_MORSE_RX @@ -197,11 +223,13 @@ int main() { 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") + for platform, enabled in (("STM32_PLATFORM",0),("STM32_PLATFORM",1),(None,0)): + with self.subTest(platform=platform, explicit_rx_opt_in=bool(enabled)): + flags = ["-Wno-unused-parameter", f"-DEXPECTED_RX={enabled}"] + if platform: + flags.append(f"-D{platform}") + if enabled or not platform: + flags.append(f"-DMESH_ENABLE_MORSE_RX={enabled}") compile_run(self, code, flags=flags, headers={ "Dispatcher.h": portable_radio_header(), "Arduino.h": arduino}) diff --git a/test/test_morse_rx_human.py b/test/test_morse_rx_human.py new file mode 100644 index 000000000..0df8acbb7 --- /dev/null +++ b/test/test_morse_rx_human.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Measure exact-text decoding of plausible manual Morse timing variations.""" +from pathlib import Path +import unittest +from test_morse_tx import compile_run + +ROOT = Path(__file__).resolve().parents[1] + + +class HumanMorseTimingTests(unittest.TestCase): + def test_actual_hil_sender_scheduler_decodes_human_variations_exactly(self): + code = (ROOT / "test/fixtures/morse_rx_hil_human.cpp").read_text(encoding="ascii") + output = compile_run(self, code, flags=("-DMESH_MORSE_HIL=1", "-DMESH_MORSE_MAX_WPM=240", + "-DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=1")) + rows = [line.split("\t") for line in output.splitlines()] + self.assertEqual(len(rows), 154) + for row in rows: + with self.subTest(wpm=row[0], sample_ms=row[1], timing=row[2]): + self.assertEqual(row[3], "PASS") + self.assertEqual(row[4], "CQ DE TEST 73 SOS ") + + def actual_rows(self): + code = (ROOT / "test/fixtures/morse_rx_human.cpp").read_text(encoding="ascii") + output = compile_run(self, code) + rows = [line.split("\t") for line in output.splitlines()] + self.assertEqual(len(rows), 98) + return rows + + def test_actual_decoder_supported_human_timing_is_exact_without_normalization(self): + rows = [row for row in self.actual_rows() if row[2] != "farnsworth_2_0"] + self.assertEqual(len(rows), 84) + failures = [row for row in rows if row[4] != "PASS" or row[5] != "CQ DE TEST 73 SOS "] + self.assertEqual(failures, [], "Strict human timing matrix failures:\n" + + "\n".join("\t".join(row) for row in failures)) + + def test_doubled_character_spacing_is_explicitly_unsupported_not_a_feature_pass(self): + # Without an explicit spacing hint, six dot units can denote either + # a stretched letter gap or a short word gap. Characterize that known + # ambiguity honestly; never count its spaced output as an exact decode. + rows = [row for row in self.actual_rows() if row[2] == "farnsworth_2_0"] + self.assertEqual(len(rows), 14) + for row in rows: + with self.subTest(wpm=row[0], sample_ms=row[1]): + self.assertEqual(row[4], "FAIL") + self.assertEqual(row[5], "C Q D E T E S T 7 3 S O S ") + self.assertNotEqual(row[5], "CQ DE TEST 73 SOS ") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_rx_timing.py b/test/test_morse_rx_timing.py new file mode 100644 index 000000000..8dede9136 --- /dev/null +++ b/test/test_morse_rx_timing.py @@ -0,0 +1,24 @@ +"""Execute the real fractional Morse timing without hardware or PlatformIO.""" +from pathlib import Path +import unittest + +from test_morse_tx import compile_run + +ROOT = Path(__file__).resolve().parents[1] + + +class MorseRxTimingTests(unittest.TestCase): + def test_actual_q5_drift_classification_bounds_fractional_gaps_rollover_and_size(self): + code = (ROOT / "test/fixtures/morse_rx_timing.cpp").read_text(encoding="ascii") + output = compile_run(self, code, flags=("-DMESH_MORSE_MAX_WPM=240", "-DMESH_MORSE_HIL=0")) + self.assertIn("Q5 timing checks passed; MorseRx size: 4168 bytes", output) + + def test_hil_fast_off_keeps_the_same_fractional_timing_and_allocation_layout(self): + code = (ROOT / "test/fixtures/morse_rx_timing.cpp").read_text(encoding="ascii") + output = compile_run(self, code, flags=("-DMESH_MORSE_MAX_WPM=240", "-DMESH_MORSE_HIL=1", + "-DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=1")) + self.assertIn("Q5 timing checks passed", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_tx.py b/test/test_morse_tx.py index 66d584f44..27884eb36 100644 --- a/test/test_morse_tx.py +++ b/test/test_morse_tx.py @@ -41,6 +41,32 @@ def compile_run(testcase, code, includes=(), flags=(), headers=None): class MorseTxTests(unittest.TestCase): + def test_flash_table_preserves_every_byte_of_the_original_itu_lookup(self): + compile_run(self, r''' +#include +#include +static uint8_t original(char c) { + if (c >= 'a' && c <= 'z') c -= '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 (c >= 'A' && c <= 'Z') return letters[c - 'A']; + if (c >= '0' && c <= '9') return digits[c - '0']; + switch (c) { + 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; + } +} +int main() { + for (unsigned byte = 0; byte < 256; ++byte) + assert(mesh::MorseTx::codeFor(char(byte)) == original(char(byte))); +} +''') + 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) diff --git a/tools/hil/morse_bench.py b/tools/hil/morse_bench.py index 68b8f1c4f..8153f7be6 100644 --- a/tools/hil/morse_bench.py +++ b/tools/hil/morse_bench.py @@ -8,6 +8,7 @@ The active LoRa packet profile stays at 909.5 MHz, 500 kHz, SF5, CR5. import argparse import json import math +from datetime import datetime, timezone from pathlib import Path import re import time @@ -18,6 +19,8 @@ SERIALS = {"rak": "9AB3B64C641BA927", "t096": "651F8E496197F882", "pocket": "93DA83ADF7B2092B"} DEFAULT_CW_FREQUENCY = 902.1 PACKET_RADIO = "909.5,500,5,5" +STANDARD_TIMING = {"jitter_percent": 0, "drift_percent": 0, + "extra_gap_percent": 0, "seed": 1} 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", @@ -38,6 +41,17 @@ def parse_cw_frequency(text): return frequency +def parse_human_timing(text): + match = re.fullmatch(r"(\d+),([+-]?\d+),(\d+),(\d+)", text) + if not match: + raise argparse.ArgumentTypeError("Use jitter,drift,extraGap,seed (for example15,10,25,12345)") + jitter, drift, gap, seed = map(int, match.groups()) + if not 0 <= jitter <= 20 or not -20 <= drift <= 20 or not 0 <= gap <= 50 or not 1 <= seed <= 0xffffffff: + raise argparse.ArgumentTypeError("Jitter0-20%, drift-20..20%, extra gap0-50%, seed1..4294967295 only") + return {"jitter_percent": jitter, "drift_percent": drift, + "extra_gap_percent": gap, "seed": seed} + + def live_temporary_profile(reply): """Parse only confirmed live profiles, never saved/pending radio settings.""" fields = r"(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)" @@ -50,6 +64,27 @@ def live_temporary_profile(reply): return float(match[1]), float(match[2]), int(match[3]), int(match[4]) +def matched_console_reply(data, command, companion): + """Require this command's complete echo before its reply and final prompt.""" + text = data.decode("utf-8", errors="replace") + echoes = list(re.finditer(r"(?:^|\r?\n)(?:> )?" + re.escape(command) + r"\r?\n", text)) + if not echoes: + return None + # A timed-out session is never reused, so repeated polling cannot confuse + # an old echo with a new request. Discard complete *different* stale frames. + echo = echoes[-1] + tail = text[echo.end():] + response = re.search(r"(?m)^ (?:-> |ERROR:|OK(?=\r?(?:\n|$)| -))", tail) + if response is None: + return None + ending = re.search(r"\r?\n> " if companion else r"\r?\n", + tail[response.start():]) + if ending is None: + return None + end = echo.end() + response.start() + ending.end() + return text[echo.start():end].lstrip("\r\n") + + class Console: def __init__(self, serial_number, companion=True): matches = [p for p in list_ports.comports() @@ -64,6 +99,7 @@ class Console: self.port.port = matches[0].device self.port.open() self.commands = [] + self.failed = False try: hello = self.read_quiet(1) if companion and "WELCOME" not in hello: @@ -85,25 +121,33 @@ class Console: 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()) + def command(self, text, timeout=10, allow_error=False): + if getattr(self, "failed", False): + raise RuntimeError(("CLI session desynchronized; refusing further commands", self.serial_number, text)) + if "\r" in text or "\n" in text: + raise ValueError("One CLI command per request only") 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"))) + try: + if self.port.in_waiting: + self.read_quiet(.15) + self.port.write((text + "\r\n").encode()) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + data.extend(self.port.read(self.port.in_waiting or 1)) + reply = matched_console_reply(data, text, self.companion) + if reply is not None: + break + else: + raise RuntimeError(("Incomplete CLI response", self.serial_number, text, + data.decode("utf-8", errors="replace"))) + except BaseException as error: + # Do not issue another command after a missing echo/reply/transport + # completion: a late response would otherwise shift every exchange. + self.failed = True + self.commands.append({"command": text, "reply": data.decode("utf-8", errors="replace"), + "failed": True, "error": str(error), + "utc": datetime.now(timezone.utc).isoformat()}) + raise 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)) @@ -116,8 +160,30 @@ class Console: self.start_when_idle("set radio2 off") self.command(f"set morse.freq {cw_frequency:.6f}") self.command("set morse.wpm 25") + self.set_hil_timing() self.apply_live_temporary(PACKET_RADIO) + def set_hil_timing(self, timing=None): + chosen = timing or STANDARD_TIMING + values = tuple(chosen[field] for field in ("jitter_percent", "drift_percent", "extra_gap_percent", "seed")) + command = "set morse.hil.timing " + ",".join(map(str, values)) + try: + self.start_when_idle(command) + except RuntimeError as error: + # A shipping image contains no timing variation state. Any other + # error (including busy/recovery) must not masquerade as standard. + message = str(error) + unsupported = "Error: HIL-only" in message or ( + "morse.hil.timing" in message and re.search(r"\bunknown (?:command|setting)\b", message, re.I)) + if timing is None and unsupported and not getattr(self, "failed", False): + return "production image: standard timing only" + raise + reply = self.command("get morse.hil.timing") + observed = re.search(r"jitter=(\d+)%; drift=(-?\d+)%; extraGap=(\d+)%; seed=(\d+)", reply) + if not observed or tuple(map(int, observed.groups())) != values: + raise RuntimeError(("HIL timing readback mismatch", chosen, reply)) + return reply + def apply_live_temporary(self, profile, timeout=12): expected = tuple(float(value) for value in profile.split(",")) if len(expected) != 4: @@ -136,7 +202,7 @@ class Console: def start_when_idle(self, text): deadline = time.monotonic() + 10 while True: - reply = self.command(text, allow_error=True) + reply = self.command(text, timeout=15, 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)) @@ -191,17 +257,20 @@ class Console: def close(self): try: - self.command("morse stop", allow_error=True) - self.command("set morse.wpm 25", allow_error=True) + if not getattr(self, "failed", False): + self.command("morse stop", allow_error=True) + self.set_hil_timing() + self.command("set morse.wpm 25", allow_error=True) finally: self.port.close() -def trial(tx, rx, wpm, message, seconds=60, trace=False): +def trial(tx, rx, wpm, message, seconds=60, trace=False, human_timing=None): tx.command("morse stop") rx.command("morse stop") tx.command(f"set morse.wpm {wpm}") rx.command(f"set morse.wpm {wpm}") + timing_status = tx.set_hil_timing(human_timing) rx.start_when_idle(f"morse listen {seconds / 60:.4f}") # Establish a real quiet RSSI floor before any carrier transmission. time.sleep(.8) @@ -223,6 +292,7 @@ def trial(tx, rx, wpm, message, seconds=60, trace=False): "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), + "human_timing": human_timing or STANDARD_TIMING, "tx_timing_status": timing_status, "rx_status": rx_status, "rx_stats": stats} if trace: result["trace"] = rx.trace() @@ -242,6 +312,9 @@ def main(): 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("--human-timing", nargs="?", type=parse_human_timing, + const="15,10,25,12345", + help="Device-timed HIL jitter,drift,extraGap,seed; default preset15,10,25,12345") 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"), @@ -259,6 +332,7 @@ def main(): results = {"frequency_mhz": args.cw_frequency, "packet_radio": PACKET_RADIO, "requested_chip_power_dbm": -9, "partner": args.partner, "direction": args.direction, + "human_timing": args.human_timing or STANDARD_TIMING, "scope": "Experimental HIL applications on this pair; not production speed qualification or all radio families.", "trials": [], "finished": False} devices = {} @@ -301,7 +375,10 @@ def main(): 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) + options = {"trace": args.trace} + if args.human_timing is not None: + options["human_timing"] = args.human_timing + result = trial(devices[tx_name], devices[rx_name], rate, message, **options) result["repeat"] = repeat results["trials"].append(result) printable = {key: value for key, value in result.items() if key != "trace"} @@ -312,6 +389,7 @@ def main(): results["finished"] = True except BaseException as error: results["error"] = str(error) + results["error_utc"] = datetime.now(timezone.utc).isoformat() raise finally: if original_gain is not None: