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 @@
+
+
View this page on MeshCore Docs.
+
+
# 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: