feat: set Morse receive floor and add bounded RSSI diagnostics
This commit is contained in:
1 parent
4e27d5108b
commit
265b71d77a
15 files changed
+358
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Vendored
+2
-2
@@ -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
@@ -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);
|
||||
|
||||
Vendored
+1
-1
@@ -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));
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
Vendored
+4
-3
@@ -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);
|
||||
|
||||
Vendored
+2
-2
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user