feat: set Morse receive floor and add bounded RSSI diagnostics

This commit is contained in:
mikecarper committed 2026-10-20 17:44:29 -07:00
1 parent 4e27d5108b
commit 265b71d77a
15 files changed
+358 -20

No files matched your search

+2
View File
@@ -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
+14
View File
@@ -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:
+9 -1
View File
@@ -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
+27 -3
View File
@@ -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 <text> | listen <minutes> | read [offset] | stats | clear | stop";
return "Error: use morse send <text> | listen <minutes> (RX >=15 WPM) | read [offset] | stats | clear | stop";
#else
return "Error: use morse send <text> | 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:
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#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<uint16_t>(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
+21 -2
View File
@@ -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<uint32_t>(now - _started), 0);
#endif
_last_rssi = rssi_dbm;
const uint32_t sample_gap = static_cast<uint32_t>(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<uint32_t>(now - _started),
(raw_keyed ? 1 : 0) | (_keyed ? 2 : 0) | (_candidate_valid ? 4 : 0)
| (learning(now) ? 8 : 0));
+2 -2
View File
@@ -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());
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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;
+4 -3
View File
@@ -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);
+2 -2
View File
@@ -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;
+144
View File
@@ -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 <helpers/MorseRx.h>
#include <cassert>
#include <memory>
#include <cstdio>
#include <initializer_list>
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<Trace>(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 <memory>
struct RawRadio : TestRadio {
std::unique_ptr<mesh::MorseRssiTrace> 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 <helpers/MorseRx.h>\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 <helpers/MorseRx.h>\nint main(){static_assert(sizeof(mesh::MorseRx)==4172,\"shipping layout unchanged\");}\n")
compile_run(self, "#include <helpers/MorseRx.h>\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 <helpers/MorseRx.h>\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 <helpers/MorseRx.h>
#include <cassert>
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<mesh::MorseRx*>(1);
radio._morse_trace = reinterpret_cast<mesh::MorseRssiTrace*>(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()
+4 -4
View File
@@ -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")
+48
View File
@@ -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()