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.
228 lines
9.9 KiB
C++
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));
|
|
}
|