Improve Morse timing adaptation and repair CI regression coverage

This commit is contained in:
mikecarper committed 2026-10-13 03:02:52 -07:00
1 parent 1b2c1cf2b3
commit b1198b6e3e
20 files changed
+1240 -74

No files matched your search

+3
View File
@@ -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
@@ -1,3 +1,7 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/test-results/morse_rx_hil_2026-10-12/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# 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.
+73 -3
View File
@@ -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;
}
+25 -12
View File
@@ -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);
}
};
+121 -15
View File
@@ -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; }
};
+21 -3
View File
@@ -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<int32_t>(_cw_until - millis());
return remaining > 0 ? static_cast<uint32_t>(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<uint32_t>(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();
+11 -2
View File
@@ -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.
+108
View File
@@ -0,0 +1,108 @@
#include <helpers/MorseTx.h>
#include <cassert>
#include <cstring>
#include <string>
#include <vector>
using mesh::MorseTx;
using Result = mesh::MorseTxResult;
static std::vector<uint32_t> 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<uint32_t> 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<uint32_t> 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<fast.size();index+=2) if(fast[index]!=5) changed_mark=true;
assert(changed_mark); // Fractional jitter must survive at five-ms nominal dots.
assert(mesh::setMorseHilTiming(MorseHilTimingConfig()));
uint32_t duration=0;
assert(MorseTx::validate(std::string(38,'E').c_str(),1,&duration)==Result::Started && duration==178800);
assert(mesh::setMorseHilTiming(MorseHilTimingConfig(0,0,50,1)));
duration=0xaabbccdd;
assert(MorseTx::validate(std::string(38,'E').c_str(),1,&duration)==Result::TooLong);
assert(duration==0xaabbccdd);
assert(!mesh::setMorseHilTiming(MorseHilTimingConfig(21,0,0,1)));
assert(!mesh::setMorseHilTiming(MorseHilTimingConfig(0,21,0,1)));
assert(!mesh::setMorseHilTiming(MorseHilTimingConfig(0,-21,0,1)));
assert(!mesh::setMorseHilTiming(MorseHilTimingConfig(0,0,51,1)));
assert(!mesh::setMorseHilTiming(MorseHilTimingConfig(0,0,0,0)));
assert(mesh::morseHilTiming().extra_gap_percent==50);
MorseTx deadline;
assert(deadline.start("SOS",25,0xffffffe0)==Result::Started);
assert(deadline.advance(deadline.hardDeadline())==MorseTx::Edge::Expired);
assert(!deadline.active() && !deadline.keyed());
assert(mesh::setMorseHilTiming(MorseHilTimingConfig()));
#else
struct ShippingLayout {
char text[MorseTx::MaxTextLength+1];
uint32_t next,hard_end,remaining;
uint16_t unit,phase;
uint8_t character,symbol,code,count;
bool active,keyed;
};
static_assert(sizeof(MorseTx)==sizeof(ShippingLayout),"production TX layout must not grow");
assert(phases("SOS",25,100)==std::vector<uint32_t>({48,48,48,48,48,144,144,48,144,48,144,144,48,48,48,48,48}));
#endif
}
+53
View File
@@ -0,0 +1,53 @@
#include <helpers/MorseRx.h>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#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;
}
+99
View File
@@ -0,0 +1,99 @@
#include <helpers/MorseRx.h>
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
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<Segment> 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<Segment> 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;
}
+175
View File
@@ -0,0 +1,175 @@
#include <helpers/MorseTx.h>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <string>
#include <type_traits>
// Exercise the actual timing methods independently of RF threshold acquisition.
// Production gains no test-only public API or state.
#define private public
#include <helpers/MorseRx.h>
#undef private
using mesh::MorseRx;
static_assert(std::is_same<decltype(MorseRx::_unit_q5), uint16_t>::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));
}
+20 -3
View File
@@ -14,6 +14,8 @@ MOCKS = r'''
#include <vector>
#include <algorithm>
#include <array>
#include <cstdio>
#include <helpers/bridges/RS232Heartbeat.h>
#define MAX_TRANS_UNIT 255
using portMUX_TYPE=int;
#define portMUX_INITIALIZER_UNLOCKED 0
@@ -84,6 +86,7 @@ class Stream {
public:
std::vector<uint8_t> input,output;size_t position=0,write_limit=1000;unsigned reads=0;
int available(){return input.size()-position;}
int availableForWrite(){return static_cast<int>(write_limit);}
int read(){++reads;return position<input.size()?input[position++]:-1;}
size_t write(const uint8_t* bytes,size_t length){
length=std::min(length,write_limit);output.insert(output.end(),bytes,bytes+length);return length;
@@ -127,6 +130,12 @@ static void append(HardwareSerial& serial,const std::vector<uint8_t>& 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<uint8_t> uart_frame(const mesh::Packet& p){
const uint16_t sum=BridgeBase::fletcher16(p.data,p.length);
std::vector<uint8_t> 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<esp_now_send_status_t>(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<uint8_t>{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;
+7 -3
View File
@@ -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 (
+237
View File
@@ -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)<sizeof(out.reply));
return std::string(out.reply);
};
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
assert(run("get morse.hil.timing").find("jitter=0%; drift=0%; extraGap=0%; seed=1")!=std::string::npos);
assert(run("set morse.hil.timing 20,-20,50,4294967295").find("OK -")==0);
assert(run("get morse.hil.timing").find("jitter=20%; drift=-20%; extraGap=50%; seed=4294967295")!=std::string::npos);
assert(mesh::saves==0 && radio.starts==0);
for(const char* invalid : {"", "1,2,3", "1,2,3,4,5", "21,0,0,1", "0,21,0,1", "0,-21,0,1",
"0,0,51,1", "0,0,0,0", "0,0,0,4294967296", "1.5,0,0,1", "1,0,0,1x", "nan,0,0,1"}) {
const auto command=std::string("set morse.hil.timing ")+invalid;
assert(run(command.c_str()).find("Error:")==0);
assert(mesh::morseHilTiming().seed==0xffffffff);
}
for(bool* busy : {&radio.active,&radio.cw,&radio.rx_active,&radio.packet}) {
*busy=true;
assert(run("set morse.hil.timing 0,0,0,1").find("busy")!=std::string::npos);
assert(run("get morse.hil.timing").find("seed=4294967295")!=std::string::npos);
*busy=false;
}
radio.receiving=false;
assert(run("set morse.hil.timing 0,0,0,1").find("busy")!=std::string::npos);
radio.receiving=true;
assert(run("get morse.hil.timing extra").find("Error:")==0);
assert(run("morse.hil.timing 0,0,0,1").find("Error:")==0);
assert(run("set morse.hil.timing 0,0,0,1").find("OK -")==0);
assert(mesh::saves==0 && radio.starts==0 && radio.stops==0);
#else
(void)run;
for(const char* command : {"get morse.hil.timing","set morse.hil.timing 0,0,0,1","morse.hil.timing"}) {
strcpy(out.reply,"unchanged");
assert(!mesh::handleMorseCommand(&radio,command,out.reply,sizeof(out.reply)));
assert(!strcmp(out.reply,"unchanged") && out.after==0xaabbccdd);
}
assert(mesh::saves==0 && radio.starts==0 && radio.stops==0);
#endif
}
'''
fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii")
for hil in (0, 1):
with self.subTest(hil=hil):
compile_run(self, code, flags=("-Wno-unused-parameter", f"-DMESH_MORSE_HIL={hil}"),
headers={"Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock})
def test_bench_human_arguments_readback_and_standard_reset(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_bench", ROOT / "tools/hil/morse_bench.py")
bench = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bench)
human = bench.parse_human_timing("15,-10,25,12345")
self.assertEqual(human, {"jitter_percent":15,"drift_percent":-10,"extra_gap_percent":25,"seed":12345})
for invalid in ("", "1,2,3", "21,0,0,1", "0,21,0,1", "0,0,51,1", "0,0,0,0", "0,0,0,4294967296", "1.5,0,0,1"):
with self.subTest(invalid=invalid), self.assertRaises(argparse.ArgumentTypeError):
bench.parse_human_timing(invalid)
device = object.__new__(bench.Console)
commands=[]
device.start_when_idle=lambda command: commands.append(command)
device.command=lambda command: (commands.append(command),"> 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()
+5 -5
View File
@@ -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")
+33 -5
View File
@@ -162,10 +162,36 @@ template<typename T,typename=void> struct HasCapturePointer : std::false_type {}
template<typename T> struct HasCapturePointer<T,std::void_t<decltype(T::_morse_rx)>> : std::true_type {};
template<typename T,typename=void> struct HasCaptureAllocator : std::false_type {};
template<typename T> struct HasCaptureAllocator<T,std::void_t<decltype(&T::canAllocateMorseReceiver)>> : std::true_type {};
template<typename T,typename=void> struct HasCaptureOwner : std::false_type {};
template<typename T> struct HasCaptureOwner<T,std::void_t<decltype(T::_morse_rx_owned)>> : std::true_type {};
template<typename T,typename=void> struct HasCaptureReadErrors : std::false_type {};
template<typename T> struct HasCaptureReadErrors<T,std::void_t<decltype(T::_morse_rx_read_errors)>> : std::true_type {};
template<typename T,typename=void> struct HasFskState : std::false_type {};
template<typename T> struct HasFskState<T,std::void_t<decltype(T::_morse_rx_fsk_mode)>> : std::true_type {};
template<typename T,typename=void> struct HasReceivePreparation : std::false_type {};
template<typename T> struct HasReceivePreparation<T,std::void_t<decltype(&T::prepareMorseReceiver)>> : std::true_type {};
template<typename T,typename=void> struct HasReceiveStart : std::false_type {};
template<typename T> struct HasReceiveStart<T,std::void_t<decltype(&T::startMorseReceiver)>> : std::true_type {};
template<typename T,typename=void> struct HasReceiveRead : std::false_type {};
template<typename T> struct HasReceiveRead<T,std::void_t<decltype(&T::readMorseRssi)>> : std::true_type {};
template<typename T,typename=void> struct HasReceiveBandwidth : std::false_type {};
template<typename T> struct HasReceiveBandwidth<T,std::void_t<decltype(&T::morseReceiverBandwidthKHz)>> : std::true_type {};
int main() {
static_assert(MESH_ENABLE_MORSE_RX==EXPECTED_RX,"STM32 RX opt-in must be explicit");
static_assert(HasCapturePointer<STMReceive>::value==bool(EXPECTED_RX),"disabled RX must exclude capture metadata");
static_assert(HasCaptureAllocator<STMReceive>::value==bool(EXPECTED_RX),"disabled RX must exclude allocation machinery");
static_assert(HasCaptureOwner<STMReceive>::value==bool(EXPECTED_RX),"disabled RX must exclude ownership state");
static_assert(HasCaptureReadErrors<STMReceive>::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<STMReceive>::value==expected_native_hooks,"compact STM must exclude the unused FSK byte");
static_assert(HasReceivePreparation<STMReceive>::value==expected_native_hooks,"compact STM must exclude native RX preparation");
static_assert(HasReceiveStart<STMReceive>::value==expected_native_hooks,"compact STM must exclude native RX start");
static_assert(HasReceiveRead<STMReceive>::value==expected_native_hooks,"compact STM must exclude native RSSI read");
static_assert(HasReceiveBandwidth<STMReceive>::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 <cstdint>\n#include <cmath>\n#include <cstdio>\n"
"#include <cstdlib>\n#include <cstring>\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})
+50
View File
@@ -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()
+24
View File
@@ -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()
+26
View File
@@ -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 <helpers/MorseTx.h>
#include <cassert>
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)
+101 -23
View File
@@ -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: