Correct Morse human spacing and harden speed qualification
Run Unit Tests / nrf52-companion-smoke (push) Canceled after 0s
Run Unit Tests / nrf52-sensor-smoke (push) Canceled after 0s
Run Unit Tests / esp32-sensor-smoke (push) Canceled after 0s
Run Unit Tests / management-reports (push) Canceled after 0s
Run Unit Tests / stm32-companion-smoke (push) Canceled after 0s
Run Unit Tests / bootloader-version (push) Canceled after 0s
Run Unit Tests / test (push) Canceled after 0s
Run Unit Tests / hosted-doc-links (push) Canceled after 0s
Build and deploy Docs site to GitHub Pages / github-pages (push) Canceled after 0s

This commit is contained in:
mikecarper committed 2026-10-13 05:25:07 -07:00
1 parent 378925638b
commit af08c02a00
23 files changed
+1194 -34

No files matched your search

+4
View File
@@ -338,8 +338,12 @@ jobs:
python3 -B test/test_morse_tx.py -v
python3 -B test/test_morse_rx.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
python3 -B test/test_morse_hil_timing.py -v
python3 -B test/test_morse_hil_production.py -v
python3 -B test/test_morse_rx_squelch.py -v
python3 -B test/test_morse_hil_squelch.py -v
python3 -B test/test_morse_cli.py -v
python3 -B test/test_morse_auth.py -v
python3 -B test/test_morse_reply_handoff.py -v
@@ -166,3 +166,195 @@ remain in progress. Default 25 WPM, public ceiling 60 WPM, radio bandwidth,
and shipping RF filtering have not been changed. Packet control stays at
909.5 MHz / 500 kHz / SF5 / CR5; CW remains separate at 902.100 MHz by default.
Raw results remain in `out/morse-rx/` and the Pi run directory above.
## Controlled speed and human-timing follow-up
These are **conditional laboratory results**, not a promise of error-free
reception on every board or with automatic noise detection. CW stayed at
902.100 MHz, packet control at 909.5 MHz / 500 kHz / SF5 / CR5, and requested
chip TX power at -9 dBm. The Pocket / RAK pair used a volatile **HIL-only
-80 dBm receive floor** against strong received tones. Shipping images do not
contain this receive-floor setting. Earlier automatic-threshold quiet controls
produced false characters on the Pocket and T096; that remains a separate
qualification limitation.
The original 2.25 / 5.5-dot letter / word boundaries failed ordinary uneven
timing even at 25 WPM. With seeded +/-20% phase jitter, gradual -10% duration
drift, and **no extra letter or word spacing**, the first sweep passed only
2/6 messages at each of 25, 60 and 120 WPM. A short, fully retained RF trace
decoded `VW MN DOG` as `VW MNDOG` in all six directions/rate combinations.
All 20 expected marks remained present; the second word gap measured
246 / 246 ms at 25 WPM, 100 / 102 ms at 60, and 54 / 52 ms at 120. The old
word thresholds were 264, 110 and 55 ms respectively.
The receiver now uses the ordinary **2 / 5-dot gap midpoints**, without changing
mark classification, RX allocation, RF filtering or the public speed limit.
The same tight human-timing profile (seed 6789), with two repetitions of all
three mixed alphabet/digit, word and punctuation messages in both directions,
then passed strictly:
| Requested WPM | Pocket to RAK | RAK to Pocket | Total exact messages |
| --- | --- | --- | --- |
| 25 | 6/6 | 6/6 | 12/12 |
| 60 | 6/6 | 6/6 | 12/12 |
| 120 | 6/6 | 6/6 | 12/12 |
Only leading/trailing capture whitespace is removed in the physical harness;
extra internal spaces, noise characters, missing letters and `?` fail the test.
There were no cleanup errors. This establishes zero observed errors in those
36 messages under the stated laboratory conditions, not universal 100%
reliability or a 120-WPM release qualification.
The production-equivalent RF comparison uses cold-XOSC TX, 3400-us PA ramp,
RSSI window 0, two-ms sampling and ordinary OFF debounce. Compile guards and
actual PlatformIO/SCons flag tests prevent faster experimental settings or a
different receiver clock from being mislabeled as shipping RF behavior.
The lab maximum override and diagnostics still make these HIL applications,
not release images.
Requested WPM is not measured RF pace. Four strict 13-word `PARIS` trials per
rate transmitted 643 dot units, omitting the unsent final seven-unit word gap:
| Requested WPM | Host-observed duration | PARIS-equivalent pace |
| --- | --- | --- |
| 60 | 14.354-14.365 seconds | 53.7 WPM |
| 120 | 7.937-7.940 seconds | 97.2 WPM |
The pace includes CLI response and polling overhead; it is not an oscilloscope
measurement of RF edges. It also does not substitute for varied-text testing.
Latest focused verification passed 129/129 test methods. The actual HIL
scheduler / decoder matrix has 175 exact results plus one explicitly
unsupported 240-WPM / two-ms-sampling / 50%-extended-spacing case. The separate
human envelope matrix has 84 exact results plus 14 explicitly unsupported
doubled-character-spacing cases. Unsupported transcripts remain checked and
visible; they are not normalized or counted as exact-decoding passes.
Former-threshold negative controls reproduce the tight-gap failure. The
production receiver remains 4168 bytes, and compact STM32 still links at
228868/229376 flash bytes with its unchanged feature boundary.
Evidence filenames in the directories above:
- `squelch80-production-rf-human-tight-20261013.json` (before gap correction)
- `production-tight-gap-trace-20261013.json` (complete short RF traces)
- `production-tight-gap-fixed-repeat-20261013.json` (36/36 corrected messages)
- `production-pace-baseline-20261013.json` (host-inclusive PARIS pace)
The tested application-only gap-correction packages were:
| Package | SHA-256 |
| --- | --- |
| `RAK_4631_companion_radio_usb-prod-rf-gap-20261013.zip` | `26cc93db4f6a602e24a35828cca79efaef002b13b18192fe763eb5426515ec6b` |
| `Mesh_pocket_companion_radio_usb-prod-rf-gap-20261013.zip` | `9d1f76c1cc42e1d30ca5daa3d30fe30232d426d72b80278cac67d2d98c21a178` |
The T096's earlier image and measurements do not qualify its speed under this
new gap correction. No LR1110 or SX127x physical speed limit is established by
the SX1262 pair. Default 25 WPM and the public 60-WPM ceiling remain unchanged.
The next faster **production-RF** timing buckets did not meet the zero-error
criterion, even with the same HIL receive floor:
| Requested WPM | Standard timing | Tight human timing, two repeats |
| --- | --- | --- |
| 130 | 5/6 | 11/12 |
| 150 | 2/6 | 6/12 |
| 170 | 0/6 | Not repeated in this corrected sweep |
| 200 | 0/6 | Not repeated in this corrected sweep |
At 130 and 150 some failures affected the initial short mark; 150 also had
later body errors. The first RF mark currently begins before the command
reply and the remaining foreground-loop work. That is a possible delay window,
not a measured explanation of all these errors. No unproved transmitter or
mark-classifier adjustment was made to improve the reported score.
Additional boundary evidence:
- `production-gap-fixed-standard-boundary-20261013.json`
- `production-gap-fixed-human-boundary-20261013.json`
A second human-style pattern, using +/-20% jitter, gradual **+10% duration
drift** (slowing down), no extra gaps and seed 12345, passed another 6/6 mixed
messages at each of 25, 60 and 120 WPM in both directions. Across the corrected
speeding-up and slowing-down profiles, that is 18/18 messages at each rate,
54/54 overall, under the same controlled receive threshold.
The subsequent 30-second **automatic-threshold**, no-intentional-TX control
was not clean overall: the RAK captured `E E `, while the Pocket was empty.
This is evidence against an unconditional production zero-error claim; it is
not a diagnosis of the source of the quiet RSSI excursions. The current
shipping speed ceiling remains unchanged.
- `production-gap-fixed-human-slowing-20261013.json`
- `production-gap-fixed-auto-quiet-20261013.json`
Short, fully retained two-second initial-window traces sharpen the initial-mark
finding. At 130 WPM, the failed first `E` measured 18 ms, exactly twice the
nominal nine-ms unit; a successful first `E` measured 16 ms. At 150, the
corresponding failed/successful widths were 16/14 ms against an eight-ms unit.
Those widths were also common among later dots, so these recordings do **not**
show an unusually long first mark. The later learned units were 11/10 ms,
making comparable later dots classify correctly. Two-ms sample phase and the
initial untrained threshold can therefore matter near this boundary. The
trace has no transmitter deadline timestamps and does not prove that CLI work
caused the errors. These partial diagnostic captures are not complete-message
qualification failures or passes.
- `production-initial-mark-trace-20261013.json`
## Faster experimental RF comparison
The corrected fast SX1262 HIL applications use warm-XOSC TX, an 800-us PA
ramp, RSSI window 2, one-ms sampling and the experimental short OFF-confirmation
minimum. These are **not shipping RF settings**. The same -80 dBm HIL receive
floor and exact-message checks were used on the same Pocket / RAK pair.
| Requested WPM | Standard timing, two repeats | Tight human timing |
| --- | --- | --- |
| 120 | Previously all-pass; not repeated in this boundary sweep | 6/6 |
| 150 | Not tested in this boundary sweep | 5/6 |
| 200 | 12/12 | 3/6 |
| 255 | 10/12 | Not tested in this boundary sweep |
The tight human profile is again +/-20% jitter, -10% duration drift, no
added letter/word spacing and seed 6789. At 150, `DOG` became `DKG` in one
direction. At 200, three messages had real body or punctuation errors.
The shortest timing bucket therefore does not qualify as zero-error, and
neither does the higher uneven-keying speed. Integer rounding maps requested
219-255 WPM to the same five-ms dot, so 255 is not 255 measured WPM.
The fast steady-PARIS pace check gives:
| Requested WPM | Host-observed duration | PARIS-equivalent pace | Exact PARIS messages |
| --- | --- | --- | --- |
| 120 | 6.868 seconds | 112.3 WPM | 2/2 |
| 200 | 4.273-4.277 seconds | 180.5 WPM | 2/2 |
| 255 | 3.669 seconds | 210.3 WPM | 1/2 |
The failed 255 PARIS capture contained `PANIS` in place of one `PARIS`.
These rates include the same host/CLI overhead as the production-equivalent
pace measurement; pace is calculated only for the 643-unit PARIS payload,
not by applying that reference count to the other test messages.
The strongest observed all-pass points are therefore **200 requested WPM
for machine timing on the fast HIL pair**, and **120 requested WPM for the
tested uneven timing**. Their steady-PARIS references are approximately
181 and 112 WPM respectively. With production-equivalent RF timing, the
tested uneven-timing reference is approximately 97 WPM. None of these
conditional limits resolves the automatic-threshold quiet-control failure
or qualifies another board/radio family. Shipping defaults remain unchanged.
- `fast-gap-fixed-standard-max-20261013.json`
- `fast-gap-fixed-human-boundary-20261013.json`
- `fast-gap-fixed-pace-20261013.json`
| Fast HIL package | SHA-256 |
| --- | --- |
| `RAK_4631_companion_radio_usb-fast-gap-20261013.zip` | `7722ca7a0008c660217535c19e9dd17d5923fc90510f9850b2ce188a6d753951` |
| `Mesh_pocket_companion_radio_usb-fast-gap-20261013.zip` | `8d153c544f19e01d45e178105b704840f855a53cb8fda2044ba102cb19b85521` |
Final cleanup confirmed both selected radios had Morse stopped, configured
25 WPM / 902.100 MHz, default HIL timing and automatic squelch restored.
Their saved primary settings still held an earlier 919.5-MHz lab tuple; those
two test devices were explicitly changed to **909.5 / 500 / SF5 / CR5**, and
that tuple was also confirmed live. No service radio was changed. The pair
remains on the identified fast HIL applications, not shipping images.
+36 -1
View File
@@ -209,10 +209,11 @@ inline bool handleMorseCommand(Radio* radio, const char* command,
const bool speed = length == 9 && !strncmp(key, "morse.wpm", 9);
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
const bool hil_timing = length == 16 && !strncmp(key, "morse.hil.timing", 16);
const bool hil_squelch = length == 17 && !strncmp(key, "morse.hil.squelch", 17);
#endif
if (!base && !frequency && !speed
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
&& !hil_timing
&& !hil_timing && !hil_squelch
#endif
) return false;
if (!radio || (!radio->supportsMorseTx()
@@ -225,6 +226,40 @@ inline bool handleMorseCommand(Radio* radio, const char* command,
}
const char* args = cli::skipRecentRepeaterSpaces(key + length);
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
if (hil_squelch) {
#if MESH_ENABLE_MORSE_RX
if (!radio->supportsMorseRx()) {
snprintf(reply, capacity, "Error: Morse RX unsupported on this radio");
return true;
}
if (query) {
if (*args) snprintf(reply, capacity, "Error: unexpected argument");
else if (isnan(morseHilSquelch())) snprintf(reply, capacity, "> auto; volatile HIL RX only");
else snprintf(reply, capacity, "> %.3f dBm; volatile HIL RX only", morseHilSquelch());
return true;
}
if (!setting) {
snprintf(reply, capacity, "Error: use get/set morse.hil.squelch auto|-160..0");
return true;
}
if (radio->isCarrierWaveActive() || radio->isMorseTxActive() || radio->isMorseRxActive()
|| !radio->isInRecvMode() || radio->isReceiving()) {
snprintf(reply, capacity, "Error: radio busy; HIL squelch changes require idle RX");
return true;
}
float value = NAN;
if ((strcmp(args, "auto") && (!cli::parseDecimalStrict(args, value)
|| !isfinite(value))) || !setMorseHilSquelch(value)) {
snprintf(reply, capacity, "Error: use morse.hil.squelch auto or -160..0 dBm");
return true;
}
if (isnan(value)) snprintf(reply, capacity, "OK - HIL Morse squelch auto; volatile RX only");
else snprintf(reply, capacity, "OK - HIL Morse squelch %.3f dBm; volatile RX only", value);
#else
snprintf(reply, capacity, "Error: Morse RX unsupported on this radio");
#endif
return true;
}
if (hil_timing) {
if (query) {
if (*args) snprintf(reply, capacity, "Error: unexpected argument");
+36 -5
View File
@@ -23,6 +23,22 @@
namespace mesh {
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
// Volatile bench squelch, not a saved setting or a production noise model.
// AUTO retains the complete adaptive detector; a finite floor lets controlled
// RF tests reject measured quiet-envelope excursions before they become keys.
inline float& morseHilSquelchStorage() {
static float value = NAN;
return value;
}
inline float morseHilSquelch() { return morseHilSquelchStorage(); }
inline bool setMorseHilSquelch(float value) {
if (!isnan(value) && (!isfinite(value) || value < -160.0f || value > 0.0f)) return false;
morseHilSquelchStorage() = value;
return true;
}
#endif
enum class MorseRxResult : uint8_t {
Started, Stopped, Busy, Unsupported, InvalidFrequency, Overlap,
InvalidSpeed, InvalidDuration, Failed
@@ -129,6 +145,10 @@ public:
#endif
static_assert(ReceiverBitRateKbps >= 0.6f && ReceiverBitRateKbps <= 300.0f,
"HIL GFSK receiver clock must remain in the supported range");
#if defined(MESH_MORSE_HIL_PRODUCTION_RF) && MESH_MORSE_HIL_PRODUCTION_RF
static_assert(ReceiverBitRateKbps == 1.2f,
"Production-equivalent HIL requires the shipping Morse receiver clock");
#endif
static constexpr uint32_t MinDurationMillis = 1000UL;
static constexpr uint32_t MaxDurationMillis = 300000UL;
static constexpr uint32_t NoiseLearningMillis = 500UL;
@@ -267,6 +287,16 @@ public:
if (provisional_fall < 5.0f) provisional_fall = 5.0f;
if (threshold < _signal - provisional_fall) threshold = _signal - provisional_fall;
}
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
const float squelch = morseHilSquelch();
if (isfinite(squelch)) {
// Bound both edges with the same three-dB hysteresis. This prevents a
// quiet pre-key from absorbing the following real dot; nothing is
// removed from, or rewritten in, the decoded transcript afterwards.
const float floor = squelch - (_keyed ? 3.0f : 0.0f);
if (threshold < floor) threshold = floor;
}
#endif
const bool raw_keyed = rssi_dbm >= threshold;
if (!_keyed && !raw_keyed && !_candidate_valid) {
// Drift slowly while quiet; follow a lower floor faster without letting
@@ -402,12 +432,13 @@ private:
// longer physical gaps and remain distinguishable at the nominal timing.
const uint16_t nominal_q5 = uint16_t(uint32_t(_nominal_unit) * UnitScale);
const uint16_t gap_unit_q5 = _unit_q5 < nominal_q5 ? _unit_q5 : nominal_q5;
// Ordinary +/-20% gap jitter still separates an intra-letter gap (<=1.2
// units) from a letter gap (>=2.4), and letters (<=3.6) from words (>=5.6).
// Keep some margin at 2.25/5.5 units rather than merging shortened gaps.
// Use the ordinary 1/3 and 3/7 gap midpoints. +/-20% human jitter leaves
// intra-letter gaps <=1.2, letter gaps >=2.4/<=3.6 and word gaps >=5.6.
// The 2/5 boundaries also leave room for a biased RF falling edge to
// shorten a gap; 2.25/5.5 merged measured ordinary human-timing words.
// This is not automatic Farnsworth-spacing or unknown-speed acquisition.
if (gap * 4UL * UnitScale >= uint32_t(gap_unit_q5) * 9UL && !_letter_flushed) flushLetter();
if (gap * 2UL * UnitScale >= uint32_t(gap_unit_q5) * 11UL && !_word_flushed && _letter_flushed) {
if (gap * UnitScale >= uint32_t(gap_unit_q5) * 2UL && !_letter_flushed) flushLetter();
if (gap * UnitScale >= uint32_t(gap_unit_q5) * 5UL && !_word_flushed && _letter_flushed) {
append(' ');
_word_flushed = true;
}
+13
View File
@@ -12,6 +12,19 @@ namespace mesh {
static_assert(MESH_MORSE_MAX_WPM >= 1 && MESH_MORSE_MAX_WPM <= 255,
"Morse WPM must fit the command/API byte");
#if defined(MESH_MORSE_HIL_PRODUCTION_RF) && MESH_MORSE_HIL_PRODUCTION_RF
// Compile-time evidence that a production-equivalent RF comparison did not
// accidentally inherit the faster bench tuple through PlatformIO's flags.
#if !defined(MESH_MORSE_HIL) || !MESH_MORSE_HIL \
|| MESH_MORSE_HIL_WARM_TX != 0 || MESH_MORSE_HIL_RSSI_WINDOW != 0 \
|| MESH_MORSE_HIL_PA_RAMP_US != 3400 \
|| MESH_MORSE_HIL_SAMPLE_INTERVAL_MS != 2 \
|| MESH_MORSE_HIL_FAST_OFF_DEBOUNCE != 0 \
|| MESH_MORSE_HIL_PACKET_GATING != 0
#error "Production-equivalent HIL requires the shipping Morse RF settings"
#endif
#endif
enum class MorseTxResult : uint8_t {
Started, Stopped, Busy, Unsupported, InvalidFrequency, Overlap,
InvalidText, InvalidSpeed, TooLong, Failed
+70
View File
@@ -0,0 +1,70 @@
#include <helpers/MorseRx.h>
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <vector>
static_assert(sizeof(mesh::MorseRx) == 4168, "gap fix must not add receiver memory");
struct Segment { bool keyed; uint32_t duration; };
static uint32_t rounded(double value) { return uint32_t(std::lround(value)); }
static std::vector<Segment> envelope(const char* text, uint32_t nominal) {
// A bounded human-envelope model, not a replay of the sparse physical trace:
// 10% faster pace, 15% shortened gap phases, and an RF OFF-edge delay of
// roughly one tenth of a dot. Marks use +11.1% phase jitter at that pace,
// staying nominal before the same delay lengthens their measured envelope.
// This puts real letter/word quiet below 2.25/5.5 but above the 2/5 midpoints.
const double peer = nominal * 0.9;
const uint32_t bias = rounded(nominal * 0.1);
std::vector<Segment> result;
const size_t length = std::strlen(text);
for (size_t position = 0; position < length; ++position) {
if (text[position] == ' ') continue;
const uint8_t code = mesh::MorseTx::codeFor(text[position]);
assert(code);
const uint8_t symbols = mesh::MorseTx::symbolCount(code);
for (uint8_t bit = symbols; bit; --bit) {
const bool dash = ((code >> (bit - 1)) & 1) != 0;
result.push_back({true, nominal * (dash ? 3U : 1U) + bias});
if (bit > 1) result.push_back({false, nominal - bias});
}
const bool word = position + 1 == length || text[position + 1] == ' ';
result.push_back({false, rounded(peer * (word ? 7.0 : 3.0) * .85) - bias});
}
return result;
}
int main() {
constexpr const char* text = "VW MN DOG";
unsigned mismatches = 0;
for (uint8_t wpm : {25, 60, 120}) {
for (uint32_t cadence : {1U, 2U}) {
mesh::MorseRx receiver;
assert(receiver.start(wpm, 300000, 0) == mesh::MorseRxResult::Started);
uint32_t now = 0;
for (; now < 600; now += cadence) receiver.sample(-120, now);
const auto segments = envelope(text, mesh::MorseTx::unitMillis(wpm));
size_t position = 0;
uint32_t end = now + segments[0].duration;
while (position < segments.size()) {
while (now >= end && ++position < segments.size()) end += segments[position].duration;
if (position == segments.size()) break;
receiver.sample(segments[position].keyed ? -40 : -120, now);
now += cadence;
}
const uint32_t finish = now + mesh::MorseTx::unitMillis(wpm) * 8;
for (; now < finish; now += cadence) receiver.sample(-120, now);
receiver.stop(now);
const bool exact = std::strcmp(receiver.text(), "VW MN DOG ") == 0;
if (!exact) ++mismatches;
std::printf("%u\t%u\t%s\t%s\n", wpm, cadence, exact ? "PASS" : "FAIL", receiver.text());
}
}
#if REQUIRE_TIGHT_PASS
assert(mismatches == 0);
#else
assert(mismatches == 6); // the former boundaries fail every tight waveform
#endif
}
+6 -4
View File
@@ -16,10 +16,10 @@ int main() {
{"speeding20", {0, -20, 0, 2}},
{"slowing20", {0, 20, 0, 2}},
{"spacing50", {0, 0, 50, 3}},
{"mixed_speeding", {15, -10, 25, 6789}}
{"mixed_speeding", {15, -10, 25, 6789}},
{"tight_human", {20, -10, 0, 6789}}
};
constexpr const char* message = "CQ DE TEST 73 SOS";
unsigned failures = 0;
// 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}) {
@@ -44,10 +44,12 @@ int main() {
const bool exact = strcmp(receiver.text(), "CQ DE TEST 73 SOS ") == 0;
printf("%u\t%u\t%s\t%s\t%s\n", wpm, cadence, profile.name,
exact ? "PASS" : "FAIL", receiver.text());
if (!exact) ++failures;
}
}
}
assert(mesh::setMorseHilTiming({}));
return failures ? 1 : 0;
// The Python contract checks every row byte-for-byte, including the one
// explicitly unsupported high-speed spacing/sampling combination. Keeping
// all rows lets it characterize limits without hiding them as feature passes.
return 0;
}
+128
View File
@@ -0,0 +1,128 @@
#include <helpers/MorseRx.h>
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <limits>
using mesh::MorseRx;
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
static_assert(sizeof(MorseRx) == 4168 + sizeof(void*), "squelch must not add decoder fields");
#else
static_assert(sizeof(MorseRx) == 4168, "shipping decoder layout must be unchanged");
#endif
struct Receiver {
MorseRx rx;
uint32_t now = 100;
Receiver() {
assert(rx.start(25, 300000, now) == mesh::MorseRxResult::Started);
level(-120, 520);
}
void level(float dbm, uint32_t ms) {
for (uint32_t sample = 0; sample < ms; ++sample) rx.sample(dbm, now++);
}
void finish() { level(-120, 400); rx.stop(now); }
};
static void unchanged_auto_weak_signal() {
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
assert(mesh::setMorseHilSquelch(NAN));
#endif
Receiver weak;
weak.level(-113, 48); // valid seven-dB CW above this quiet floor
weak.finish();
assert(std::strcmp(weak.rx.text(), "E ") == 0);
}
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
static void setter_range_and_auto() {
assert(std::isnan(mesh::morseHilSquelch())); // default AUTO, no saved setting
for (float value : {-160.0f, -100.0f, 0.0f}) {
assert(mesh::setMorseHilSquelch(value));
assert(mesh::morseHilSquelch() == value);
}
assert(mesh::setMorseHilSquelch(-100));
for (float invalid : {-160.01f, 0.01f,
std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity()}) {
assert(!mesh::setMorseHilSquelch(invalid));
assert(mesh::morseHilSquelch() == -100);
}
assert(mesh::setMorseHilSquelch(NAN));
assert(std::isnan(mesh::morseHilSquelch()));
}
static void quiet_prekey_cannot_absorb_the_real_dot() {
assert(mesh::setMorseHilSquelch(NAN));
Receiver automatic;
automatic.level(-111, 100); // measured-style quiet excursion keys prematurely
assert(automatic.rx.keyed());
automatic.level(-40, 48);
automatic.finish();
assert(std::strcmp(automatic.rx.text(), "T ") == 0); // one false long mark, not a real dash
assert(mesh::setMorseHilSquelch(-100));
Receiver bounded;
bounded.level(-111, 100);
assert(!bounded.rx.keyed() && bounded.rx.length() == 0);
bounded.level(-40, 48);
bounded.finish();
assert(std::strcmp(bounded.rx.text(), "E ") == 0); // reject RF noise before symbol recording
}
static void measured_style_quiet_is_empty_without_transcript_filtering() {
assert(mesh::setMorseHilSquelch(NAN));
Receiver automatic;
for (unsigned pulse = 0; pulse < 8; ++pulse) {
automatic.level(-111, 100);
automatic.level(-120, 320);
}
automatic.finish();
assert(automatic.rx.length() > 0); // negative control proves the fixture crosses AUTO
assert(mesh::setMorseHilSquelch(-100));
Receiver bounded;
for (unsigned sample = 0; sample < 5000; ++sample) {
const float dbm = sample % 109 < 40 ? -106 : (sample % 3 ? -115 : -124);
bounded.level(dbm, 1);
assert(!bounded.rx.keyed() && bounded.rx.length() == 0);
}
bounded.finish();
assert(bounded.rx.length() == 0 && !bounded.rx.truncated());
}
static void falling_floor_has_three_db_hysteresis() {
assert(mesh::setMorseHilSquelch(-100));
Receiver bounded;
bounded.level(-99, 48);
assert(bounded.rx.keyed());
bounded.level(-102, 20); // below the ON floor, but still above its -103 OFF floor
assert(bounded.rx.keyed());
bounded.level(-104, 15);
assert(!bounded.rx.keyed());
bounded.finish();
assert(std::strcmp(bounded.rx.text(), "E ") == 0);
// The bound is deliberately optional: enabling it rejects weaker real CW,
// then AUTO returns the exact existing weak-signal behavior, not a new gate.
Receiver weak;
weak.level(-113, 48);
weak.finish();
assert(weak.rx.length() == 0);
unchanged_auto_weak_signal();
}
#endif
int main() {
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
setter_range_and_auto();
quiet_prekey_cannot_absorb_the_real_dot();
measured_style_quiet_is_empty_without_transcript_filtering();
falling_floor_has_three_db_hysteresis();
#endif
unchanged_auto_weak_signal();
std::puts("actual squelch checks passed");
}
+9 -9
View File
@@ -109,13 +109,13 @@ static void fractional_gap_comparisons() {
rx._last_edge = 100;
rx._code = 2;
rx._symbols = 1;
rx.flushGap(108);
assert(rx.length() == 0); // eight ms is still shorter than two fractional units
rx.flushGap(109);
assert(rx.length() == 0); // nine ms is still shorter than 2.25 fractional units
rx.flushGap(110);
assert(std::strcmp(rx.text(), "E") == 0);
rx.flushGap(122);
assert(std::strcmp(rx.text(), "E") == 0); // 22 ms is shorter than 5.5 fractional units
rx.flushGap(123);
rx.flushGap(120);
assert(std::strcmp(rx.text(), "E") == 0); // 20 ms is shorter than five fractional units
rx.flushGap(121);
assert(std::strcmp(rx.text(), "E ") == 0);
begin(rx, 240);
@@ -123,13 +123,13 @@ static void fractional_gap_comparisons() {
rx._last_edge = 100;
rx._code = 2;
rx._symbols = 1;
rx.flushGap(111);
rx.flushGap(109);
assert(rx.length() == 0);
rx.flushGap(112);
rx.flushGap(110);
assert(std::strcmp(rx.text(), "E") == 0);
rx.flushGap(127);
rx.flushGap(124);
assert(std::strcmp(rx.text(), "E") == 0);
rx.flushGap(128);
rx.flushGap(125);
assert(std::strcmp(rx.text(), "E ") == 0);
}
+24 -4
View File
@@ -7,6 +7,7 @@ import subprocess
import tempfile
import unittest
from test_morse_loop_policy import RADIO as MORSE_RADIO
ROOT = Path(__file__).resolve().parents[1]
@@ -25,10 +26,11 @@ def run_cpp(source, defines=()):
with tempfile.TemporaryDirectory(prefix="mesh-full-sleep-") as directory:
cpp = Path(directory) / "test.cpp"
exe = Path(directory) / "test"
(Path(directory) / "Dispatcher.h").write_text(MORSE_RADIO, encoding="ascii")
cpp.write_text(source)
built = subprocess.run(
[os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra",
"-I", str(ROOT / "src"),
"-I", str(Path(directory)), "-I", str(ROOT / "src"),
*("-D" + flag for flag in defines), str(cpp), "-o", str(exe)],
text=True, capture_output=True)
if built.returncode:
@@ -91,14 +93,14 @@ int main() {
usb_held = function((ROOT / "src/helpers/ESP32Board.h").read_text(),
"bool isUsbSleepHeld()")
start = main.index(" // USB power alone")
end = main.index("#if defined(ESP32) && defined(WIFI_SSID)",
main.index("else if (the_mesh.getNodePrefs()->powersaving_enabled)", start))
end = main.index("#if defined(ESP32) && defined(WIFI_SSID)", start)
branch = main[start:end]
source = r'''
#include <cassert>
#include <cstdint>
#include <helpers/UsbHostSleepPolicy.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#include <helpers/MorseLoopPolicy.h>
static uint32_t now = 1000;
uint32_t millis() { return now; }
using esp_err_t = int;
@@ -127,10 +129,17 @@ struct Board {
bool isRadioTestActive() const { return test; }
} board;
struct Prefs { bool powersaving_enabled = true; } prefs;
struct TestRadio : mesh::Radio {
bool tx = false, rx = false, cw = false;
bool isMorseTxActive() const override { return tx; }
bool isMorseRxActive() const override { return rx; }
bool isCarrierWaveActive() const override { return cw; }
} timing_radio;
struct Mesh {
bool pending = false;
Prefs* getNodePrefs() { return &prefs; }
bool hasPendingWork() const { return pending; }
mesh::Radio* getProfileRadio() { return &timing_radio; }
} the_mesh;
struct Interfaces { bool pending = false; bool hasPendingIO() const { return pending; } } interface_manager;
struct Sensors { bool gps_uart = false; bool gpsUsesSerialUart(uint8_t uart) const { return uart == 1 && gps_uart; } } sensors;
@@ -153,12 +162,23 @@ Control& control() { return controller; }
}
@SUSPENDED@
@READY@
void idle() { @BRANCH@ }
void idle() { const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio()); @BRANCH@ }
template<class T> void blocks(T& flag) {
flag = true; int before = sleeps; idle(); assert(sleeps == before); flag = false;
}
int main() {
idle(); assert(sleeps == 1);
for (bool* active : {&timing_radio.tx, &timing_radio.rx}) {
*active = true;
int before_sleeps = sleeps, before_yields = yields;
idle();
assert(sleeps == before_sleeps && yields == before_yields);
*active = false;
idle(); assert(sleeps == before_sleeps + 1);
}
timing_radio.cw = true;
int before_cw = sleeps; idle(); assert(sleeps == before_cw + 1);
timing_radio.cw = false;
for (unsigned service : {1u, 2u, 4u}) {
companion_wireless.services = service; int before = sleeps; idle(); assert(sleeps == before);
}
+19 -1
View File
@@ -10,6 +10,7 @@ import subprocess
import tempfile
import unittest
from test_morse_loop_policy import RADIO as MORSE_RADIO
ROOT = Path(__file__).resolve().parents[1]
@@ -308,6 +309,7 @@ V4_BUTTON_HARNESS = r'''
#include <helpers/UsbHostSleepPolicy.h>
#include <helpers/ui/MomentaryButton.h>
#include <helpers/ui/DisplayPowerPolicy.h>
#include <helpers/MorseLoopPolicy.h>
#include <array>
#include <stdexcept>
#include <iostream>
@@ -367,6 +369,13 @@ struct ESP32Board : MainBoard {
bool isExternalPowered() const { return false; }
@METHODS@
} board;
struct TestRadio : mesh::Radio {
bool tx = false, rx = false;
bool isMorseTxActive() const override { return tx; }
bool isMorseRxActive() const override { return rx; }
} timing_radio;
struct TestMesh { mesh::Radio* getProfileRadio() { return &timing_radio; } } the_mesh;
TestMesh& activeSensorMesh() { return the_mesh; }
@BUTTON_CONSTRUCTOR@
struct TestDisplay {
mesh::ui::DisplayPowerPrefs prefs;
@@ -402,6 +411,9 @@ int main() {
g_mock_millis = 1000;
g_mock_pin_levels[PIN_USER_BTN] = HIGH;
user_btn.begin();
require(!mesh::morseTimingActive(the_mesh.getProfileRadio()), "idle radio acquired Morse ownership");
timing_radio.tx = true;
require(mesh::morseTimingActive(the_mesh.getProfileRadio()), "TX ownership was not visible to UI");
ui.loop();
require(!display.isOn(), "button-mode screen did not start blank");
require(roleCanPowerSave(), "idle button blocked initial power saving");
@@ -441,11 +453,16 @@ int main() {
require(display.isOn(), "real UITask click did not wake the screen");
require(!user_btn.needsPolling() && roleCanPowerSave(),
"completed gesture permanently disabled power saving");
timing_radio.tx = false;
timing_radio.rx = true;
require(mesh::morseTimingActive(activeSensorMesh().getProfileRadio()), "RX ownership was not visible to UI");
board.sleep(30);
require(sleep_calls == 2, "released button failed to allow sleep again");
g_mock_millis += 15000;
ui.loop();
require(!display.isOn(), "button screen failed to expire normally");
timing_radio.rx = false;
require(!mesh::morseTimingActive(the_mesh.getProfileRadio()), "Morse ownership failed to release");
} catch (const std::exception& error) {
std::cerr << error.what() << '\n'; return 1;
}
@@ -482,6 +499,7 @@ class Esp32UsbSleepTest(unittest.TestCase):
compiler = os.environ.get("CXX", "c++")
self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-v4-button-wake-") as directory:
(Path(directory) / "Dispatcher.h").write_text(MORSE_RADIO, encoding="ascii")
for family in ("heltec_v4", "heltec_v4_r8"):
target = (ROOT / f"variants/{family}/target.cpp").read_text()
constructor = re.search(
@@ -536,7 +554,7 @@ class Esp32UsbSleepTest(unittest.TestCase):
"-DUSER_BTN_MULTICLICK=" +
selected.get("USER_BTN_MULTICLICK", "1"),
"-DPOWEROFF_DELAY=2000",
*flags, "-I", str(ROOT / "test/mocks"),
*flags, "-I", str(Path(directory)), "-I", str(ROOT / "test/mocks"),
"-I", str(ROOT / "src"), str(cpp),
str(ROOT / "src/helpers/ui/MomentaryButton.cpp"),
"-o", str(stem)], capture_output=True, text=True, timeout=60)
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Prove production-equivalent HIL flags survive actual PlatformIO/SCons parsing."""
import ast
import configparser
import importlib.util
import os
from pathlib import Path
import re
import sys
import unittest
from test_morse_tx import compile_run
ROOT = Path(__file__).resolve().parents[1]
RF_DEFAULTS = {
"MESH_MORSE_HIL_WARM_TX": 0,
"MESH_MORSE_HIL_RSSI_WINDOW": 0,
"MESH_MORSE_HIL_PA_RAMP_US": 3400,
"MESH_MORSE_HIL_SAMPLE_INTERVAL_MS": 2,
"MESH_MORSE_HIL_FAST_OFF_DEBOUNCE": 0,
"MESH_MORSE_HIL_PACKET_GATING": 0,
}
GOOD_FLAGS = ["-DMESH_MORSE_HIL=1", "-DMESH_MORSE_HIL_PRODUCTION_RF=1"] + [
f"-D{name}={value}" for name, value in RF_DEFAULTS.items()]
GUARD_MESSAGE = "Production-equivalent HIL requires the shipping Morse RF settings"
FIXTURE = """
#include <helpers/MorseTx.h>
#include <helpers/MorseRx.h>
#include <cassert>
int main() {
static_assert(mesh::MorseRx::SampleIntervalMillis == 2, "shipping sampler must remain two ms");
static_assert(mesh::MorseRx::ReceiverBitRateKbps == 1.2f, "shipping receiver clock must remain unchanged");
mesh::MorseTx sender;
assert(sender.start("SOS",25,0) == mesh::MorseTxResult::Started);
assert(sender.nextDeadline() == 48);
}
"""
def platformio_flag_environment():
"""Import only the real parser helpers: never invoke a PlatformIO process."""
scons_override = os.environ.get("MESH_TEST_SCONS_PATH")
if scons_override:
sys.path.insert(0, scons_override)
elif importlib.util.find_spec("SCons") is None:
core = Path(os.environ.get("PLATFORMIO_CORE_DIR", Path.home() / ".platformio"))
choices = sorted((core / "packages/tool-scons").glob("scons-local-*"))
if not choices:
raise AssertionError("Earlier PlatformIO native CI step must install SCons; set MESH_TEST_SCONS_PATH for another host")
sys.path.insert(0, str(choices[-1]))
import SCons.Environment
import SCons.Node.FS
import SCons.Util
builder_override = os.environ.get("MESH_TEST_PLATFORMIO_BUILDER")
if builder_override:
builder = Path(builder_override)
else:
package = importlib.util.find_spec("platformio")
if package is None:
raise AssertionError("PlatformIO parser package is required; set MESH_TEST_PLATFORMIO_BUILDER for another host")
builder = Path(package.origin).parent / "builder/tools/piobuild.py"
parsed = ast.parse(builder.read_text(encoding="utf-8"))
names = {"ParseFlagsExtended", "ProcessFlags", "ProcessUnFlags"}
selected = [node for node in parsed.body if isinstance(node, ast.FunctionDef) and node.name in names]
if {node.name for node in selected} != names:
raise AssertionError("Installed PlatformIO flag helper contract changed; review the actual implementation")
namespace = {"Util": SCons.Util, "FS": SCons.Node.FS, "os": os, "string_types": (str,)}
exec(compile(ast.Module(body=selected, type_ignores=[]), str(builder), "exec"), namespace)
env = SCons.Environment.Environment(tools=["gcc"])
for name in names:
env.AddMethod(namespace[name], name)
return env
def local_hil_flags(config, section, option, chain=()):
"""Resolve local recipe references; external base recipes contain no HIL flags."""
key = (section, option)
if key in chain:
raise AssertionError("HIL recipe contains a recursive interpolation")
value = config.get(section, option, fallback="")
def expand(match):
referenced, field = match.group(1), match.group(2)
return local_hil_flags(config, referenced, field, chain + (key,)) if config.has_section(referenced) else ""
return re.sub(r"\$\{([^}.]+)\.([^}]+)\}", expand, value)
class ProductionHilFlagTests(unittest.TestCase):
def test_production_guard_rejects_receiver_clock_override_without_fixture_assertion(self):
# The actual header must reject an experimental clock, independently
# of this suite's ordinary fixture assertion or the chosen INI recipe.
fixture = "#include <helpers/MorseRx.h>\nint main() {}\n"
compile_run(self, fixture, flags=GOOD_FLAGS)
compile_run(self, fixture, flags=GOOD_FLAGS + ["-DMESH_MORSE_HIL_RX_BITRATE_KBPS=1.2f"])
for rate in ("0.6f", "20.0f", "300.0f"):
with self.subTest(rate=rate), self.assertRaisesRegex(
AssertionError, "Production-equivalent HIL requires the shipping Morse receiver clock"):
compile_run(self, fixture, flags=GOOD_FLAGS + [f"-DMESH_MORSE_HIL_RX_BITRATE_KBPS={rate}"])
experimental = [flag for flag in GOOD_FLAGS if not flag.startswith("-DMESH_MORSE_HIL_PRODUCTION_RF=")]
fixture = ("#include <helpers/MorseRx.h>\n"
"static_assert(mesh::MorseRx::ReceiverBitRateKbps == 20.0f, \"lab override remains available\");\n"
"int main() {}\n")
compile_run(self, fixture, flags=experimental + ["-DMESH_MORSE_HIL_RX_BITRATE_KBPS=20.0f"])
def test_correct_rf_defaults_compile_and_each_experimental_rf_value_is_rejected(self):
compile_run(self, FIXTURE, flags=GOOD_FLAGS)
for name in RF_DEFAULTS:
bad = 800 if name.endswith("PA_RAMP_US") else 1
if name.endswith("RSSI_WINDOW"):
bad = 2
flags = [flag for flag in GOOD_FLAGS if not flag.startswith(f"-D{name}=")]
flags.append(f"-D{name}={bad}")
with self.subTest(flag=name), self.assertRaisesRegex(AssertionError, GUARD_MESSAGE):
compile_run(self, FIXTURE, flags=flags)
for hil in (None, 0):
flags = [flag for flag in GOOD_FLAGS if not flag.startswith("-DMESH_MORSE_HIL=")]
if hil is not None:
flags.append(f"-DMESH_MORSE_HIL={hil}")
with self.subTest(hil=hil), self.assertRaisesRegex(AssertionError, GUARD_MESSAGE):
compile_run(self, FIXTURE, flags=flags)
def test_actual_scons_inline_undef_is_a_negative_control_not_an_override(self):
env = platformio_flag_environment()
# PlatformIO moves -U after its CPPDEFINES. A textual -U then -D must
# never be accepted as proof of the final production flag values.
env.ProcessFlags(" ".join(GOOD_FLAGS + ["-UMESH_MORSE_HIL_PA_RAMP_US", "-DMESH_MORSE_HIL_PA_RAMP_US=3400"]))
resolved = env.subst("$CCFLAGS $_CPPDEFFLAGS").split()
self.assertGreater(resolved.index("-UMESH_MORSE_HIL_PA_RAMP_US"),
max(i for i, item in enumerate(resolved) if item.startswith("-DMESH_MORSE_HIL_PA_RAMP_US=")))
with self.assertRaisesRegex(AssertionError, GUARD_MESSAGE):
compile_run(self, FIXTURE, flags=resolved)
def test_both_actual_production_recipes_survive_scons_and_compiler_ordering(self):
config = configparser.ConfigParser(interpolation=None)
config.read(ROOT / "tools/hil/morse_bench.ini", encoding="utf-8")
for section in ("env:morse_production_rak4631_companion", "env:morse_production_pocket_companion"):
with self.subTest(recipe=section):
env = platformio_flag_environment()
flags = local_hil_flags(config, section, "build_flags")
unflags = local_hil_flags(config, section, "build_unflags")
env.ProcessFlags(flags)
env.ProcessUnFlags(unflags)
resolved = [flag for flag in env.subst("$CCFLAGS $_CPPDEFFLAGS").split()
if flag.startswith(("-DMESH_MORSE_", "-UMESH_MORSE_"))]
for name, expected in RF_DEFAULTS.items():
self.assertEqual([flag for flag in resolved if flag.startswith(f"-D{name}=")],
[f"-D{name}={expected}"])
self.assertNotIn(f"-U{name}", resolved)
self.assertIn("-DMESH_MORSE_HIL_PRODUCTION_RF=1", resolved)
compile_run(self, FIXTURE, flags=resolved)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -16,9 +16,10 @@ from unittest.mock import Mock, patch
import warnings
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from tools.hil import verified_nrf_flash as guard
ROOT = Path(__file__).resolve().parents[1]
serial_stub = types.ModuleType("serial")
serial_tools_stub = types.ModuleType("serial.tools")
serial_tools_stub.list_ports = types.SimpleNamespace(comports=Mock(return_value=[]))
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Execute actual lab-only receive squelch CLI and identity-bound runner paths."""
from pathlib import Path
import argparse
import importlib.util
import json
import math
import sys
import types
import unittest
from unittest.mock import patch
from test_morse_tx import compile_run, portable_radio_header
ROOT = Path(__file__).resolve().parents[1]
def load_bench():
serial = types.ModuleType("serial")
serial_tools = types.ModuleType("serial.tools")
serial_tools.list_ports = types.SimpleNamespace(comports=lambda: [])
with patch.dict(sys.modules, {"serial": serial, "serial.tools": serial_tools}):
spec = importlib.util.spec_from_file_location("squelch_bench", ROOT / "tools/hil/morse_bench.py")
bench = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bench)
return bench
class MorseHilSquelchTests(unittest.TestCase):
def test_actual_cli_strict_bounds_idle_ownership_volatile_and_shipping_absence(self):
prefix = (ROOT / "test/fixtures/morse_cli.cpp").read_text(encoding="ascii").split("int main() {", 1)[0]
prefix = prefix.replace("struct TestRadio : mesh::Radio {",
"struct TestRadio : mesh::Radio {\n bool packet=false; bool isReceiving() override { return packet; }")
code = prefix + r'''
int main() {
TestRadio radio;
radio.rx_supported=true;
struct Guard { char reply[157]; uint32_t after=0xaabbccdd; } out;
const auto run=[&](const char* text) {
assert(mesh::handleMorseCommand(&radio,text,out.reply,sizeof(out.reply)));
assert(out.after==0xaabbccdd && strlen(out.reply)<sizeof(out.reply));
return std::string(out.reply);
};
#if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL
assert(run("get morse.hil.squelch")=="> auto; volatile HIL RX only");
assert(isnan(mesh::morseHilSquelch()));
for(const char* value : {"-160","0","-100.125"}) {
const auto command=std::string("set morse.hil.squelch ")+value;
assert(run(command.c_str()).find("OK -")==0);
}
assert(fabsf(mesh::morseHilSquelch()+100.125f)<0.0001f);
const float accepted=mesh::morseHilSquelch();
assert(run("get morse.hil.squelch")=="> -100.125 dBm; volatile HIL RX only");
for(const char* invalid : {"", "nan", "NaN", "inf", "-inf", "1", "-160.01", "-999",
"1e2", "-100x", "auto x", "AUTO", "auto,1", "--100", "42949672960"}) {
const auto command=std::string("set morse.hil.squelch ")+invalid;
assert(run(command.c_str()).find("Error:")==0);
assert(mesh::morseHilSquelch()==accepted);
}
for(bool* busy : {&radio.active,&radio.cw,&radio.rx_active,&radio.packet}) {
*busy=true;
assert(run("set morse.hil.squelch auto").find("busy")!=std::string::npos);
assert(run("get morse.hil.squelch").find("-100.125")!=std::string::npos);
*busy=false;
}
radio.receiving=false;
assert(run("set morse.hil.squelch auto").find("busy")!=std::string::npos);
radio.receiving=true;
assert(run("get morse.hil.squelch extra").find("Error:")==0);
assert(run("morse.hil.squelch -100").find("Error:")==0);
assert(run("set morse.hil.squelch auto").find("OK -")==0);
assert(isnan(mesh::morseHilSquelch()));
radio.rx_supported=false;
assert(run("set morse.hil.squelch -100").find("unsupported")!=std::string::npos);
assert(isnan(mesh::morseHilSquelch()));
assert(mesh::saves==0 && radio.starts==0 && radio.stops==0 && radio.rx_starts==0 && radio.rx_stops==0);
#else
(void)run;
for(const char* command : {"get morse.hil.squelch","set morse.hil.squelch -100",
"set morse.hil.squelch auto","morse.hil.squelch"}) {
strcpy(out.reply,"unchanged");
assert(!mesh::handleMorseCommand(&radio,command,out.reply,sizeof(out.reply)));
assert(!strcmp(out.reply,"unchanged") && out.after==0xaabbccdd);
}
assert(mesh::saves==0 && radio.starts==0 && radio.stops==0 && radio.rx_starts==0);
#endif
}
'''
fs_mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text(encoding="ascii")
for hil in (0, 1):
with self.subTest(hil=hil):
compile_run(self, code, flags=("-Wno-unused-parameter", f"-DMESH_MORSE_HIL={hil}"),
headers={"Dispatcher.h": portable_radio_header(), "helpers/IdentityStore.h": fs_mock})
def test_argument_readback_validation_safe_standard_fallback_and_cleanup(self):
bench = load_bench()
self.assertIsNone(bench.parse_rx_squelch("auto"))
for text, expected in (("-160",-160),("0",0),("-100.1254",-100.125),("-.5",-.5)):
self.assertEqual(bench.parse_rx_squelch(text),expected)
for text in ("","NaN","nan","inf","-inf","1","-160.01","-1e2","-100x","auto x"):
with self.subTest(text=text),self.assertRaises(argparse.ArgumentTypeError):
bench.parse_rx_squelch(text)
device = object.__new__(bench.Console)
calls = []
device.start_when_idle=lambda command:calls.append(command)
device.command=lambda command:(calls.append(command),"> -100.125 dBm; volatile HIL RX only")[1]
self.assertIn("-100.125",device.set_rx_squelch(-100.125))
self.assertEqual(calls,["set morse.hil.squelch -100.125","get morse.hil.squelch"])
for value in (math.nan,math.inf,-math.inf,1,-160.01):
before=len(calls)
with self.assertRaises(ValueError): device.set_rx_squelch(value)
self.assertEqual(len(calls),before)
device.command=lambda command:"> auto; volatile HIL RX only"
with self.assertRaisesRegex(RuntimeError,"readback mismatch"): device.set_rx_squelch(-100)
self.assertIn("auto",device.set_rx_squelch())
device.command=lambda command:"> -100.125x dBm; volatile HIL RX only"
with self.assertRaisesRegex(RuntimeError,"readback mismatch"): device.set_rx_squelch(-100.125)
device.start_when_idle=lambda command:(_ for _ in ()).throw(RuntimeError(
"set morse.hil.squelch auto: Error: unknown setting: morse.hil.squelch"))
self.assertEqual(device.set_rx_squelch(),"production image: adaptive RX squelch only")
with self.assertRaisesRegex(RuntimeError,"unknown setting"): device.set_rx_squelch(-100)
device.failed=True
with self.assertRaisesRegex(RuntimeError,"unknown setting"): device.set_rx_squelch()
device.failed=False
device.start_when_idle=lambda command:(_ for _ in ()).throw(RuntimeError("Error: radio busy"))
with self.assertRaisesRegex(RuntimeError,"busy"): device.set_rx_squelch()
calls=[]
device.command=lambda command,**kwargs:calls.append(command)
device.set_hil_timing=lambda:calls.append("timing reset")
device.set_rx_squelch=lambda:calls.append("squelch auto")
device.port=types.SimpleNamespace(close=lambda:calls.append("port close"))
device.close()
self.assertEqual(calls,["morse stop","timing reset","squelch auto","set morse.wpm 25","port close"])
device.failed=True; calls=[]
device.close()
self.assertEqual(calls,["port close"])
def test_trial_squelch_only_receiver_exact_match_and_metadata(self):
bench=load_bench()
actions=[]
class Device:
def __init__(self,name,text="SOS"): self.serial_number=name; self.text=text
def command(self,command):
actions.append((self.serial_number,command))
return "> off;" if command=="get morse" else "OK"
def start_when_idle(self,command): return self.command(command)
def set_hil_timing(self,value): return "standard timing"
def set_rx_squelch(self,value):
actions.append((self.serial_number,"squelch",value))
return f"> {value:.3f} dBm; volatile HIL RX only"
def capture(self): return self.text
for text,passed in (("SOS",True),(" SOS ",True),("E SOS",False),("SOS E",False),("S OS",False),("SO?",False)):
actions.clear()
with patch.object(bench.time,"sleep"):
result=bench.trial(Device("tx"),Device("rx",text),25,"SOS",rx_squelch=-100)
self.assertEqual(result["passed"],passed)
self.assertEqual(result["decoded"],text)
self.assertEqual(result["rx_squelch_dbm"],-100)
self.assertIn("-100.000",result["rx_squelch_status"])
self.assertEqual([a for a in actions if len(a)==3],[("rx","squelch",-100)])
self.assertLess(actions.index(("rx","squelch",-100)),actions.index(("rx","morse listen 1.0000")))
self.assertFalse(any("set tx" in a[1] for a in actions))
def test_main_quiet_both_receivers_and_trial_option_exact_identities(self):
bench=load_bench()
class Output:
def exists(self): return False
def write_text(self,text): self.text=text
for quiet in (False,True):
output=Output(); opened=[]; calls=[]; trials=[]
class Device:
def __init__(self,identity):
self.serial_number=identity; self.commands=[]; opened.append(identity)
def setup(self,frequency): calls.append((self.serial_number,"setup",frequency))
def close(self): calls.append((self.serial_number,"close"))
def command(self,command): self.commands.append(command); return "stats"
def start_when_idle(self,command): self.command(command)
def set_rx_squelch(self,value):
calls.append((self.serial_number,"squelch",value))
return "> -100.000 dBm; volatile HIL RX only"
def capture(self): return ""
def trial(*args,**kwargs): trials.append(kwargs); return {"passed":True}
argv=["bench","--output","memory","--rates","25","--messages","1","--rx-squelch","-100"]
if quiet: argv+=["--quiet-seconds","1"]
with patch.object(bench,"Console",Device),patch.object(bench,"trial",trial), \
patch.object(bench,"Path",lambda path:output),patch.object(bench.time,"sleep"), \
patch("builtins.print"),patch.object(sys,"argv",argv):
bench.main()
result=json.loads(output.text)
self.assertEqual(opened,[bench.SERIALS["rak"],bench.SERIALS["t096"]])
self.assertEqual(result["rx_squelch_dbm"],-100)
if quiet:
self.assertEqual([c for c in calls if len(c)==3 and c[1]=="squelch"],
[(identity,"squelch",-100) for identity in opened])
self.assertEqual(len(result["trials"]),2)
for row in result["trials"]:
self.assertEqual(row["rx_squelch_dbm"],-100)
self.assertIn("-100.000",row["rx_squelch_status"])
self.assertTrue(row["passed"])
self.assertEqual(trials,[])
else:
self.assertEqual([row["rx_squelch"] for row in trials],[-100,-100])
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -199,6 +199,9 @@ int main() {
def set_hil_timing(self,timing):
actions.append((self.serial_number,"device timing",timing))
return "> jitter=15%; drift=10%; extraGap=25%; seed=12345"
def set_rx_squelch(self,value):
actions.append((self.serial_number,"receive squelch",value))
return "> auto; volatile HIL RX only"
def start_when_idle(self,command): return self.command(command)
def capture(self): return self.decoded
human=bench.parse_human_timing("15,10,25,12345")
+7
View File
@@ -237,6 +237,10 @@ class BenchControlTests(unittest.TestCase):
raise RuntimeError("injected gain set failure")
return "OK"
def start_when_idle(self,text): return self.command(text)
def set_rx_squelch(self,value=None):
assert value is None, "Default quiet control must retain automatic squelch"
self.commands.extend(["set morse.hil.squelch auto","get morse.hil.squelch"])
return "> auto; volatile HIL RX only"
def capture(self):
if failure=="capture": raise RuntimeError("injected capture failure")
return ""
@@ -261,6 +265,9 @@ class BenchControlTests(unittest.TestCase):
else:
self.assertEqual(len(result["trials"]),2)
self.assertTrue(all(item["kind"]=="quiet_control" and item["passed"] for item in result["trials"]))
self.assertTrue(all(item["rx_squelch_dbm"] is None
and item["rx_squelch_status"]=="> auto; volatile HIL RX only"
for item in result["trials"]))
class HandoffProtocolTests(unittest.IsolatedAsyncioTestCase):
+35
View File
@@ -0,0 +1,35 @@
"""Exercise actual RSSI decoding of shortened human gaps and RF OFF-edge bias."""
from pathlib import Path
import unittest
from test_morse_tx import compile_run
ROOT = Path(__file__).resolve().parents[1]
class MorseRxGapBiasTests(unittest.TestCase):
def test_actual_sampled_decoder_retains_tight_letters_words_and_receiver_layout(self):
code = (ROOT / "test/fixtures/morse_rx_gap_bias.cpp").read_text(encoding="ascii")
output = compile_run(self, code, flags=("-DMESH_MORSE_MAX_WPM=120", "-DREQUIRE_TIGHT_PASS=1"))
rows = [line.split("\t") for line in output.splitlines()]
self.assertEqual(len(rows), 6)
for row in rows:
self.assertEqual(row[2:], ["PASS", "VW MN DOG "])
def test_former_boundaries_reproduce_the_same_tight_gap_failure(self):
code = (ROOT / "test/fixtures/morse_rx_gap_bias.cpp").read_text(encoding="ascii")
current = (ROOT / "src/helpers/MorseRx.h").read_text(encoding="ascii")
letter = "gap * UnitScale >= uint32_t(gap_unit_q5) * 2UL"
word = "gap * UnitScale >= uint32_t(gap_unit_q5) * 5UL"
self.assertIn(letter, current)
self.assertIn(word, current)
former = current.replace(letter, "gap * 4UL * UnitScale >= uint32_t(gap_unit_q5) * 9UL")
former = former.replace(word, "gap * 2UL * UnitScale >= uint32_t(gap_unit_q5) * 11UL")
output = compile_run(self, code, flags=("-DMESH_MORSE_MAX_WPM=120", "-DREQUIRE_TIGHT_PASS=0"),
includes=(ROOT / "src/helpers",),
headers={"helpers/MorseRx.h": former})
self.assertEqual(sum("\tFAIL\t" in line for line in output.splitlines()), 6)
if __name__ == "__main__":
unittest.main()
+30 -3
View File
@@ -13,11 +13,38 @@ 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), 154)
self.assertEqual(len(rows), 176)
unsupported = 0
for row in rows:
with self.subTest(wpm=row[0], sample_ms=row[1], timing=row[2]):
self.assertEqual(row[3], "PASS")
self.assertEqual(row[4], "CQ DE TEST 73 SOS ")
if row[:3] == ["240", "2", "spacing50"]:
# Experimental 5 ms dots at 2 ms RSSI sampling can bias the
# learned unit enough to confuse a 4.5-dot stretched letter
# gap with a word. Never claim this as an exact decode.
unsupported += 1
self.assertEqual(row[3], "FAIL")
self.assertEqual(row[4], "CQ DE TES T 73 SOS ")
else:
self.assertEqual(row[3], "PASS")
self.assertEqual(row[4], "CQ DE TEST 73 SOS ")
self.assertEqual(unsupported, 1)
def test_old_gap_thresholds_fail_tight_actual_sender_without_changing_marks(self):
code = (ROOT / "test/fixtures/morse_rx_hil_human.cpp").read_text(encoding="ascii")
source = (ROOT / "src/helpers/MorseRx.h").read_text(encoding="utf-8")
old = source.replace("gap * UnitScale >= uint32_t(gap_unit_q5) * 2UL",
"gap * 4UL * UnitScale >= uint32_t(gap_unit_q5) * 9UL").replace(
"gap * UnitScale >= uint32_t(gap_unit_q5) * 5UL",
"gap * 2UL * UnitScale >= uint32_t(gap_unit_q5) * 11UL")
self.assertNotEqual(source, old)
output = compile_run(self, code, flags=("-DMESH_MORSE_HIL=1", "-DMESH_MORSE_MAX_WPM=240",
"-DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=1"), headers={
"helpers/MorseRx.h": old,
"helpers/MorseTx.h": (ROOT / "src/helpers/MorseTx.h").read_text(encoding="utf-8")})
failures = {tuple(row[:2]): row[4] for line in output.splitlines()
if (row := line.split("\t"))[2:4] == ["tight_human", "FAIL"]}
for pair in (("25", "1"), ("25", "2"), ("120", "1"), ("120", "2")):
self.assertEqual(failures[pair], "CQ DE TEST 73 ?S ")
def actual_rows(self):
code = (ROOT / "test/fixtures/morse_rx_human.cpp").read_text(encoding="ascii")
+28
View File
@@ -0,0 +1,28 @@
"""Execute the optional bench RSSI floor against the real sampled decoder."""
from pathlib import Path
import unittest
from test_morse_tx import compile_run
ROOT = Path(__file__).resolve().parents[1]
class MorseRxSquelchTests(unittest.TestCase):
def test_hil_noise_prekey_real_dot_quiet_hysteresis_and_unchanged_auto(self):
code = (ROOT / "test/fixtures/morse_rx_squelch.cpp").read_text(encoding="ascii")
output = compile_run(self, code, flags=("-DMESH_MORSE_HIL=1",))
self.assertIn("actual squelch checks passed", output)
def test_shipping_weak_behavior_and_layout_have_no_squelch_dependency(self):
code = (ROOT / "test/fixtures/morse_rx_squelch.cpp").read_text(encoding="ascii")
output = compile_run(self, code, flags=("-DMESH_MORSE_HIL=0",))
self.assertIn("actual squelch checks passed", output)
def test_shipping_has_no_squelch_api_or_storage(self):
code = "#include <helpers/MorseRx.h>\nint main() { return mesh::setMorseHilSquelch(-100); }\n"
with self.assertRaisesRegex(AssertionError, "setMorseHilSquelch.*not a member"):
compile_run(self, code, flags=("-DMESH_MORSE_HIL=0",))
if __name__ == "__main__":
unittest.main()
+56 -2
View File
@@ -75,9 +75,26 @@ def firmware_harness(*, lose_sleep_guard=False, lose_crlf=False,
'console.printf(" -> %s\\n", reply);')
start = main.index(" bool can_power_save =")
end = main.index(" if (the_mesh.getNodePrefs()->reboot_interval", start)
power_loop = main[start:end]
morse_declaration = " const bool morse_timing = mesh::morseTimingActive(&radio_driver);"
assert morse_declaration in main[:start], "Missing live Morse ownership snapshot"
power_loop = morse_declaration + "\n" + main[start:end]
morse_helper = body((ROOT / "src/helpers/MorseLoopPolicy.h").read_text(),
"inline bool morseTimingActive(const Radio* radio)")
# Model only RF ownership at the peripheral boundary; use the real helper
# and loop guards rather than making every invocation permanently idle.
morse_radio = r'''
namespace mesh {
struct Radio {
bool tx = false, rx = false, cw = false;
bool isMorseTxActive() const { return tx; }
bool isMorseRxActive() const { return rx; }
};
@MORSE_HELPER@
}
static mesh::Radio radio_driver;
'''.replace("@MORSE_HELPER@", morse_helper)
fixture = (ROOT / "test/fixtures/official_web_console/firmware.cpp").read_text()
return source + (fixture.replace("@SETTER@", setter).replace("@GETTER@", getter)
return source + morse_radio + (fixture.replace("@SETTER@", setter).replace("@GETTER@", getter)
.replace("@COMMAND_PUMP@", pump).replace("@POWER_LOOP@", power_loop))
@@ -140,6 +157,43 @@ class OfficialWebConsoleTests(unittest.TestCase):
self.assertNotEqual(result.returncode, 0, "Negative control did not detect buffered replies")
self.assertIn("power-saving setter", result.stderr)
def test_actual_morse_ownership_blocks_sleep_and_idle_delay_then_resumes(self):
source = firmware_harness().split("int main() {", 1)[0] + r'''
int main() {
attachHost(false); Serial.terminal_open = false;
prefs.powersaving_enabled = 1; mock_millis = 300000;
const unsigned before = yield_ms;
radio_driver.tx = true;
require(mesh::morseTimingActive(&radio_driver), "Morse TX ownership was lost");
power_loop();
require(sleep_calls == 0 && yield_ms == before, "Morse TX slept or inserted an idle delay");
radio_driver.tx = false; radio_driver.rx = true;
require(mesh::morseTimingActive(&radio_driver), "Morse RX ownership was lost");
power_loop();
require(sleep_calls == 0 && yield_ms == before, "Morse RX slept or inserted an idle delay");
radio_driver.rx = false; radio_driver.cw = true;
require(!mesh::morseTimingActive(&radio_driver), "Unkeyed CW changed the Morse loop policy");
radio_driver.cw = false;
power_loop();
require(sleep_calls == 1, "Power saving did not resume when Morse released RF ownership");
}
'''
with tempfile.TemporaryDirectory(prefix="official-console-morse-ownership-") as temporary:
cpp = Path(temporary) / "ownership.cpp"
binary = Path(temporary) / "ownership"
cpp.write_text(source, encoding="ascii")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
result = subprocess.run([self.compiler, "-std=c++17", "-Wall", "-Wextra",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", "-DESP32_PLATFORM=1",
"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_CONSOLE_COOPERATIVE=1",
"-DMESH_USB_LOGGING_AVAILABLE=1", "-DCONFIG_TINYUSB_ENABLED=0",
"-I", str(ROOT / "src"), *sanitizers, str(cpp), "-o", str(binary)],
text=True, capture_output=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+54 -2
View File
@@ -8,6 +8,7 @@ import tempfile
import unittest
from test_replay_reset_integration import extract_braced
from test_morse_tx import portable_radio_header
ROOT = Path(__file__).resolve().parents[1]
@@ -17,6 +18,7 @@ HARNESS = r'''
#include <initializer_list>
#include <Packet.h>
#include <helpers/RoutingPolicy.h>
#include <helpers/MorseReplyHandoff.h>
#define LOOP_DETECT_OFF 0
#define LOOP_DETECT_MINIMAL 1
#define LOOP_DETECT_MODERATE 2
@@ -45,6 +47,11 @@ struct SimpleMeshTables {
}
};
struct MyMesh : mesh::Mesh {
struct CLI {
mesh::MorseReplyHandoff handoff;
mesh::MorseReplyHandoff& morseReply() { return handoff; }
const mesh::MorseReplyHandoff& morseReply() const { return handoff; }
} _cli;
struct Prefs {
uint8_t disable_fwd = 1, trace_when_repeat_off = 0;
uint8_t flood_max = 64, flood_max_unscoped = 64, flood_max_advert = 64;
@@ -172,6 +179,48 @@ int main() {
assert(node.allowPacketForward(&p));
node._prefs.disable_fwd = 1;
assert(!node.allowPacketForward(&p));
// Execute the real exact-reply handoff, not an always-false fixture stub.
// The listen acknowledgement and its profile copy must never retry while
// owned: retransmitting them can delay RX ownership and corrupt capture.
node._prefs.direct_retry_recent_enabled = 0;
node._prefs.trace_when_repeat_off = 1;
mesh::Packet reply, copy, unrelated;
reply.header = copy.header = unrelated.header =
ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT);
reply.path_len = copy.path_len = unrelated.path_len = 0;
const uint8_t actor[32] = {0x42};
auto& handoff = node._cli.morseReply();
handoff.beginCommand(actor);
#if MESH_ENABLE_MORSE_RX
assert(handoff.stage(902100, 25, 60000, 100));
handoff.prepareReply(&reply);
handoff.copy(&reply, &copy);
handoff.armReply(&reply, true);
handoff.endCommand();
assert(handoff.pending() && handoff.ownsReply(&reply) && handoff.ownsReply(&copy));
assert(!node.allowDirectRetry(&reply, nullptr, 0));
assert(!node.allowDirectRetry(&copy, nullptr, 0));
assert(node.allowDirectRetry(&unrelated, nullptr, 0)); // identical content is not ownership
assert(node.allowDirectRetry(nullptr, nullptr, 0));
handoff.complete(&reply);
assert(!handoff.ownsReply(&reply) && handoff.ownsReply(&copy));
assert(node.allowDirectRetry(&reply, nullptr, 0));
assert(!node.allowDirectRetry(&copy, nullptr, 0));
handoff.fail(&copy);
assert(!handoff.ownsReply(&copy));
assert(node.allowDirectRetry(&copy, nullptr, 0));
handoff.cancel();
#else
assert(!handoff.stage(902100, 25, 60000, 100));
handoff.prepareReply(&reply);
handoff.copy(&reply, &copy);
handoff.armReply(&reply, true);
handoff.endCommand();
assert(!handoff.pending() && !handoff.ownsReply(&reply) && !handoff.ownsReply(&copy));
assert(node.allowDirectRetry(&reply, nullptr, 0));
assert(node.allowDirectRetry(&copy, nullptr, 0));
#endif
}
'''
@@ -195,14 +244,17 @@ class RepeaterTraceForwardingTest(unittest.TestCase):
work = Path(directory)
cpp = work / 'test.cpp'
cpp.write_text(code, encoding='utf-8')
for portable in (False, True):
with self.subTest(portable=portable):
(work / 'Dispatcher.h').write_text(portable_radio_header(), encoding='utf-8')
for portable, morse_rx in ((False, 1), (True, 1), (False, 0), (True, 0)):
with self.subTest(portable=portable, morse_rx=morse_rx):
binary = work / 'test'
built = subprocess.run([
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-unused-parameter',
f'-DMESH_ENABLE_MORSE_RX={morse_rx}',
'-DMESH_ENABLE_FLOOD_GROUP_MODERATION=1', '-DMESH_ENABLE_CLOCK_SYNC=1',
*(['-DPORTABLE_MQTT_OBSERVER=1'] if portable else []), *flags,
'-I' + str(work), '-I' + str(ROOT / 'test/mocks'),
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
+35
View File
@@ -77,3 +77,38 @@ build_flags = ${env:RAK_4631_repeater.build_flags}
-DLORA_SF=5
-DENABLE_ADVERT_ON_BOOT=0
-DMESH_MORSE_MAX_WPM=255
; Same production RF timings/filtering, with lab-only trace/squelch available.
; Compare these to the fast HIL images before qualifying any shipping speed.
[env:morse_production_rak4631_companion]
extends = env:morse_bench_rak4631_companion
build_flags = ${env:RAK_4631_companion_radio_usb.build_flags}
-DLORA_FREQ=909.5
-DENABLE_ADVERT_ON_BOOT=0
-DMESH_MORSE_MAX_WPM=255
-DMESH_MORSE_HIL=1
-DMESH_MORSE_HIL_PRODUCTION_RF=1
-DMESH_MORSE_HIL_WARM_TX=0
-DMESH_MORSE_HIL_RSSI_WINDOW=0
-DMESH_MORSE_HIL_PA_RAMP_US=3400
-DMESH_MORSE_HIL_SAMPLE_INTERVAL_MS=2
-DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=0
-DMESH_MORSE_HIL_PACKET_GATING=0
-DCOMPANION_FEATURE_TEMP_RADIO=1
[env:morse_production_pocket_companion]
extends = env:morse_bench_pocket_companion
build_flags = ${env:Mesh_pocket_companion_radio_usb.build_flags}
-DMESH_CONTACT_CACHE=1
-DLORA_FREQ=909.5
-DENABLE_ADVERT_ON_BOOT=0
-DMESH_MORSE_MAX_WPM=255
-DMESH_MORSE_HIL=1
-DMESH_MORSE_HIL_PRODUCTION_RF=1
-DMESH_MORSE_HIL_WARM_TX=0
-DMESH_MORSE_HIL_RSSI_WINDOW=0
-DMESH_MORSE_HIL_PA_RAMP_US=3400
-DMESH_MORSE_HIL_SAMPLE_INTERVAL_MS=2
-DMESH_MORSE_HIL_FAST_OFF_DEBOUNCE=0
-DMESH_MORSE_HIL_PACKET_GATING=0
-DCOMPANION_FEATURE_TEMP_RADIO=1
+47 -2
View File
@@ -52,6 +52,18 @@ def parse_human_timing(text):
"extra_gap_percent": gap, "seed": seed}
def parse_rx_squelch(text):
"""A volatile HIL receive threshold, never a radio TX-power setting."""
if text == "auto":
return None
if not re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", text):
raise argparse.ArgumentTypeError("RX squelch must be auto or a finite -160..0 dBm value")
value = float(text)
if not math.isfinite(value) or not -160 <= value <= 0:
raise argparse.ArgumentTypeError("RX squelch must be between -160 and 0 dBm")
return round(value, 3)
def live_temporary_profile(reply):
"""Parse only confirmed live profiles, never saved/pending radio settings."""
fields = r"(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)"
@@ -161,6 +173,7 @@ class Console:
self.command(f"set morse.freq {cw_frequency:.6f}")
self.command("set morse.wpm 25")
self.set_hil_timing()
self.set_rx_squelch()
self.apply_live_temporary(PACKET_RADIO)
def set_hil_timing(self, timing=None):
@@ -184,6 +197,27 @@ class Console:
raise RuntimeError(("HIL timing readback mismatch", chosen, reply))
return reply
def set_rx_squelch(self, value=None):
if value is not None and (not math.isfinite(value) or not -160 <= value <= 0):
raise ValueError("RX squelch must be auto or between -160 and 0 dBm")
chosen = "auto" if value is None else f"{value:.3f}"
try:
self.start_when_idle("set morse.hil.squelch " + chosen)
except RuntimeError as error:
message = str(error)
unsupported = "Error: HIL-only" in message or (
"morse.hil.squelch" in message and re.search(r"\bunknown (?:command|setting)\b", message, re.I))
if value is None and unsupported and not getattr(self, "failed", False):
return "production image: adaptive RX squelch only"
raise
reply = self.command("get morse.hil.squelch")
observed = re.search(r"> (auto|[+-]?\d+(?:\.\d+)?)(?: dBm)?; volatile HIL RX only", reply)
if (not observed or (value is None and observed[1] != "auto")
or (value is not None and (observed[1] == "auto"
or not math.isclose(float(observed[1]), value, abs_tol=.00051, rel_tol=0)))):
raise RuntimeError(("HIL RX squelch readback mismatch", chosen, reply))
return reply
def apply_live_temporary(self, profile, timeout=12):
expected = tuple(float(value) for value in profile.split(","))
if len(expected) != 4:
@@ -260,17 +294,19 @@ class Console:
if not getattr(self, "failed", False):
self.command("morse stop", allow_error=True)
self.set_hil_timing()
self.set_rx_squelch()
self.command("set morse.wpm 25", allow_error=True)
finally:
self.port.close()
def trial(tx, rx, wpm, message, seconds=60, trace=False, human_timing=None):
def trial(tx, rx, wpm, message, seconds=60, trace=False, human_timing=None, rx_squelch=None):
tx.command("morse stop")
rx.command("morse stop")
tx.command(f"set morse.wpm {wpm}")
rx.command(f"set morse.wpm {wpm}")
timing_status = tx.set_hil_timing(human_timing)
squelch_status = rx.set_rx_squelch(rx_squelch)
rx.start_when_idle(f"morse listen {seconds / 60:.4f}")
# Establish a real quiet RSSI floor before any carrier transmission.
time.sleep(.8)
@@ -293,6 +329,7 @@ def trial(tx, rx, wpm, message, seconds=60, trace=False, human_timing=None):
"sent": message, "decoded": decoded, "passed": decoded.strip() == message,
"tx_elapsed_s": round(tx_finished - started, 3),
"human_timing": human_timing or STANDARD_TIMING, "tx_timing_status": timing_status,
"rx_squelch_dbm": rx_squelch, "rx_squelch_status": squelch_status,
"rx_status": rx_status, "rx_stats": stats}
if trace:
result["trace"] = rx.trace()
@@ -315,6 +352,8 @@ def main():
parser.add_argument("--human-timing", nargs="?", type=parse_human_timing,
const="15,10,25,12345",
help="Device-timed HIL jitter,drift,extraGap,seed; default preset15,10,25,12345")
parser.add_argument("--rx-squelch", type=parse_rx_squelch, default=None,
help="HIL receiver squelch floor in dBm (-160..0), default adaptive auto")
parser.add_argument("--quiet-seconds", type=int, default=0,
help="No-transmitter receive control, 1-30 seconds")
parser.add_argument("--t096-rxgain", choices=("on", "off"),
@@ -333,6 +372,7 @@ def main():
"requested_chip_power_dbm": -9,
"partner": args.partner, "direction": args.direction,
"human_timing": args.human_timing or STANDARD_TIMING,
"rx_squelch_dbm": args.rx_squelch,
"scope": "Experimental HIL applications on this pair; not production speed qualification or all radio families.",
"trials": [], "finished": False}
devices = {}
@@ -350,11 +390,14 @@ def main():
original_gain = match[1]
devices["t096"].command("set radio.fem.rxgain " + args.t096_rxgain)
if args.quiet_seconds:
for device in devices.values():
squelch_status = {}
for name, device in devices.items():
squelch_status[name] = device.set_rx_squelch(args.rx_squelch)
device.start_when_idle(f"morse listen {args.quiet_seconds / 60:.4f}")
time.sleep(args.quiet_seconds + .2)
for name, device in devices.items():
result = {"kind": "quiet_control", "receiver": name,
"rx_squelch_dbm": args.rx_squelch, "rx_squelch_status": squelch_status[name],
"rx_stats": device.command("morse stats")}
device.command("morse stop")
result["decoded"] = device.capture()
@@ -378,6 +421,8 @@ def main():
options = {"trace": args.trace}
if args.human_timing is not None:
options["human_timing"] = args.human_timing
if args.rx_squelch is not None:
options["rx_squelch"] = args.rx_squelch
result = trial(devices[tx_name], devices[rx_name], rate, message, **options)
result["repeat"] = repeat
results["trials"].append(result)