diff --git a/zephcore/ADAPTIVE_CAD.md b/zephcore/ADAPTIVE_CAD.md index 97de517..c9c0963 100644 --- a/zephcore/ADAPTIVE_CAD.md +++ b/zephcore/ADAPTIVE_CAD.md @@ -26,7 +26,7 @@ feature measures CAD behaviour directly instead of deriving it from RSSI. ## How it works -Every `cad.probe.interval` seconds (default 60), when the radio is idle +Every `cad.probe.interval` seconds (default 15), when the radio is idle in receive mode, the firmware runs one **calibration CAD probe** and immediately re-arms RX. A probe takes one CAD duration — about 4 ms at SF7/250 kHz, up to ~130 ms at SF12/125 kHz — so the added radio deaf time @@ -61,9 +61,10 @@ Statistics decay (halve) every 6 hours so the picture stays fresh, and reset completely whenever the radio parameters change (frequency, SF, BW — the data is only valid for one configuration). -### Auto mode (staircase) +### Auto mode (staircase) — ON by default -With `cad.auto on`, a one-sided staircase controller acts on the stats: +`cad.auto` ships **on**, on repeaters and companions alike. A one-sided +staircase controller acts on the probe stats: - Probes concentrate on the **frontier** — one level more sensitive than the current operating point (3 of every 4 probes), with the remainder @@ -73,86 +74,98 @@ With `cad.auto on`, a one-sided staircase controller acts on the stats: - **Step up** (less sensitive) quickly when the operating level itself shows a false-positive rate above 2% over ≥50 samples — false positives at the operating point cost real transmissions. -- The offset is clamped to −4…+4 around the family base and persisted to - flash whenever it steps (steps are hours apart). +- The offset is persisted to flash whenever it steps. -Quiet sites converge *below* the standard value — more sensitive LBT than -any fixed-config firmware, meaning fewer TX-over-RX stomps. Noisy sites +At the default 15-second probe interval a step decision lands roughly +every **1–2 hours**, so the node tracks a changing RF environment within +that window without thrashing. + +The offset is clamped to **−8…+12** levels around the family base — wide +enough that a dense hilltop can settle much less sensitive and a quiet +valley node much more sensitive. The driver additionally clamps the +absolute detPeak (SX126x 15–40, LR11xx/LR20xx 48–90). That absolute clamp +is a *firmware guardrail*, not a chip limit — `cadDetPeak` is a full +8-bit register (0–255) — it simply stops the staircase from wandering +into "CAD never fires" (detPeak too high → LBT effectively off) or "CAD +always busy" (too low → node can't transmit) territory. Quiet sites +converge *below* the standard value — more sensitive LBT than any +fixed-config firmware, meaning fewer TX-over-RX stomps. Noisy sites ratchet up until false positives vanish. -## Recommended workflow (dry-run first) +To watch or hand-tune instead of letting it self-adjust, set +`cad.auto off` (see below). -Everything ships **observing but not acting**: probes run and counters -accumulate from first boot, but the operating detPeak stays at the -family default until you enable auto mode or set an offset by hand. +## Reading the status -1. **Let it observe.** Optionally speed up data collection during the - observation window: +Out of the box the node calibrates itself — you don't have to do +anything. To see what it's doing: - ``` - set cad.probe.interval 15 - ``` +``` +get cad +``` -2. **Read the curve.** After some hours: +Compact output (kept short so it survives a truncated LoRa reply): - ``` - get cad - ``` +``` +> a:on o:-1 pk:20(b21/4s) iv:15s +-2(19) 241p 9b 7f 2t 3% +-1(20) 900p 5b 3f 2t 0% ++0(21) 300p 2b 0f 2t 0% +``` - Example output: +Header: `a` auto on/off · `o` operating offset · `pk` operating detPeak · +`b` family base · `4s` 4 symbols · `iv` probe interval. +Per level: `level(peak) probes busy fp tp fp%%` — `fp` = suspected false +positives, `tp` = busy verdicts confirmed by a real packet, `fp%%` = +integer false-positive rate. Only levels that have been probed print, so +in auto mode you'll usually see the operating level and its frontier. - ``` - > auto:off offset:0 peak:21 (base 21, 4 sym) probe:15s - lvl -3 (peak 18): 240 probes, 31 busy, 28 fp, 3 tp (fp 11.6%) - lvl -2 (peak 19): 241 probes, 9 busy, 7 fp, 2 tp (fp 2.9%) - lvl -1 (peak 20): 240 probes, 3 busy, 1 fp, 2 tp (fp 0.4%) - lvl +0 (peak 21): 241 probes, 2 busy, 0 fp, 2 tp (fp 0.0%) - lvl +1 (peak 22): 240 probes, 2 busy, 0 fp, 2 tp (fp 0.0%) - lvl +2 (peak 23): 240 probes, 1 busy, 0 fp, 1 tp (fp 0.0%) - ``` +Here the staircase has already stepped to offset −1 (peak 20): the −1 +level is clean (0%) over 900 probes while −2 shows a 3% jump, so −1 is +this site's knee. - Read it bottom-up: the false-positive rate jumps somewhere (here - between −1 and −2). The lowest clean level (−1) is your site's knee. - In dry-run mode the sweep covers levels −3…+2 evenly. +### Dry-run / manual tuning - How long to wait (at the default 60 s interval; divide by 4 at 15 s): +Prefer to observe or pin the value yourself? Turn the staircase off: - | After | You get | - |---|---| - | 12–24 h | Knee location, coarsely — enough for a manual offset | - | 2–3 days | Per-level rates at ~1% resolution, incl. day/night variation | - | 1 week | Solid, weekday/weekend-proof picture | +``` +set cad.auto off +``` -3. **Act.** Either pin it manually: +Probing continues (stats keep updating) but the operating offset no +longer moves. In dry-run the probe sweep covers levels −4…+4 evenly, so +`get cad` shows the whole false-positive-vs-detPeak curve. Read it +bottom-up: the level where `fp%%` jumps from ~0 is the knee; sit one step +above it. Then pin it: - ``` - set cad.offset -1 - ``` +``` +set cad.offset -1 +``` - or let the staircase manage it from here on: +Negative offset = more sensitive LBT (catches weaker signals, risks false +busy); positive = less sensitive. Range −8…+12. Re-enable the staircase +any time with `set cad.auto on`. Offset changes (manual or automatic) +appear in the log as `cad: step down/up -> offset N`. - ``` - set cad.auto on - set cad.probe.interval 60 - ``` - - With auto on, `get cad` keeps showing the live state; offset changes - appear in the log as `cad: step down/up -> offset N` and are saved to - flash automatically. +To collect dry-run data faster, drop the interval (probing is temporary +then): `set cad.probe.interval 10`. At the default 15 s, allow ~a day for +a knee to resolve clearly, longer to capture day/night variation. ## Command reference | Command | Default | Description | |---|---|---| | `get cad` | | Status + per-level statistics (see above). | -| `set cad.auto ` | off | Staircase controller acts on the stats. | -| `set cad.offset ` | 0 | Operating offset, −4…4. Negative = more sensitive. Applied live. | -| `set cad.probe.interval ` | 60 | Probe cadence; 0 disables probing (and freezes auto), 10–255 otherwise. | +| `set cad.auto ` | **on** | Staircase controller acts on the stats. Off = observe/hand-tune. | +| `set cad.offset ` | 0 | Operating offset, −8…12. Negative = more sensitive. Applied live. | +| `set cad.probe.interval ` | 15 | Probe cadence; 0 disables probing (and freezes auto), 10–255 otherwise. | | `set cad.reset` | | Clear accumulated statistics (RAM only). | All settings persist in prefs and apply to every role — repeater, room server, and companion (companions reach the CLI via the v-contact admin -chat or USB serial). +chat or USB serial). Command keywords are case-insensitive (`Get cad` +works); command *values* like passwords and node names are not, so `on` +/`off` must be lowercase. ## Notes and limits diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 4654bee..cdfe6b6 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -253,9 +253,9 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. | | `set adc.multiplier ` | (0 = use board default) | Battery voltage ADC calibration multiplier | | `set meshtimesync ` | default **off** | Mesh time sync: automatically correct this node's clock from the consensus of Ed25519-signed advert timestamps heard on the mesh. Steps at most ±1 h per step, one step per 6 h; abstains without a quorum (default 6) of tenured agreeing senders; never overrides a clock set in the last 7 days, whether from GPS (re-armed on every fix) or a manual set. See `MESHTIMESYNC.md`. | -| `set cad.auto ` | default **off** | Adaptive CAD: let the staircase controller move the operating detPeak offset based on probe statistics. Leave off (dry-run) until `get cad` has accumulated a few days of per-level data. See `ADAPTIVE_CAD.md`. | -| `set cad.offset ` | −4 to 4, default 0 | Operating detPeak offset from the chip family's per-SF base (SX126x: SF+13; LR11xx/LR20xx: 56–68 table). Negative = more sensitive LBT (catches weaker signals, risks false busy), positive = less sensitive. Applied live; the auto staircase may move it later if `cad.auto` is on. | -| `set cad.probe.interval ` | 0 (off) or 10–255, default **60** | Seconds between calibration CAD probes. Lower (15–20 s) during a dry-run observation window to accumulate statistics faster; 0 disables probing entirely (also freezes auto adaptation). | +| `set cad.auto ` | default **on** | Adaptive CAD: let the staircase controller move the operating detPeak offset based on probe statistics. On by default (repeaters and companions); at the default 15 s probe interval it responds to environment change in ~1–2 h. Turn off to observe/hand-tune via `get cad` + `set cad.offset`. See `ADAPTIVE_CAD.md`. | +| `set cad.offset ` | −8 to 12, default 0 | Operating detPeak offset from the chip family's per-SF base (SX126x: SF+13; LR11xx/LR20xx: 56–68 table). Negative = more sensitive LBT (catches weaker signals, risks false busy), positive = less sensitive. Wide range so dense hilltops / quiet valleys can settle far from base. The per-family absolute clamp in the driver (SX126x 15–40, LR 48–90) is a firmware guardrail against a CAD that never/always fires, not a chip limit (`cadDetPeak` is a full `uint8_t`). Applied live; the auto staircase may move it later if `cad.auto` is on. | +| `set cad.probe.interval ` | 0 (off) or 10–255, default **15** | Seconds between calibration CAD probes. Default 15 s → ~1–2 h staircase response. 0 disables probing entirely (also freezes auto adaptation). | | `set cad.reset` | | Clear the accumulated per-level CAD probe statistics (RAM only; also cleared automatically on any radio parameter change). | | `set prv.key ` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* | diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 84666bb..b9b7d50 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -702,10 +702,10 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) } } - /* Offset 156: cad_offset (ZephCore extension, signed -4..4, default 0) */ + /* Offset 156: cad_offset (ZephCore extension, signed, default 0) */ if (off < len) { prefs.cad_offset = (int8_t)buf[off++]; - if (prefs.cad_offset < -4 || prefs.cad_offset > 4) { + if (prefs.cad_offset < CAD_OFFSET_MIN || prefs.cad_offset > CAD_OFFSET_MAX) { prefs.cad_offset = 0; } } diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 0d76ca0..6b98d06 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -1140,15 +1140,20 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) int n = 0; if (base == 0) { - return snprintf(buf, cap, "CAD: not supported by this radio"); + return snprintf(buf, cap, "cad n/a"); } - int peak = (int)base + _cad_offset; - + /* Terse on purpose — remote replies are capped at ~160 B over LoRa. + * Header: a:on o:1 pk:22(b21/4s) iv:15s + * a auto on/off o offset pk operating peak + * b family base 4s symbols iv probe interval + * Level: -3(18) 22p 18b 16f 2t 72% + * level(peak) probes busy fp tp fp-rate%% (integer). */ n += snprintf(buf + n, cap > n ? cap - n : 0, - "auto:%s offset:%d peak:%d (base %u, 4 sym) probe:%us", - _cad_auto ? "on" : "off", (int)_cad_offset, peak, - base, (unsigned)_cad_probe_interval_s); + "a:%s o:%d pk:%d(b%u/4s) iv:%us", + _cad_auto ? "on" : "off", (int)_cad_offset, + (int)base + _cad_offset, base, + (unsigned)_cad_probe_interval_s); for (int i = 0; i < CAD_NUM_LEVELS; i++) { CadLevelStats &s = _cad_stats[i]; @@ -1156,14 +1161,14 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) if (s.probes == 0) { continue; } - /* FP rate in tenths of a percent */ - uint32_t fp_pm = ((uint32_t)s.fp * 1000U) / s.probes; + /* Integer FP rate, rounded to nearest percent. */ + unsigned fp_pct = (unsigned)(((uint32_t)s.fp * 100U + s.probes / 2) + / s.probes); n += snprintf(buf + n, cap > n ? cap - n : 0, - "\nlvl %+d (peak %d): %u probes, %u busy, %u fp, %u tp (fp %u.%u%%)", + "\n%+d(%d) %up %ub %uf %ut %u%%", i + CAD_LEVEL_MIN, (int)base + i + CAD_LEVEL_MIN, - s.probes, s.busy, s.fp, s.tp, - (unsigned)(fp_pm / 10), (unsigned)(fp_pm % 10)); + s.probes, s.busy, s.fp, s.tp, fp_pct); } return n; diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index ce94915..f1ea3e8 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -26,11 +26,17 @@ * false-positive rate stays under target. Levels are signed offsets from * the chip family's per-SF base detPeak (SX126x: SF+13; LR11xx/LR20xx: * 56-68 table) so the C++ layer stays scale-independent. */ -#define CAD_LEVEL_MIN (-4) /* most sensitive probe level */ -#define CAD_LEVEL_MAX 4 /* least sensitive probe level */ +/* Operating-offset range (levels from the per-SF family base detPeak). Wide + * on purpose — a dense hilltop may need a much higher detPeak than a quiet + * valley node; the per-family absolute clamp in the driver (SX126x 15-40, + * LR 48-90) is a firmware guardrail against "CAD never/always fires", NOT a + * chip limit (cadDetPeak is a full uint8_t). + * MUST match CAD_OFFSET_MIN/MAX in helpers/NodePrefs.h. */ +#define CAD_LEVEL_MIN (-8) /* most sensitive probe level */ +#define CAD_LEVEL_MAX 12 /* least sensitive probe level */ #define CAD_NUM_LEVELS (CAD_LEVEL_MAX - CAD_LEVEL_MIN + 1) -#define CAD_SWEEP_MIN (-3) /* dry-run sweep window */ -#define CAD_SWEEP_MAX 2 +#define CAD_SWEEP_MIN (-4) /* dry-run sweep window (get cad with auto off) */ +#define CAD_SWEEP_MAX 4 #define CAD_FP_TARGET_PERMILLE 10 /* step-down needs FP rate <= 1% */ #define CAD_STEP_DOWN_MIN_PROBES 300 /* samples before a down-step call */ #define CAD_STEP_UP_MIN_PROBES 50 /* samples before an up-step call */ diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 95b9523..9085075 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -240,7 +240,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { if (prefs.apc_margin < 6 || prefs.apc_margin > 30) prefs.apc_margin = 16; if (prefs.meshtimesync > 1) prefs.meshtimesync = 0; if (prefs.cad_auto > 1) prefs.cad_auto = 0; - if (prefs.cad_offset < -4 || prefs.cad_offset > 4) prefs.cad_offset = 0; + if (prefs.cad_offset < CAD_OFFSET_MIN || prefs.cad_offset > CAD_OFFSET_MAX) prefs.cad_offset = 0; if (prefs.cad_probe_interval != 0 && prefs.cad_probe_interval < 10) prefs.cad_probe_interval = 10; /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index f2b02fb..6cb7dca 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -171,7 +171,7 @@ void CommonCLI::loadPrefs(const char* path) { _prefs->flood_max_advert = constrain(_prefs->flood_max_advert, (uint8_t)0, (uint8_t)64); _prefs->meshtimesync = constrain(_prefs->meshtimesync, (uint8_t)0, (uint8_t)1); _prefs->cad_auto = constrain(_prefs->cad_auto, (uint8_t)0, (uint8_t)1); - _prefs->cad_offset = constrain(_prefs->cad_offset, (int8_t)-4, (int8_t)4); + _prefs->cad_offset = constrain(_prefs->cad_offset, (int8_t)CAD_OFFSET_MIN, (int8_t)CAD_OFFSET_MAX); if (_prefs->cad_probe_interval != 0 && _prefs->cad_probe_interval < 10) { _prefs->cad_probe_interval = 10; } @@ -304,6 +304,32 @@ void CommonCLI::scheduleReboot(uint8_t type) } void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) { + /* Case-insensitive command keywords. Lowercase only the first two + * whitespace-delimited tokens (the verb and the config key) — the value + * (3rd token onward) is preserved verbatim, so case-sensitive arguments + * like passwords, node names, owner info, and keys are never mangled. + * Fixes e.g. "Get cad" / "SET Cad.Auto on" not being recognized. */ + char norm[CLI_REPLY_SIZE]; + { + int tok = 0; /* completed tokens so far */ + bool in_tok = false; + size_t j = 0; + for (size_t i = 0; command[i] != '\0' && j < sizeof(norm) - 1; i++) { + char c = command[i]; + if (c == ' ' || c == '\t') { + if (in_tok) { in_tok = false; tok++; } + } else { + in_tok = true; + if (tok < 2 && c >= 'A' && c <= 'Z') { + c = (char)(c - 'A' + 'a'); + } + } + norm[j++] = c; + } + norm[j] = '\0'; + command = norm; + } + if (strcmp(command, "start dfu") == 0) { /* Reboot into UF2 bootloader for firmware update */ strcpy(reply, "OK - rebooting to UF2 DFU"); @@ -637,8 +663,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } } else if (memcmp(config, "cad.offset ", 11) == 0) { int val = atoi(&config[11]); - if (val < -4 || val > 4) { - strcpy(reply, "Error: offset range is -4..4"); + if (val < CAD_OFFSET_MIN || val > CAD_OFFSET_MAX) { + snprintf(reply, CLI_REPLY_SIZE, "Error: offset range is %d..%d", + CAD_OFFSET_MIN, CAD_OFFSET_MAX); } else { _prefs->cad_offset = (int8_t)val; _callbacks->applyCadPrefs(); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 1c459f9..6a777b8 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -24,6 +24,18 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 +/* Adaptive-CAD operating detPeak offset range (levels from the family base). + * Wide on purpose: a dense hilltop can need a much higher detPeak than a quiet + * valley node. The per-family absolute clamp inside the driver (SX126x 15-40, + * LR11xx/LR20xx 48-90) is a firmware guardrail, NOT a chip limit — cadDetPeak + * is a full uint8_t (0-255). It just keeps the staircase from wandering into + * "CAD never fires" (too high) or "CAD always busy" (too low) territory. This + * offset range limits how far the staircase / manual offset may roam; MUST + * match CAD_LEVEL_MIN/MAX in adapters/radio/radio_common.h (they index the + * per-level stats array). */ +#define CAD_OFFSET_MIN (-8) +#define CAD_OFFSET_MAX 12 + struct NodePrefs { /* ---- Common fields (both roles) ---- */ float airtime_factor; @@ -139,9 +151,9 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->rx_duty_cycle = 0; // Default OFF — continuous RX for best reliability prefs->apc_enabled = 0; // Default OFF — fixed TX power prefs->apc_margin = 16; // Default 16 dB target link margin - prefs->cad_auto = 0; // Default OFF — dry-run: probes + counters only - prefs->cad_offset = 0; // Family base detPeak (SF+13 on SX126x) - prefs->cad_probe_interval = 60; // One CAD probe per minute + prefs->cad_auto = 1; // Default ON — adaptive staircase acts on probe stats + prefs->cad_offset = 0; // Start at family base detPeak (SF+13 on SX126x) + prefs->cad_probe_interval = 15; // 15 s → staircase responds to change in ~1-2 h prefs->wake_on_msg = 1; // Default ON — wake display when message arrives prefs->v_contact_enabled = 1; // Default ON — v-contact loopback admin chat (companion) prefs->v_battery_alert_mv = 0xFFFF; // Sentinel: derive from board auto-shutdown threshold