Files
MeshCore/test/fixtures/morse_rx_decoder.cpp
mikecarper 0be4c7488c Add bounded Morse receive and negotiated RS232 autosleep
Add native narrow-band CW capture, ACK-first remote sessions, retained LoRa-readable pages, checked restoration and transport-safe progress. Add stock-compatible negotiated heartbeat/autosleep and recovery. Include host regressions and identity-bound HIL runners; keep unverified faster tuning HIL-only and document outstanding hardware validation.
2026-10-13 01:24:39 -07:00

228 lines
9.9 KiB
C++

#include <helpers/MorseRx.h>
#include <helpers/MorseTx.h>
#include <cassert>
#include <cstring>
#include <initializer_list>
#include <string>
#include <cstdio>
using mesh::MorseRx;
struct Receiver {
MorseRx decoder;
uint32_t now;
uint16_t unit;
explicit Receiver(uint8_t wpm = 25, uint32_t start = 100, uint32_t duration = 300000)
: now(start), unit(mesh::MorseTx::unitMillis(wpm)) {
assert(decoder.start(wpm, duration, now) == mesh::MorseRxResult::Started);
}
void level(float rssi, uint32_t duration) {
for (uint32_t elapsed = 0; elapsed < duration; ++elapsed)
decoder.sample(rssi, now++);
}
void quiet(uint32_t duration) { level(-110, duration); }
void warmup() { quiet(MorseRx::NoiseLearningMillis + 20); }
void pattern(const char* marks) {
for (size_t i = 0; marks[i]; ++i) {
level(-80, uint32_t(unit) * (marks[i] == '-' ? 3 : 1));
if (marks[i + 1]) quiet(unit);
}
}
void letter_gap() { quiet(uint32_t(unit) * 3); }
void word_gap() { quiet(uint32_t(unit) * 7); }
std::string text() const {
std::string captured(decoder.text(), decoder.length());
while (!captured.empty() && captured.back() == ' ') captured.pop_back();
return captured;
}
};
static void speed_and_gaps() {
for (uint8_t speed : {1, 5, 12, 25, 40, 60}) {
Receiver rx(speed); rx.warmup();
rx.pattern("..."); rx.letter_gap();
rx.pattern("---"); rx.letter_gap();
rx.pattern("..."); rx.word_gap();
assert(rx.text() == "SOS" && !rx.decoder.keyed() && rx.decoder.active());
rx.pattern("."); rx.word_gap();
assert(rx.text() == "SOS E");
rx.decoder.stop(rx.now);
assert(!rx.decoder.active() && rx.text() == "SOS E");
const auto snapshot = rx.text();
rx.level(-80, 10000); rx.quiet(10000);
assert(rx.text() == snapshot); // retained transcript is not a live idle decoder
rx.decoder.clear(); assert(rx.decoder.length() == 0 && !rx.decoder.truncated());
}
}
static void alphabet_and_punctuation() {
const char* marks[] = {".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....",
"..", ".---", "-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.",
"...", "-", "..-", "...-", ".--", "-..-", "-.--", "--..",
"-----", ".----", "..---", "...--", "....-", ".....", "-....", "--...", "---..", "----.",
".-.-.-", "--..--", "..--..", ".----.", "-.-.--", "-..-.", "-.--.", "-.--.-", ".-...",
"---...", "-.-.-.", "-...-", ".-.-.", "-....-", "..--.-", ".-..-.", "...-..-", ".--.-."};
const char expected[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,?'!/()&:;=+-_\"$@";
static_assert(sizeof(expected) - 1 == sizeof(marks) / sizeof(*marks), "test table");
Receiver rx(25); rx.warmup();
for (const char* pattern : marks) { rx.pattern(pattern); rx.letter_gap(); }
rx.word_gap();
assert(rx.text() == expected);
}
static void falling_edge_lag_does_not_merge_letters() {
for (unsigned lag : {4, 8, 12}) {
Receiver rx(25); rx.warmup();
const char* text="MORSE TEST 25";
for (size_t letter=0; text[letter]; ++letter) {
if (text[letter]==' ') continue;
const auto code=mesh::MorseTx::codeFor(text[letter]);
for (uint8_t bit=mesh::MorseTx::symbolCount(code); bit; --bit) {
const unsigned units=code & (1U << (bit-1)) ? 3 : 1;
// A narrow RSSI filter holds the falling envelope high for a fixed
// delay, lengthening marks while shortening their following gap.
rx.level(-80, units*rx.unit+lag);
const unsigned gap=bit>1 ? 1 : (text[letter+1]==' ' || !text[letter+1] ? 7 : 3);
rx.quiet(gap*rx.unit-lag);
}
}
rx.decoder.stop(rx.now);
assert(rx.text()==text);
}
}
static void noise_and_keying_envelope() {
Receiver rx; rx.warmup();
for (unsigned i = 0; i < 10000; ++i) {
// Low floor jitter plus isolated <2ms RF spikes cannot produce letters.
const float signal = i % 317 == 0 ? -70 : -110 + int(i % 3) - 1;
rx.decoder.sample(signal, rx.now++);
}
rx.word_gap(); assert(rx.decoder.length() == 0);
// A slow PA rise/fall and one-millisecond dips must remain one mark.
for (int dot = 0; dot < 3; ++dot) {
rx.level(-108, 1); rx.level(-103, 1); rx.level(-96, 1);
for (unsigned i = 0; i < rx.unit - 6U; ++i)
rx.decoder.sample(i == 15 ? -110 : -80, rx.now++);
rx.level(-96, 1); rx.level(-103, 1); rx.level(-108, 1);
if (dot < 2) rx.quiet(rx.unit);
}
rx.word_gap(); assert(rx.text() == "S");
Receiver silence; silence.warmup(); silence.quiet(20000);
silence.decoder.stop(silence.now); assert(silence.decoder.length() == 0);
for (unsigned duration : {10, 48 * 12}) {
Receiver bad; bad.warmup(); bad.level(-80, duration); bad.word_gap();
bad.decoder.stop(bad.now);
assert(bad.text() == (duration == 10 ? "" : "?")); // never guess a valid dot/dash
}
Receiver diagnostic; diagnostic.warmup();
mesh::MorseRxDiagnostics stats;
diagnostic.decoder.diagnostics(stats);
assert(stats.samples == MorseRx::NoiseLearningMillis + 20 && stats.noise_dbm == -110);
diagnostic.pattern("."); diagnostic.word_gap(); diagnostic.decoder.stop(diagnostic.now);
diagnostic.decoder.diagnostics(stats);
assert(stats.minimum_dbm == -110 && stats.maximum_dbm == -80 && stats.last_dbm == -110);
assert(stats.unit_ms == 48 && stats.read_errors == 0);
const auto samples = stats.samples;
diagnostic.level(NAN, 100); diagnostic.decoder.diagnostics(stats);
assert(stats.samples == samples); // retained statistics do not accrue after capture ends
for(uint32_t started : {100U,0xfffffff0U}) {
Receiver gap(25,started); gap.warmup();
gap.decoder.diagnostics(stats); assert(stats.max_sample_gap_ms==1);
const uint32_t last_valid=gap.now-1;
const auto count=stats.samples;
gap.now+=200; const uint32_t invalid_at=gap.now;
gap.decoder.sample(NAN,gap.now++);
gap.decoder.diagnostics(stats);
assert(stats.samples==count && stats.max_sample_gap_ms==uint32_t(invalid_at-last_valid));
gap.now+=20; const uint32_t resumed=gap.now;
gap.decoder.sample(-110,gap.now++); gap.decoder.diagnostics(stats);
assert(stats.samples==count+1 && stats.max_sample_gap_ms==uint32_t(resumed-last_valid));
gap.quiet(20); gap.decoder.diagnostics(stats);
assert(stats.max_sample_gap_ms==uint32_t(resumed-last_valid));
}
}
static void stop_and_expiry() {
{
Receiver rx; rx.warmup(); rx.pattern("."); rx.quiet(14);
rx.decoder.stop(rx.now); assert(rx.text() == "E");
}
{
Receiver rx; rx.warmup(); rx.pattern("."); rx.quiet(9);
rx.decoder.stop(rx.now); assert(rx.decoder.length()==0); // OFF debounce is still incomplete
}
{
Receiver rx; rx.warmup(); rx.pattern("."); rx.quiet(rx.unit);
rx.level(-80, rx.unit / 2); // stopped in the middle of a multi-mark character
rx.decoder.stop(rx.now); assert(rx.decoder.length() == 0 && !rx.decoder.keyed());
}
{
Receiver rx(25, 0xfffffff0); rx.warmup();
rx.pattern("..."); rx.letter_gap(); rx.pattern("---"); rx.letter_gap();
rx.pattern("..."); rx.word_gap(); assert(rx.text() == "SOS");
rx.now = uint32_t(0xfffffff0 + 300000);
assert(!rx.decoder.service(rx.now));
assert(!rx.decoder.active() && !rx.decoder.keyed() && rx.text() == "SOS");
}
{
Receiver rx(25, 100, 1000); rx.warmup(); rx.level(-80, 1000);
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)}) {
MorseRx invalid;
assert(invalid.start(speed, 1000, 0) == mesh::MorseRxResult::InvalidSpeed);
assert(!invalid.active());
}
for (uint32_t duration : {0U, 999U, 300001U, 0xffffffffU}) {
MorseRx invalid;
assert(invalid.start(25, duration, 0) == mesh::MorseRxResult::InvalidDuration);
assert(!invalid.active());
}
}
static void bounded_capture_and_restart() {
Receiver rx(60); rx.warmup();
for (size_t character = 0; character < MorseRx::CaptureCapacity + 100; ++character) {
// Slightly fast dots remain within the bounded timing adaptation; 55ms
// gaps are full letters even before that adaptation settles. This fills
// 4096 bytes inside the hard five-minute session without a lab WPM override.
rx.level(-80, 15); rx.quiet(55);
}
assert(rx.decoder.truncated() && rx.decoder.length() == MorseRx::CaptureCapacity);
assert(!rx.decoder.active() && !rx.decoder.keyed());
assert(rx.decoder.text()[rx.decoder.length()] == 0);
for (size_t i = 0; i < rx.decoder.length(); ++i) assert(rx.decoder.text()[i] == 'E');
const auto before = rx.text();
rx.decoder.stop(rx.now);
assert(rx.text() == before && rx.decoder.truncated());
std::string reconstructed;
size_t offset = 0;
while (offset < rx.decoder.length()) {
char page[23];
const size_t copied = rx.decoder.read(offset, page, sizeof(page));
assert(copied > 0 && copied < sizeof(page) && page[copied] == 0);
reconstructed += page; offset += copied;
}
assert(reconstructed == std::string(rx.decoder.text(), rx.decoder.length()));
for (size_t offset : {rx.decoder.length(), size_t(0xffffffffU)}) {
char page[2] = {'X', 'Y'};
assert(rx.decoder.read(offset, page, sizeof(page)) == 0 && page[0] == 0 && page[1] == 'Y');
}
char small[2] = {'X', 'Y'};
assert(rx.decoder.read(0, small, 0) == 0 && small[0] == 'X');
assert(rx.decoder.read(0, small, 1) == 0 && small[0] == 0 && small[1] == 'Y');
assert(rx.decoder.read(0, nullptr, 10) == 0);
assert(rx.decoder.start(0, 1000, rx.now) == mesh::MorseRxResult::InvalidSpeed);
assert(rx.text() == before && rx.decoder.truncated() && !rx.decoder.active());
assert(rx.decoder.start(25, 300000, rx.now) == mesh::MorseRxResult::Started);
assert(rx.decoder.length() == 0 && !rx.decoder.truncated()); // only a new valid session clears
}
int main() {
speed_and_gaps(); alphabet_and_punctuation(); noise_and_keying_envelope();
falling_edge_lag_does_not_merge_letters(); stop_and_expiry(); bounded_capture_and_restart();
std::printf("MorseRx size: %zu bytes\n", sizeof(MorseRx));
}