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
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:
1 parent
378925638b
commit
af08c02a00
23 files changed
+1194
-34
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+70
@@ -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
|
||||
}
|
||||
Vendored
+6
-4
@@ -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;
|
||||
}
|
||||
Vendored
+128
@@ -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");
|
||||
}
|
||||
Vendored
+9
-9
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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=[]))
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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, ©);
|
||||
handoff.armReply(&reply, true);
|
||||
handoff.endCommand();
|
||||
assert(handoff.pending() && handoff.ownsReply(&reply) && handoff.ownsReply(©));
|
||||
assert(!node.allowDirectRetry(&reply, nullptr, 0));
|
||||
assert(!node.allowDirectRetry(©, 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(©));
|
||||
assert(node.allowDirectRetry(&reply, nullptr, 0));
|
||||
assert(!node.allowDirectRetry(©, nullptr, 0));
|
||||
handoff.fail(©);
|
||||
assert(!handoff.ownsReply(©));
|
||||
assert(node.allowDirectRetry(©, nullptr, 0));
|
||||
handoff.cancel();
|
||||
#else
|
||||
assert(!handoff.stage(902100, 25, 60000, 100));
|
||||
handoff.prepareReply(&reply);
|
||||
handoff.copy(&reply, ©);
|
||||
handoff.armReply(&reply, true);
|
||||
handoff.endCommand();
|
||||
assert(!handoff.pending() && !handoff.ownsReply(&reply) && !handoff.ownsReply(©));
|
||||
assert(node.allowDirectRetry(&reply, nullptr, 0));
|
||||
assert(node.allowDirectRetry(©, 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user