diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 2e59841f4..768c3574e 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -573,6 +573,8 @@ 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_min_speed.py -v + python3 -B test/test_morse_raw_rssi.py -v python3 -B test/test_morse_rx_timing.py -v python3 -B test/test_morse_rx_gap_bias.py -v python3 -B test/test_morse_rx_human.py -v diff --git a/docs/carrier_wave.md b/docs/carrier_wave.md index fb1bd7f15..7054f43d5 100644 --- a/docs/carrier_wave.md +++ b/docs/carrier_wave.md @@ -83,6 +83,11 @@ power envelope into text. This is a separate manual session, not another MeshCore packet channel or an authenticated sender. Nothing decoded is automatically transmitted, relayed or inserted into a channel/DM inbox. +Reception requires a saved timing hint of **15-60 WPM**. The default remains +25 WPM. Slower speeds of 1-14 WPM remain available for transmission; listening +rejects them with a receive-specific error instead of silently changing the +saved setting. Set `morse.wpm` to at least 15 before listening. + | Command | Action | | --- | --- | | `morse listen 1` | Listen for one minute at the saved `morse.freq`, using `morse.wpm` as the timing hint. | @@ -101,6 +106,15 @@ radio at least one second after listening begins. Start with matching WPM on the sender and receiver. Noise, fading, interference and timing mismatch can produce errors; decoded text has no sender identity or integrity check. +Automatic detection retains sustained noise peaks learned during the quiet +interval, after initial receiver settling. It keeps that quiet envelope across +marks and gaps rather than dropping the threshold back toward the average +noise level. There is no fixed per-board RSSI cutoff. A stable quiet floor +retains the six-dB acquisition margin; a noisier environment requires a signal +above its learned excursions. The envelope relaxes gradually when conditions +become quieter. Stop and start listening to run the initial learning interval +again in a new environment. + The receiver uses a continuous native FSK receive filter and live received signal strength (RSSI) to follow the carrier's on/off envelope. It does not transmit or require a packet preamble, sync word, CRC or direct-data pin: diff --git a/docs/usb_logging_watchdog.md b/docs/usb_logging_watchdog.md index 1d0cd1061..969109bf0 100644 --- a/docs/usb_logging_watchdog.md +++ b/docs/usb_logging_watchdog.md @@ -88,11 +88,19 @@ and the USB recovery grace periods. Tiers grow after reset authorization: stats outage cannot shorten a later physical fault's interval. Ten minutes of healthy monitored operation resets a raised tier to one hour. -Native TX-completion events are the progress evidence. Queueing, purging, +Native TX-completion events are progress evidence. On the pinned ESP32 HWCDC +backend, an exclusive check of both the empty software queue and acknowledged +hardware FIFO also completes a pending write: the SDK does not emit another +TX event for the final FIFO acknowledgement. An empty software queue alone +does not establish completion. Queueing, purging, reinitializing, or accepting the optional bounded minute probe is not proof of recovery. The watchdog never waits for a host. USB connection reports, especially HWCDC's host/reader flags, are SDK approximations and do not reliably identify an open TTY process. +HWCDC therefore uses physical host presence with a 100 ms continuous-loss +check rather than its unreliable open-reader heuristic. A brief status dip +does not revoke a valid stats lease. A real USB bus reset invalidates the lease +immediately; sustained host loss and actual TX stalls remain observable. Recovery waits for active Binary USB clients, mOTA/update owners, unsaved contacts, radio tests/calibration/temporary-radio operations, functional CLI diff --git a/src/helpers/MorseCLI.h b/src/helpers/MorseCLI.h index 9962eb5e8..3858a88e7 100644 --- a/src/helpers/MorseCLI.h +++ b/src/helpers/MorseCLI.h @@ -25,7 +25,7 @@ inline MorseAction parseMorseAction(const char* word, size_t length) { inline const char* morseUsageError() { #if MESH_ENABLE_MORSE_RX - return "Error: use morse send | listen | read [offset] | stats | clear | stop"; + return "Error: use morse send | listen (RX >=15 WPM) | read [offset] | stats | clear | stop"; #else return "Error: use morse send | stop"; #endif @@ -104,6 +104,7 @@ inline bool handleMorseReplyCommand(MorseReplyHandoff& handoff, Radio* radio, if (!handoff.remoteCommand() || !listen) return false; float minutes = 0; if (!radio || !radio->supportsMorseRx() || !morseSettingsUsable() + || morseSettings().wpm < MorseRx::MinWpm || !cli::parseDecimalStrict(argument, minutes) || minutes <= 0 || minutes > 5) return false; const uint32_t duration = uint32_t(minutes * 60000.0f + 0.5f); if (duration < 1000 || duration > 300000) return false; @@ -159,19 +160,29 @@ inline void formatMorseCapturePage(Radio* radio, const char* argument, #if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL inline void formatMorseTracePage(Radio* radio, const char* argument, char* reply, size_t capacity) { +#if defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI + if (radio->isMorseRxActive()) { + cli::formatError(reply, capacity, "stop Morse RX before reading raw RSSI"); + return; + } +#endif uint32_t offset = 0; if (*argument && !cli::parseUnsignedIntegerStrict(argument, offset)) { cli::formatError(reply, capacity, "use morse trace [retained entry offset]"); return; } const size_t length = radio->morseTraceLength(); - if (length > 512 || offset > length) { + if (length > MorseRssiTrace::Capacity || offset > length) { snprintf(reply, capacity, "Error: trace offset must be 0-%u", unsigned(length)); return; } // `t` is the absolute total entry count; offset is relative to the retained // oldest sample. A changed t between pages identifies a moving HIL window. +#if defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI + const int prefix = snprintf(reply, capacity, "> morse.hil.raw-rssi-v1 %05u/%05u t=%lu n=----- x=%u q=-0.5 ", +#else const int prefix = snprintf(reply, capacity, "> %03u/%03u t=%lu n=--- x=%u ", +#endif unsigned(offset), unsigned(length), (unsigned long)radio->morseTraceTotal(), unsigned(radio->morseTraceTruncated())); if (prefix < 0 || size_t(prefix) + 1 >= capacity) { @@ -186,8 +197,13 @@ inline void formatMorseTracePage(Radio* radio, const char* argument, return; } char row[32]; +#if defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI + const int bytes = snprintf(row, sizeof(row), "%lu,%u;", + (unsigned long)entry.millis, unsigned(-2.0f * entry.rssi_dbm)); +#else const int bytes = snprintf(row, sizeof(row), "%lu,%d,%u;", (unsigned long)entry.millis, int(entry.rssi_dbm), unsigned(entry.flags)); +#endif if (bytes < 0 || size_t(bytes) >= sizeof(row) || used + size_t(bytes) >= capacity) break; memcpy(reply + used, row, bytes); used += bytes; @@ -198,11 +214,19 @@ inline void formatMorseTracePage(Radio* radio, const char* argument, cli::formatError(reply, capacity, "reply buffer too small for trace entry"); return; } +#if defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI + char following[6]; + if (next >= length) memcpy(following, "END ", 6); + else snprintf(following, sizeof(following), "%05u", unsigned(next)); + char* marker = strstr(reply, "n=-----"); + if (marker) memcpy(marker + 2, following, 5); +#else char following[4]; if (next >= length) memcpy(following, "END", 4); else snprintf(following, sizeof(following), "%03u", unsigned(next)); char* marker = strstr(reply, "n=---"); if (marker) memcpy(marker + 2, following, 3); +#endif } #endif #endif // MESH_ENABLE_MORSE_RX @@ -494,7 +518,7 @@ inline bool handleMorseCommand(Radio* radio, const char* command, case MorseRxResult::Overlap: cli::formatError(reply, capacity, "Morse frequency overlaps radio or radio2; change morse.freq"); break; case MorseRxResult::InvalidSpeed: - snprintf(reply, capacity, "Error: morse.wpm must be %u-%u", unsigned(MorseMinWpm), unsigned(MorseMaxWpm)); break; + snprintf(reply, capacity, "Error: Morse RX needs %u-%u WPM; set morse.wpm", unsigned(MorseRx::MinWpm), unsigned(MorseMaxWpm)); break; case MorseRxResult::InvalidDuration: cli::formatError(reply, capacity, "Morse listening must last 1-300 seconds"); break; default: diff --git a/src/helpers/MorseRawRssiTrace.h b/src/helpers/MorseRawRssiTrace.h new file mode 100644 index 000000000..a37d29166 --- /dev/null +++ b/src/helpers/MorseRawRssiTrace.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#if !defined(MESH_MORSE_HIL_RAW_RSSI) || MESH_MORSE_HIL_RAW_RSSI != 1 \ + || !defined(ESP32_PLATFORM) || !defined(MESH_MORSE_HIL) || !MESH_MORSE_HIL \ + || !defined(MESH_MORSE_HIL_PRODUCTION_RF) || !MESH_MORSE_HIL_PRODUCTION_RF +#error "Raw Morse RSSI requires ESP32 production-equivalent HIL diagnostics" +#endif + +#ifndef MESH_MORSE_HIL_RAW_WINDOW_MS +#define MESH_MORSE_HIL_RAW_WINDOW_MS 60000 +#endif + +namespace mesh { + +struct MorseRawRssiTraceEntry { + uint32_t millis; + float rssi_dbm; + uint8_t flags; +}; + +// Explicit ESP32 diagnostic only. Retain every half-dBm measurement from RX +// entry, including arming and candidates. Freeze the prefix on overflow; never +// overwrite its initial detector history or stream while the radio samples. +class MorseRawRssiTrace { +public: + static constexpr uint32_t WindowMillis = MESH_MORSE_HIL_RAW_WINDOW_MS; + static_assert(WindowMillis >= 30000 && WindowMillis <= 60000, + "Raw Morse trace window must be 30000..60000 milliseconds"); + static constexpr size_t Capacity = WindowMillis / 2 + 1; + enum StopReason : uint8_t { None, Window, Full, Gap, Value }; + void clear() { _length = _total = _last_elapsed = 0; _reason = None; } + size_t length() const { return _length; } + uint32_t total() const { return _total; } + bool truncated() const { return _reason != None; } + StopReason stopReason() const { return _reason; } + bool read(size_t offset, MorseRawRssiTraceEntry& out) const { + if (offset >= _length) return false; + const size_t anchor = offset / AnchorStride; + uint32_t elapsed = _anchors[anchor]; + for (size_t index = anchor * AnchorStride + 1; index <= offset; ++index) + elapsed += delta(_entries[index]); + out = {elapsed, -0.5f * _entries[offset].raw_rssi, 0}; + return true; + } + void sample(float rssi, uint32_t elapsed, uint8_t flags) { + (void)flags; + ++_total; + if (_reason != None) return; + if (elapsed > WindowMillis) { _reason = Window; return; } + if (_length == Capacity) { _reason = Full; return; } + const uint32_t dt = _length ? elapsed - _last_elapsed : 0; + if (dt > UINT16_MAX) { _reason = Gap; return; } + // SX126x GetRssiInst is exactly -0.5 * one unsigned byte. Reject an + // unrepresentable value instead of silently rounding a different backend. + if (!(rssi >= -127.5f && rssi <= 0.0f)) { _reason = Value; return; } + const float raw = -2.0f * rssi; + if (raw != static_cast(raw)) { _reason = Value; return; } + if (_length % AnchorStride == 0) _anchors[_length / AnchorStride] = elapsed; + _entries[_length++] = {uint8_t(dt), uint8_t(dt >> 8), uint8_t(raw)}; + _last_elapsed = elapsed; + } +private: + static constexpr size_t AnchorStride = 256; + struct StoredEntry { uint8_t delta_low, delta_high, raw_rssi; }; + static_assert(sizeof(StoredEntry) == 3, "Raw Morse entries must remain three bytes"); + static uint16_t delta(const StoredEntry& entry) { + return uint16_t(entry.delta_low) | uint16_t(uint16_t(entry.delta_high) << 8); + } + StoredEntry _entries[Capacity]; + uint32_t _anchors[(Capacity + AnchorStride - 1) / AnchorStride]; + uint32_t _length = 0, _total = 0, _last_elapsed = 0; + StopReason _reason = None; +}; + +} // namespace mesh diff --git a/src/helpers/MorseRx.h b/src/helpers/MorseRx.h index 859646be3..f86e63b2a 100644 --- a/src/helpers/MorseRx.h +++ b/src/helpers/MorseRx.h @@ -21,6 +21,15 @@ #error "HIL Morse fast OFF debounce flag must be 0 or 1" #endif +#if defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI +#if MESH_MORSE_HIL_RAW_RSSI != 1 || !defined(ESP32_PLATFORM) \ + || !defined(MESH_MORSE_HIL) || !MESH_MORSE_HIL \ + || !defined(MESH_MORSE_HIL_PRODUCTION_RF) || !MESH_MORSE_HIL_PRODUCTION_RF +#error "Raw Morse RSSI requires ESP32 production-equivalent HIL diagnostics" +#endif +#include "MorseRawRssiTrace.h" +#endif + namespace mesh { #if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL @@ -53,6 +62,10 @@ struct MorseRxDiagnostics { }; #if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +#if defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI +using MorseRssiTraceEntry = MorseRawRssiTraceEntry; +using MorseRssiTrace = MorseRawRssiTrace; +#else struct MorseRssiTraceEntry { uint32_t millis; int8_t rssi_dbm; @@ -124,6 +137,7 @@ private: bool _triggered = false, _dropped = false; }; #endif +#endif // RSSI envelope decoding, not an RF demodulator or an artificial narrow // filter. The real receiver supplies its instantaneous power measurements. @@ -150,6 +164,7 @@ public: "Production-equivalent HIL requires the shipping Morse receiver clock"); #endif static constexpr uint32_t MinDurationMillis = 1000UL; + static constexpr uint8_t MinWpm = 15; static constexpr uint32_t MaxDurationMillis = 300000UL; static constexpr uint32_t NoiseLearningMillis = 500UL; static constexpr size_t CaptureCapacity = 4096; @@ -160,7 +175,7 @@ public: #endif static MorseRxResult validate(uint8_t wpm, uint32_t duration_ms) { - if (!MorseTx::unitMillis(wpm)) return MorseRxResult::InvalidSpeed; + if (wpm < MinWpm || !MorseTx::unitMillis(wpm)) return MorseRxResult::InvalidSpeed; if (duration_ms < MinDurationMillis || duration_ms > MaxDurationMillis) return MorseRxResult::InvalidDuration; return MorseRxResult::Started; @@ -256,6 +271,9 @@ public: void sample(float rssi_dbm, uint32_t now) { if (!service(now) || !(rssi_dbm >= -180.0f && rssi_dbm <= 20.0f)) return; +#if defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI + if (_trace) _trace->sample(rssi_dbm, static_cast(now - _started), 0); +#endif _last_rssi = rssi_dbm; const uint32_t sample_gap = static_cast(now - _last_sample_at); const float filter_gap = sample_gap > 10U ? 10U : sample_gap; @@ -410,7 +428,8 @@ private: #endif void traceSample(float rssi, uint32_t now, bool raw_keyed) { -#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL +#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL \ + && !(defined(MESH_MORSE_HIL_RAW_RSSI) && MESH_MORSE_HIL_RAW_RSSI) if (_trace) _trace->sample(rssi, static_cast(now - _started), (raw_keyed ? 1 : 0) | (_keyed ? 2 : 0) | (_candidate_valid ? 4 : 0) | (learning(now) ? 8 : 0)); diff --git a/test/fixtures/morse_rx_decoder.cpp b/test/fixtures/morse_rx_decoder.cpp index 201a7284b..a5d07ee9e 100644 --- a/test/fixtures/morse_rx_decoder.cpp +++ b/test/fixtures/morse_rx_decoder.cpp @@ -38,7 +38,7 @@ struct Receiver { }; static void speed_and_gaps() { - for (uint8_t speed : {1, 5, 12, 25, 40, 60}) { + for (uint8_t speed : {15, 20, 25, 40, 60}) { Receiver rx(speed); rx.warmup(); rx.pattern("..."); rx.letter_gap(); rx.pattern("---"); rx.letter_gap(); @@ -170,7 +170,7 @@ static void stop_and_expiry() { assert(!rx.decoder.service(rx.now) && !rx.decoder.active()); assert(rx.decoder.length() == 0); // timeout cannot guess an unfinished held carrier } - for (uint8_t speed : {uint8_t(0), uint8_t(mesh::MorseTx::MaxWpm + 1)}) { + for (uint8_t speed : {uint8_t(0), uint8_t(1), uint8_t(14), uint8_t(mesh::MorseTx::MaxWpm + 1)}) { MorseRx invalid; assert(invalid.start(speed, 1000, 0) == mesh::MorseRxResult::InvalidSpeed); assert(!invalid.active()); diff --git a/test/fixtures/morse_rx_floor_step.cpp b/test/fixtures/morse_rx_floor_step.cpp index d1c63786a..d4ba748a1 100644 --- a/test/fixtures/morse_rx_floor_step.cpp +++ b/test/fixtures/morse_rx_floor_step.cpp @@ -50,7 +50,7 @@ int main() { receive("unchanged floor / fading",-127.5f,-34.5f,true); receive("floor step / fading",-104.0f,-34.5f,true); #ifdef MORSE_ESTIMATOR_EXTENDED - for (uint8_t speed : {1,25,60,120,240}) for (uint32_t cadence : {1,2}) { + for (uint8_t speed : {15,25,60,120,240}) for (uint32_t cadence : {1,2}) { receive("floor step / strong",-104.0f,-34.5f,false,speed,cadence); receive("floor step / moderate",-104.0f,-64.0f,false,speed,cadence); receive("floor step / weak6dB",-104.0f,-98.0f,false,speed,cadence); diff --git a/test/fixtures/morse_rx_hil_human.cpp b/test/fixtures/morse_rx_hil_human.cpp index f13188363..31e30030f 100644 --- a/test/fixtures/morse_rx_hil_human.cpp +++ b/test/fixtures/morse_rx_hil_human.cpp @@ -22,7 +22,7 @@ int main() { constexpr const char* message = "CQ DE TEST 73 SOS"; // 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 (uint8_t wpm : {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)); diff --git a/test/fixtures/morse_rx_human.cpp b/test/fixtures/morse_rx_human.cpp index 6d4551f7a..c2ceaf098 100644 --- a/test/fixtures/morse_rx_human.cpp +++ b/test/fixtures/morse_rx_human.cpp @@ -79,7 +79,7 @@ int main() { {"farnsworth_2_0", Style::WideFarnsworth} }; unsigned failures = 0; - for (uint8_t wpm : {5, 10, 15, 20, 25, 30, 40}) { + for (uint8_t wpm : {15, 20, 25, 30, 40}) { for (uint32_t cadence : {1U, 2U}) { for (const auto& style : styles) { uint16_t learned = 0; diff --git a/test/fixtures/morse_rx_mark_fade.cpp b/test/fixtures/morse_rx_mark_fade.cpp index 0c0b658eb..446d8ca9f 100644 --- a/test/fixtures/morse_rx_mark_fade.cpp +++ b/test/fixtures/morse_rx_mark_fade.cpp @@ -43,9 +43,10 @@ static void trial(const char* label,float signal,float quiet,bool vary_mark,bool if(signal-quiet>=6.0f) { assert(correct && !stuck && !false_marks); // The first provisional mark in this deliberately ambiguous33dB-SNR - // envelope can include one discarded sub-dot edge at1/60WPM. Pin that + // envelope can include one discarded sub-dot edge at15/60WPM. Pin that // precise exception; no later mark or other waveform may silently cut. - if(cuts) assert(!strcmp(label,"stable33SNR/12dBfade") && cuts==1 && (wpm==1 || wpm==60)); + if(cuts) assert(!strcmp(label,"stable33SNR/12dBfade") && cuts==1 + && ((wpm==15 && cadence==1) || wpm==60)); } #endif std::printf("%s %uWPM/%lums: required=%u correct=%u cuts=%lu false_marks=%lu stuck=%u text=\"%s\"\n", @@ -60,7 +61,7 @@ int main() { trial("weak6dB/floorstep",-94,-100,false,false,wpm); } #ifdef MORSE_HYBRID_MATRIX - for(uint8_t wpm:{1,25,60,120,240}) for(uint32_t cadence:{1,2}) { + for(uint8_t wpm:{15,25,60,120,240}) for(uint32_t cadence:{1,2}) { trial("strong12dB/noisyfloor",-23,-103,true,true,wpm,cadence); trial("strong12dB/floorstep30",-23,-97.5f,true,true,wpm,cadence); trial("weak6dB/step23.5",-98,-104,false,false,wpm,cadence); diff --git a/test/fixtures/morse_rx_timing.cpp b/test/fixtures/morse_rx_timing.cpp index 3e42cfd7d..0ad0e67d7 100644 --- a/test/fixtures/morse_rx_timing.cpp +++ b/test/fixtures/morse_rx_timing.cpp @@ -33,7 +33,7 @@ static void mark(MorseRx& rx, uint32_t duration) { } static void fractional_drift_and_integer_negative_control() { - for (uint8_t wpm : {1, 25, 60, 120, 200, 240}) { + for (uint8_t wpm : {15, 25, 60, 120, 200, 240}) { for (int difference : {-1, 1}) { for (bool dash : {false, true}) { MorseRx rx; @@ -76,7 +76,7 @@ static void first_ambiguous_and_warmed_borderline_marks() { } static void unchanged_bounds_and_checked_duration_limits() { - for (uint8_t wpm : {1, 25, 60, 120, 200, 240}) { + for (uint8_t wpm : {15, 25, 60, 120, 200, 240}) { MorseRx rx; begin(rx, wpm); const uint16_t nominal = rx._nominal_unit; diff --git a/test/test_morse_raw_rssi.py b/test/test_morse_raw_rssi.py new file mode 100644 index 000000000..306d4d8f5 --- /dev/null +++ b/test/test_morse_raw_rssi.py @@ -0,0 +1,144 @@ +"""Bounded production-RF ESP32 raw RSSI diagnostics, without hardware or PIO.""" +from pathlib import Path +import unittest +from test_morse_tx import compile_run, portable_radio_header +from test_morse_hil_production import GOOD_FLAGS +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] +RAW_FLAGS = GOOD_FLAGS + ["-DESP32_PLATFORM", "-DMESH_MORSE_HIL_RAW_RSSI=1"] + + +class MorseRawRssiTests(unittest.TestCase): + def test_every_sample_including_arming_candidates_rollover_half_db_and_frozen_overflow(self): + compile_run(self, r''' +#include +#include +#include +#include +#include +int main() { + using Trace = mesh::MorseRssiTrace; + static_assert(Trace::Capacity == 30001, "60s shipping-cadence prefix"); + static_assert(sizeof(Trace) < 91000, "raw capture must remain admitted and bounded"); + auto trace = std::unique_ptr(new Trace); + mesh::MorseRx rx; rx.setTrace(trace.get()); + const uint32_t start = 0xfffffff0U; + assert(rx.start(25, 300000, start) == mesh::MorseRxResult::Started); + for (uint32_t elapsed = 0; elapsed <= 60000; elapsed += 2) { + const float rssi = elapsed >= 600 && elapsed < 630 ? -60.5f : (elapsed % 4 ? -110.5f : -110.0f); + rx.sample(rssi, start + elapsed); + assert(trace->length() == elapsed / 2 + 1); + } + mesh::MorseRxDiagnostics stats; rx.diagnostics(stats); + assert(stats.samples == trace->total()); + for (size_t index : {size_t(0),size_t(1),size_t(249),size_t(255),size_t(256),size_t(300),size_t(314),size_t(10000),size_t(30000)}) { + mesh::MorseRssiTraceEntry entry{}; assert(trace->read(index, entry)); + const uint32_t elapsed = uint32_t(index * 2); + const float expected = elapsed >= 600 && elapsed < 630 ? -60.5f : (elapsed % 4 ? -110.5f : -110.0f); + assert(entry.millis == elapsed && entry.rssi_dbm == expected); + } + rx.sample(-100, start + 60002); + assert(trace->truncated() && trace->stopReason() == Trace::Window && trace->total() == 30002); + mesh::MorseRssiTraceEntry retained; assert(trace->read(0, retained) && retained.millis == 0 && retained.rssi_dbm == -110); + trace->clear(); assert(!trace->length() && !trace->total() && !trace->truncated()); + trace->sample(-127.5f, 0, 0); trace->sample(0, 1, 0); + assert(trace->read(0, retained) && retained.rssi_dbm == -127.5f); + assert(trace->read(1, retained) && retained.rssi_dbm == 0); + trace->sample(-100.25f, 2, 0); assert(trace->stopReason() == Trace::Value && trace->length() == 2); + trace->clear(); trace->sample(-100, 10, 0); trace->sample(-100, 9, 0); + assert(trace->stopReason() == Trace::Gap && trace->length() == 1); + trace->clear(); + for (size_t index = 0; index <= Trace::Capacity; ++index) trace->sample(-100, 0, 0); + assert(trace->stopReason() == Trace::Full && trace->length() == Trace::Capacity); + printf("raw trace bytes=%zu; all samples retained or explicitly truncated\n", sizeof(Trace)); +} +''', flags=RAW_FLAGS) + + def test_raw_pages_preserve_half_db_timestamps_five_digit_offsets_and_never_stream_during_rx(self): + first = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + code = first + r''' +#include +struct RawRadio : TestRadio { + std::unique_ptr trace{new mesh::MorseRssiTrace}; + size_t morseTraceLength() const override { return trace->length(); } + uint32_t morseTraceTotal() const override { return trace->total(); } + bool morseTraceTruncated() const override { return trace->truncated(); } + bool readMorseTrace(size_t offset, mesh::MorseRssiTraceEntry& out) const override { return trace->read(offset,out); } +}; +int main() { + RawRadio radio; char reply[158]; + for (uint32_t index = 0; index < 15001; ++index) radio.trace->sample(-100.5f,index*2,0); + radio.rx_active = true; + mesh::formatMorseTracePage(&radio,"0",reply,157); + assert(strstr(reply,"stop Morse RX")); + radio.rx_active = false; memset(reply,'~',sizeof(reply)); + mesh::formatMorseTracePage(&radio,"10000",reply,157); + assert(reply[157] == '~' && strlen(reply) < 157); + assert(strstr(reply,"> morse.hil.raw-rssi-v1 10000/15001")); + assert(strstr(reply,"q=-0.5 20000,201;20002,201;")); + assert(!strstr(reply,"n=-----")); + mesh::formatMorseTracePage(&radio,"15000",reply,157); + assert(strstr(reply,"n=END ") && strstr(reply,"30000,201;")); + mesh::formatMorseTracePage(&radio,"15002",reply,157); assert(strstr(reply,"Error:")); +} +''' + fs = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=RAW_FLAGS + ["-Wno-unused-parameter"], headers={ + "Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs}) + + def test_raw_mode_requires_esp32_hil_and_all_shipping_rf_assertions(self): + fixture = "#include \nint main() {}\n" + for missing in ("-DESP32_PLATFORM", "-DMESH_MORSE_HIL_PRODUCTION_RF=1", "-DMESH_MORSE_HIL=1"): + flags = [flag for flag in RAW_FLAGS if flag != missing] + with self.subTest(missing=missing), self.assertRaisesRegex(AssertionError, "Raw Morse RSSI requires|Production-equivalent HIL requires"): + compile_run(self, fixture, flags=flags) + for flag in ("-DMESH_MORSE_HIL_SAMPLE_INTERVAL_MS=1", "-DMESH_MORSE_HIL_PA_RAMP_US=800"): + name = flag.split("=",1)[0] + "=" + flags = [item for item in RAW_FLAGS if not item.startswith(name)] + [flag] + with self.subTest(changed_rf=flag), self.assertRaisesRegex(AssertionError, "Production-equivalent HIL requires"): + compile_run(self, fixture, flags=flags) + for window in (29999,60001): + with self.subTest(window=window), self.assertRaisesRegex(AssertionError,"Raw Morse trace window must be"): + compile_run(self, fixture, flags=RAW_FLAGS+[f"-DMESH_MORSE_HIL_RAW_WINDOW_MS={window}"]) + + def test_shipping_and_default_hil_keep_their_existing_trace_layouts(self): + compile_run(self, "#include \nint main(){static_assert(sizeof(mesh::MorseRx)==4172,\"shipping layout unchanged\");}\n") + compile_run(self, "#include \nint main(){static_assert(sizeof(mesh::MorseRssiTrace)<2100,\"default HIL stays sparse\");}\n", flags=("-DMESH_MORSE_HIL=1",)) + with self.assertRaisesRegex(AssertionError,"MorseRawRssiTrace"): + compile_run(self,"#include \nint main(){mesh::MorseRawRssiTrace trace;}\n") + + def test_actual_lazy_admission_accounts_for_whole_raw_buffer_and_contiguous_allocation(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text(encoding="utf-8") + code = r''' +#include +#include +static size_t available = 0, largest = 0; +struct { size_t getFreeHeap() const { return available; } } ESP; +constexpr int MALLOC_CAP_8BIT = 1; +size_t heap_caps_get_largest_free_block(int) { return largest; } +struct RadioLibWrapper { + mesh::MorseRx* _morse_rx = nullptr; + mesh::MorseRssiTrace* _morse_trace = nullptr; + bool canAllocateMorseReceiver() const; +}; +''' + method(source,"bool RadioLibWrapper::canAllocateMorseReceiver(") + r''' +int main() { + RadioLibWrapper radio; + const size_t raw = sizeof(mesh::MorseRssiTrace); + const size_t required = sizeof(mesh::MorseRx) + raw + 4096; + assert(raw > 90000); + available = required - 1; largest = raw; assert(!radio.canAllocateMorseReceiver()); + available = required; largest = raw - 1; assert(!radio.canAllocateMorseReceiver()); + largest = raw; assert(radio.canAllocateMorseReceiver()); + radio._morse_rx = reinterpret_cast(1); + radio._morse_trace = reinterpret_cast(1); + available = 4095; largest = 0; assert(!radio.canAllocateMorseReceiver()); + available = 4096; assert(radio.canAllocateMorseReceiver()); +} +''' + compile_run(self, code, flags=RAW_FLAGS) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_rx_human.py b/test/test_morse_rx_human.py index 8dd6acf50..288d59dab 100644 --- a/test/test_morse_rx_human.py +++ b/test/test_morse_rx_human.py @@ -13,7 +13,7 @@ class HumanMorseTimingTests(unittest.TestCase): 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), 176) + self.assertEqual(len(rows), 144) unsupported = 0 for row in rows: with self.subTest(wpm=row[0], sample_ms=row[1], timing=row[2]): @@ -50,12 +50,12 @@ class HumanMorseTimingTests(unittest.TestCase): 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) + self.assertEqual(len(rows), 70) 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) + self.assertEqual(len(rows), 60) 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)) @@ -65,7 +65,7 @@ class HumanMorseTimingTests(unittest.TestCase): # 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) + self.assertEqual(len(rows), 10) for row in rows: with self.subTest(wpm=row[0], sample_ms=row[1]): self.assertEqual(row[4], "FAIL") diff --git a/test/test_morse_rx_min_speed.py b/test/test_morse_rx_min_speed.py new file mode 100644 index 000000000..3a417bbf0 --- /dev/null +++ b/test/test_morse_rx_min_speed.py @@ -0,0 +1,48 @@ +"""Enforce the requested 15 WPM RX floor while retaining slower transmit.""" +from pathlib import Path +import unittest +from test_morse_tx import compile_run, portable_radio_header + +ROOT = Path(__file__).resolve().parents[1] + + +class MorseRxMinimumSpeedTests(unittest.TestCase): + def test_rx_floor_rejects_every_slow_speed_without_changing_tx_or_settings(self): + first = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0] + code = first + r''' +int main() { + static_assert(mesh::MorseRx::MinWpm == 15, "receive floor must remain explicit"); + TestRadio radio; radio.rx_supported = true; + char reply[160]; + for (uint8_t speed = 0; speed < 15; ++speed) { + mesh::MorseRx receiver; + assert(receiver.start(speed, 1000, 0) == mesh::MorseRxResult::InvalidSpeed); + assert(!receiver.active()); + if (!speed) continue; + mesh::MorseTx transmitter; + assert(transmitter.start("E", speed, 0) == mesh::MorseTxResult::Started); + const auto setting = std::string("set morse.wpm ") + std::to_string(speed); + assert(mesh::handleMorseCommand(&radio, setting.c_str(), reply, sizeof(reply))); + assert(mesh::morseSettings().wpm == speed); + radio.capture = "RETAIN"; + assert(mesh::handleMorseCommand(&radio, "morse listen 1", reply, sizeof(reply))); + assert(std::string(reply) == "Error: Morse RX needs 15-60 WPM; set morse.wpm"); + assert(!radio.rx_active && radio.capture == "RETAIN" && mesh::morseSettings().wpm == speed); + mesh::MorseReplyHandoff handoff; uint8_t actor[32] = {1}; handoff.beginCommand(actor); + assert(!mesh::handleMorseReplyCommand(handoff, &radio, "morse listen 1", reply, sizeof(reply), 0)); + assert(!handoff.pending() && !radio.rx_active); + } + assert(mesh::MorseRx::validate(15, 1000) == mesh::MorseRxResult::Started); + assert(mesh::handleMorseCommand(&radio, "set morse.wpm 15", reply, sizeof(reply))); + assert(mesh::handleMorseCommand(&radio, "morse listen 1", reply, sizeof(reply))); + assert(radio.rx_active && radio.speed == 15); + assert(strstr(mesh::morseUsageError(), "RX >=15 WPM")); +} +''' + fs = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii") + compile_run(self, code, flags=("-Wno-unused-parameter",), headers={ + "Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs}) + + +if __name__ == "__main__": + unittest.main()