mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 20:22:26 +00:00
Improve Morse timing adaptation and repair CI regression coverage
This commit is contained in:
1 parent
1b2c1cf2b3
commit
b1198b6e3e
20 files changed
+1240
-74
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+108
@@ -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
|
||||
}
|
||||
Vendored
+53
@@ -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;
|
||||
}
|
||||
Vendored
+99
@@ -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;
|
||||
}
|
||||
Vendored
+175
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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})
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user