diff --git a/releasenotes/RELEASE_NOTES_1.16.6-zephcore.md b/releasenotes/RELEASE_NOTES_1.16.6-zephcore.md new file mode 100644 index 0000000..4f41c30 --- /dev/null +++ b/releasenotes/RELEASE_NOTES_1.16.6-zephcore.md @@ -0,0 +1,109 @@ +# ZephCore 1.16.6-zephcore + +> [!IMPORTANT] +> ## Before you upgrade +> +> **From v1.16.5** — clean flash, no re-bond, bonds and data survive. +> +> **From v1.16.2 / v1.16.3 / v1.16.4** — clean flash, bonds and data survive. If you are on an +> **ESP32-S3 / ESP32-C board** and have *not* yet taken the v1.16.5 update, you still owe the one-time +> serial reflash described there: the app moved to flash offset `0x10000` in v1.16.5, and a board on the +> old layout cannot cross that update over WiFi-OTA or the browser flasher — **flash the `-merged.bin` +> once over USB/serial.** Affected boards: *Heltec V3 / V4 / V4.3, Station G2, Wireless Tracker / V2, +> XIAO ESP32-S3 / C3 / C6, LilyGo T-Lora C6.* Identity, contacts, channels, prefs, and BLE bonds are +> preserved. nRF52, classic ESP32 (T-Beam / PICO-D4 / TTGO LoRa32), STM32WL, and native Linux are +> unaffected. +> +> **From v1.16.1 or older** — flash it; on first boot it clears BLE bonds automatically (identity, +> contacts, channels, prefs preserved). Re-bond your phone/desktop once. +> +> **Coming from Arduino MeshCore** — flash it; auto-formats on first boot (new identity, clean storage). +> +> Take this with a grain of salt — try the formatters if anything anomalous happens with your node. + +--- + +A maintenance release: an important **admin-password fix**, **GPS standby power savings**, and the +removal of **Adaptive Power Control**. + +## Highlights + +### Fixed: uppercase letters in an admin password were silently lowercased + +**If you set or changed an admin password on v1.16.5 and can no longer log in, this is why.** v1.16.5 +introduced case-insensitive CLI keywords by lowercasing the first two words of every command before +matching them. That is safe for `get cad` — but `password ` puts the *value* in the second word, +so `password MyPass` was stored as `mypass`, and the password you typed afterwards never matched. +(`set guest.password ` has three words and was never affected.) + +The normalizer has been removed and CLI commands are case-sensitive again, matching upstream Arduino +MeshCore. **If you are locked out, re-flash and reconfigure, or log in with the all-lowercase form of the +password you set.** + +The one place autocapitalization genuinely hurts — typing commands to the V-Contact from a phone keyboard, +which capitalizes the first letter of every line — is handled narrowly instead: only character 0 of a +V-Contact chat line is folded. No command takes an argument at position 0, so no value can be touched. + +### GPS standby now actually saves power + +`CONFIG_PM_DEVICE` (device power management) is enabled for the first time, carefully scoped: system-managed +PM stays off, nothing suspends automatically, and every PM call is made explicitly from the main thread. +This buys two things: + +- **The GNSS UART is suspended while GPS is off or in standby.** An armed nRF UARTE receiver keeps the + high-frequency clock running — roughly **0.5–1 mA continuously on nRF52840** — even with the GPS module + itself powered down. Nodes with GPS disabled in prefs were paying this for their entire uptime. The UART + is resumed before every wake, so no NMEA is lost. +- **GNSS drivers that boot suspended now get resumed.** `gnss-nmea-generic` initializes suspended under + device PM and never opens its data pipe until told to — this was the old "enabling PM breaks GPS" trap, + now handled once at boot with retries. + +(Only nRF UARTE ports are gated in. Other UART drivers are deliberately left alone — the saving is +UARTE-specific and their suspend/resume round-trip is unverified.) + +### Removed: Adaptive Power Control + +APC tried to save battery by lowering TX power when neighbors reported more signal margin than they +needed. It has been removed entirely, because the saving it chased is not one most nodes actually pay. + +TX power is only spent at the instant of a transmission. A companion that sits idle for days and sends a +handful of messages spends almost nothing on transmit to begin with — its drain is BLE advertising, the +radio sitting in receive, and the MCU. APC could not meaningfully reduce that. On the nodes where transmit +volume *is* high enough to matter, the feature never got a clean run: its measurement was structurally +confounded (the echo it measured comes from the neighbor's own fixed-power transmission and does not +respond to your reduction), and the staleness timer that ramped power back up could not tell "our link +degraded" from "the mesh was quiet." Rather than ship another round of tuning on a feature with no +demonstrated payoff and a real downside — a node that reduces too far goes silent, and cannot detect that +it has — it's gone. + +What changes for you: + +- **`set tx apc` and `set`/`get apc.margin` are removed.** `set tx ` sets a fixed power, as it always + did, and `get tx` now answers with a plain number (`> 22`) exactly like upstream Arduino MeshCore. +- **Nodes that had APC enabled now transmit at their configured `tx` power.** If you had turned it on, + check that your configured power is what you actually want — it is now used verbatim. +- **The APC line is gone from the on-device radio display**, and the TX row now shows a single power + value instead of an `effective/max` pair — there is only one number now, so it is only printed once. +- **Your saved settings are safe.** APC's two prefs bytes are kept reserved at their original offsets, so + no stored configuration shifts and nothing else is misread on upgrade. + +## Other fixes and improvements + +- **Fixed: Heltec V3 failed to build from source** once device PM was enabled. The upstream Zephyr board + definition force-enables the GPIO power-domain driver without the devicetree guard that normally comes + with it, and ZephCore's overlay deliberately replaces both of that board's power domains with plain + regulators — so the driver was being compiled for hardware that no longer existed in the devicetree, and + tripped over a helper that only exists under a config it never turned on. The power-domain subsystem is + now explicitly off for V3, which is simply the truth about that board. Binary releases were unaffected; + this only bit source builds. (V4 / V4.3 use ZephCore-local board definitions and were never affected.) +- **Air530z GPS driver patch** comments corrected to match the new PM strategy. + +## Recommended upgrade checklist + +1. **Set an admin password on v1.16.5?** Try the all-lowercase form if you are locked out; otherwise + re-set it after upgrading. +2. **ESP32-S3 / ESP32-C boards that skipped v1.16.5:** flash `-merged.bin` once over USB/serial. Data survives. +3. **Everything else, from v1.16.2 onward:** just flash — bonds and data survive. +4. **From v1.16.1 or older:** just flash — self-migrates on first boot; re-bond once. +5. **From Official MeshCore:** just flash — auto-formats on first boot. +6. **Anything odd?** Format first. diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 1fb04c1..c980641 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -51,7 +51,6 @@ zephcore/ │ ├── Identity.cpp # Ed25519 key management, ECDH shared secrets │ ├── Utils.cpp # AES-ECB encrypt, HMAC-SHA256, MAC │ ├── ContentionTracker.cpp # Adaptive contention window (EMA, backoff) -│ ├── PowerController.cpp # Adaptive Power Control (APC) │ ├── StaticPoolPacketManager.cpp # Fixed-size packet pool (32 slots) │ ├── main_companion.cpp # Companion mode entry point + event loop │ ├── main_repeater.cpp # Repeater mode entry point + event loop @@ -63,7 +62,6 @@ zephcore/ │ ├── Radio.h # Abstract radio interface │ ├── Board.h, Clock.h, RNG.h, RTC.h # HAL interfaces │ ├── ContentionTracker.h # Adaptive contention window state -│ ├── PowerController.h # APC state machine │ ├── LoRaConfig.h # Default radio parameters │ ├── RadioIncludes.h # Compile-time radio driver selection │ ├── SimpleMeshTables.h # Hash-based packet deduplication @@ -984,7 +982,7 @@ in their shared base fields: **Companion `/lfs/new_prefs` (152 bytes)** — `adapters/datastore/ZephyrDataStore.cpp` `loadPrefs()`/`savePrefs()` (offset comments inline). Arduino companion layout (name, lat/lon, radio params, telemetry modes, BLE pin, GPS, autoadd) plus ZephCore extensions from offset 92: -rx_boost(92), leds_disabled(93), apc(94-95), default flood scope name/key(96-142), +rx_boost(92), leds_disabled(93), reserved(94-95, was APC), default flood scope name/key(96-142), ble_disabled(143), display/wake/screen-off/auto-shutdown(144-149), rx_duty_cycle(150), meshtimesync(151). @@ -992,7 +990,7 @@ meshtimesync(151). `loadPrefs()`/`savePrefs()` (same field order as `helpers/CommonCLI.cpp`; offset comments inline). Key ranges: name(4-36), radio(72-119), adaptive-delay(80-111, ignored at runtime), Arduino-bridge(127-151, read+discarded), GPS(156-161), owner_info(170-290), rx_boost/duty(290-291), -apc(292-293), flood_max_unscoped/advert(294-295), meshtimesync(296). Older shorter files +reserved(292-293, was APC), flood_max_unscoped/advert(294-295), meshtimesync(296). Older shorter files load cleanly — reads past EOF are no-ops, so newer fields keep their defaults and a one-time upgrade block migrates them. diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 25ddc83..b033a79 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -464,7 +464,7 @@ add_definitions(-DFIRMWARE_BUILD_EPOCH=${ZEPHCORE_BUILD_EPOCH}u) # release is tagged/named, and what the Mesh America catalog uses as its version # key — so the configurator can match a running device against the catalog. Keep # all four identical; the release workflow reads this value directly. -set(ZEPHCORE_FIRMWARE_VERSION "1.16.5-zephcore") +set(ZEPHCORE_FIRMWARE_VERSION "1.16.6-zephcore") add_definitions(-DFIRMWARE_VERSION="${ZEPHCORE_FIRMWARE_VERSION}") add_subdirectory(lib/monocypher) @@ -491,7 +491,6 @@ target_sources(app PRIVATE src/ContentionTracker.cpp src/Dispatcher.cpp src/Identity.cpp - $<$:src/PowerController.cpp> src/Mesh.cpp src/Packet.cpp src/StaticPoolPacketManager.cpp diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 8021e96..6cfccb9 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -581,25 +581,6 @@ config ZEPHCORE_LORA_DC_MIN_SYMBOLS for extra detection margin on very noisy sites. SF5/6 adds +4 internally. Lowering below 8 risks missed packets at low SNR. -config ZEPHCORE_APC - bool "Adaptive Power Control (APC)" - default y - help - Automatically reduce TX power when true echoes (flood dupes - that provably routed through this node) show excess SNR margin - downstream. The control law compensates for its own reduction - (echo SNR is measured from the neighbor's fixed-power TX), so - it holds the target margin instead of ratcheting to the floor. - Saves battery and reduces channel congestion. Ramps back to - full power within 2 minutes without echoes, or immediately - after 2 consecutive unechoed transmissions. - - Uses rogue-filtering: clusters echo SNRs to avoid one badly - placed high-SNR neighbor from over-reducing power. - - Compiled in by default; disabled at runtime (enable per-node - via CLI "set tx apc" — persisted in prefs). - endmenu config ZEPHCORE_RTC_AUTODISCOVER @@ -867,6 +848,22 @@ config ZEPHCORE_UI_JOYSTICK D-pad. Not user-selectable; set by the board configuration. Follows the same pattern as ZEPHCORE_UI_BUTTONS and ZEPHCORE_UI_DISPLAY. +config ZEPHCORE_UI_KEYBOARD + bool + help + Set by board-level Kconfig when the hardware has a full keyboard + (e.g. ThinkNode M9's STC8H matrix MCU on I2C). Not user-selectable. + + A keyboard is a superset of a joystick — it supplies arrows, enter + and escape — so it drives the same full companion UI rather than a + separate one. Selecting this makes ZEPHCORE_UI_DESIGN_JOYSTICK + available on a board that has no 5-way stick. + + No board selects this yet: the STC8H driver and the keyboard text + entry screen are not written, and the M9 is unverified on hardware. + The key-space reservation in helpers/ui-joystick/joystick_defs.h is + the groundwork that makes them droppable in later. + config ZEPHCORE_UI_DESIGN_BUTTON bool "Button-based page UI" default y if (ZEPHCORE_UI_BUTTONS || ZEPHCORE_UI_DISPLAY || ZEPHCORE_UI_BUZZER) && !ZEPHCORE_UI_DESIGN_JOYSTICK @@ -878,14 +875,18 @@ config ZEPHCORE_UI_DESIGN_BUTTON implementation without touching this Kconfig. config ZEPHCORE_UI_DESIGN_JOYSTICK - bool "Joystick-based companion UI" - default y if ZEPHCORE_ROLE_COMPANION && ZEPHCORE_UI_DISPLAY && ZEPHCORE_UI_BUTTONS && ZEPHCORE_UI_JOYSTICK - depends on ZEPHCORE_ROLE_COMPANION && ZEPHCORE_UI_DISPLAY && ZEPHCORE_UI_BUTTONS && ZEPHCORE_UI_JOYSTICK + bool "Full companion UI (joystick or keyboard driven)" + default y if ZEPHCORE_ROLE_COMPANION && ZEPHCORE_UI_DISPLAY && ZEPHCORE_UI_BUTTONS && (ZEPHCORE_UI_JOYSTICK || ZEPHCORE_UI_KEYBOARD) + depends on ZEPHCORE_ROLE_COMPANION && ZEPHCORE_UI_DISPLAY && ZEPHCORE_UI_BUTTONS && (ZEPHCORE_UI_JOYSTICK || ZEPHCORE_UI_KEYBOARD) help - Full joystick-driven companion UI (helpers/ui-joystick/). Provides GPS, - contacts, channels, snake game, repeater admin, and all other companion - screens via a 5-way joystick or D-pad. Replaces ZEPHCORE_UI_DESIGN_BUTTON - for companion builds on boards with joystick hardware. + Full companion UI (helpers/ui-joystick/). Provides GPS, contacts, + channels, snake game, repeater admin, and all other companion screens. + Replaces ZEPHCORE_UI_DESIGN_BUTTON for companion builds on boards with + joystick or keyboard hardware. + + Named "joystick" for the hardware that first drove it; the screens + themselves only consume the abstract key codes in joystick_defs.h, so + any input device supplying arrows/enter/escape can drive them. endmenu diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index e9d5ab5..0257d16 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -180,8 +180,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get repeat` | Forwarding enabled: `on` or `off` | | `get radio` | Radio params: `freq,bw,sf,cr` | | `get freq` | Frequency in MHz | -| `get tx` | TX power: fixed dBm or APC status | -| `get tx apc` | APC diagnostics: effective/max power, reduction, margin EMA, target, echo/noecho counters | +| `get tx` | TX power in dBm | | `get lat` | Stored latitude | | `get lon` | Stored longitude | | `get dutycycle` | Duty cycle as percentage (e.g. "50.0%") | @@ -195,7 +194,6 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get flood.max.advert` | Max retransmit hops for ADVERT floods | | `get flood.advert.interval` | Flood advertisement interval in hours | | `get advert.interval` | Local advertisement interval in minutes | -| `get apc.margin` | Adaptive Power Control target SNR link margin in dB | | `get allow.read.only` | Whether read-only clients are allowed | | `get guest.password` | Guest access password | | `get owner.info` | Owner/contact info (pipes `\|` display as newlines) | @@ -227,7 +225,7 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set repeat ` | | Enable or disable packet forwarding | | `set radio ` | freq 150–2500, bw 7–500, sf 5–12, cr 5–8 | Set radio params *(reboot required)* | | `set freq ` | 150–2500 *(USB only)* | Set frequency alone *(reboot required)* | -| `set tx ` | −9 to board max (default 30), or `apc` | Set TX power fixed or enable Adaptive Power Control | +| `set tx ` | −9 to board max (default 30) | Set TX power | | `set lat ` | | Set stored latitude | | `set lon ` | | Set stored longitude | | `set dutycycle ` | 1–100 | Set duty cycle percentage (converted to airtime factor internally) | @@ -241,7 +239,6 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set flood.max.advert ` | 0–64 | Hop limit for ADVERT floods only (default 8); curbs advert churn independent of flood.max | | `set flood.advert.interval ` | 3–168 | How often the repeater floods its own advertisement | | `set advert.interval ` | min–240 | How often the repeater sends local advertisements | -| `set apc.margin ` | 6–30 | Target SNR link margin for Adaptive Power Control | | `set allow.read.only ` | | Allow or deny read-only client connections | | `set guest.password ` | | Set guest access password | | `set owner.info ` | Use `\|` for newlines | Owner/contact information | diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 09af16b..9f46ba0 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -659,17 +659,14 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) prefs.leds_disabled = 0; /* Default: LEDs on */ } - /* Offset 94: apc_enabled (ZephCore extension) */ + /* Offsets 94-95: RESERVED — formerly apc_enabled / apc_margin (APC, + * removed in 1.16.6). Still consumed so offset 96 onward keeps landing + * where already-deployed nodes wrote it. Values are ignored. */ if (off < len) { - prefs.apc_enabled = buf[off++]; + prefs._reserved_apc_enabled = buf[off++]; } - - /* Offset 95: apc_margin (ZephCore extension) */ if (off < len) { - prefs.apc_margin = buf[off++]; - if (prefs.apc_margin < 6 || prefs.apc_margin > 30) { - prefs.apc_margin = 20; /* companion default */ - } + prefs._reserved_apc_margin = buf[off++]; } /* Offset 96: default_scope_name (31 bytes) — v11 FIRMWARE_VER_CODE */ @@ -847,10 +844,10 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) buf[off++] = prefs.rx_boost; /* Offset 93: leds_disabled (ZephCore extension) */ buf[off++] = prefs.leds_disabled; - /* Offset 94: apc_enabled (ZephCore extension) */ - buf[off++] = prefs.apc_enabled; - /* Offset 95: apc_margin (ZephCore extension) */ - buf[off++] = prefs.apc_margin; + /* Offsets 94-95: RESERVED — formerly apc_enabled / apc_margin (removed + * in 1.16.6). Written back unchanged to hold the layout. */ + buf[off++] = prefs._reserved_apc_enabled; + buf[off++] = prefs._reserved_apc_margin; /* Offset 96: default_scope_name (31 bytes) — v11 FIRMWARE_VER_CODE */ memcpy(&buf[off], prefs.default_scope_name, 31); off += 31; diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index c928fc2..dbbe875 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -52,7 +52,6 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, _cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_probe_rr(0), _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), _rx_boost_enabled(true), - _tx_power_reduction_db(0), _dc_last_rx_us(0), _dc_last_sleep_us(0), _config_cached(false), _has_radio_override(false), @@ -236,8 +235,6 @@ void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx) cfg.tx_power = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; } #endif - /* APC reduction (applied after all clamps) */ - cfg.tx_power -= _tx_power_reduction_db; if (cfg.tx_power < -9) cfg.tx_power = -9; cfg.tx = tx; @@ -316,16 +313,6 @@ int8_t LoRaRadioBase::getConfiguredTxPower() const return (int8_t)power; } -int8_t LoRaRadioBase::getEffectiveTxPower() const -{ - int power = (int)getConfiguredTxPower() - (int)_tx_power_reduction_db; - - if (power < -9) { - power = -9; - } - return (int8_t)power; -} - /** * Compare radio-relevant fields of two modem configs. * Ignores the tx flag — that only selects TX vs RX mode, the actual diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index 462330f..e21a166 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -84,7 +84,7 @@ public: /* Read-only view of the modem config currently used by buildModemConfig(). * These honor temporary radio overrides for freq/bw/sf/cr and the same TX - * clamps/APC reduction as the actual lora_config() path. */ + * clamps as the actual lora_config() path. */ uint32_t getActiveFrequencyHz() const; uint16_t getActiveBandwidthKHzX10() const; uint8_t getActiveSpreadingFactor() const; @@ -92,7 +92,6 @@ public: uint16_t getActivePreambleLength() const; uint8_t getActiveSyncWord() const; int8_t getConfiguredTxPower() const; - int8_t getEffectiveTxPower() const; bool isTxActive() const { return atomic_get(&_tx_active) != 0; } /* Duty-cycle preamble false-positive counter. @@ -105,10 +104,6 @@ public: virtual uint32_t getDutyCycleTimeoutRestarts() const { return 0; } virtual void resetDutyCycleTimeoutRestarts() {} - /* Adaptive Power Control */ - void setTxPowerReduction(int8_t reduction_db) override { _tx_power_reduction_db = reduction_db; } - int8_t getTxPowerReduction() const override { return _tx_power_reduction_db; } - /* Adaptive CAD (LBT detPeak calibration) */ void setCadParams(bool auto_enabled, int8_t offset, uint16_t probe_interval_s, uint8_t busycap_pct) override; @@ -223,7 +218,6 @@ protected: /* Power saving */ bool _rx_duty_cycle_enabled; bool _rx_boost_enabled; - int8_t _tx_power_reduction_db; /* Last duty-cycle timing handed to the driver — used to log timing * changes once at INF instead of on every RX restart. 0/0 = never @@ -237,7 +231,7 @@ protected: /* Radio param override — when set, buildModemConfig() uses these * for freq/bw/sf/cr instead of _prefs. Everything else (tx_power, - * preamble, APC reduction) still comes from _prefs. */ + * preamble) still comes from _prefs. */ bool _has_radio_override; float _override_freq; float _override_bw; diff --git a/zephcore/apc.md b/zephcore/apc.md deleted file mode 100644 index 4e53c03..0000000 --- a/zephcore/apc.md +++ /dev/null @@ -1,360 +0,0 @@ -# Adaptive Power Control (APC) for ZephCore - -## Context - -TX power is a static user setting (`NodePrefs.tx_power_dbm`, default 22 dBm). When neighbors are nearby and receiving with 20+ dB of excess SNR margin, we're wasting battery and adding unnecessary channel energy. APC automatically reduces TX power when echo packets (dupes of our own transmissions, heard back from neighbors who retransmitted them) indicate strong link margins, and ramps back up when data goes stale (neighbor offline/moving). - -This is a novel "echo-based" approach — no published LoRa APC uses this technique. It's well-suited to flood mesh because every retransmit naturally produces echoes without any protocol overhead. - -## Status - -**Implemented and building** on all boards. **Compiled in by default** (`CONFIG_ZEPHCORE_APC=y`) but **disabled at runtime** — enable per-node with `set tx apc` (persisted in prefs, survives reboot). Works for both companion and repeater roles. - -**Rev 2 (2026-07-17):** first field test showed the rev-1 control loop was unusable — see [Control theory](#control-theory-why-rev-1-failed) below. Rev 2 adds reduction self-compensation, true-echo gating, last-hop source attribution, a no-echo fast-recovery tripwire, stale-gap EMA re-warmup, and SF5/6 thresholds. Bench/field validation of rev 2 is pending. - -## Control theory (why rev 1 failed) - -**The echo SNR we measure does not respond to our own TX power.** An echo's SNR is set by the *neighbor's* transmitter power and the path loss — reducing our power changes what *they* receive, not what *we* measure. Rev 1 compared this unresponsive measurement against the target and stepped power down whenever margin looked high, so in any healthy neighborhood it ratcheted straight to −12 dB and pinned there. The only feedback that ever closed the loop was catastrophic: - -- **Companion:** reduce until nobody decodes us → echoes stop → EMA crashes → power back up → repeat. A limit cycle hunting the audibility cliff, losing real messages in every trough. -- **Repeater:** peers echo the *origin's* copy whether or not they can hear us, so echoes never even stop — the node just sat at −12 dB while its actual forwarding coverage shrank. - -(The rev-1 design implicitly assumed a network where every node runs APC — then each node's reduction lowers the *others'* measured margins and a coupled equilibrium exists. On a real mesh of vanilla nodes there is no such coupling.) - -**Rev 2 fix — self-compensation.** By reciprocity, the neighbor hears us at approximately `echo_margin − our_reduction` (when TX powers are comparable). The controller now regulates that predicted value: - -``` -excess = margin_ema − target − current_reduction -excess > +1 dB → increase reduction by min(3, excess) per tick -excess < −1 dB → decrease reduction by min(6, −excess) per tick -``` - -This has a genuine equilibrium (`reduction ≈ margin_ema − target`), converges in a few 5 s ticks, and works unilaterally — no other node needs APC. In an all-APC network, mutual compensation double-counts and both sides settle *above* target (safe, slightly less savings). - -## True echoes (what counts as evidence) - -A dupe only proves our TX was received if it actually **routed through us**: - -- **Originated floods** (`path_pos = ORIGINATED`): nobody else had the content before our TX, so every dupe is a true echo. -- **Forwarded floods**: when we retransmit, we append our path hash at index `n`. A true echo carries our hash at exactly that position. Parallel retransmits by peers who heard the origin (including dupes heard *before our own TX airs* — tracking happens at queue time and the contention window can hold our TX for ~2 s) don't carry it and are rejected. - -Consequences of the gate: - -- The margin EMA measures the link to nodes **for whom we are upstream** — exactly the constituency that depends on our TX. -- Where nobody depends on us (edge repeater, quiet leaf area), true echoes are rare → staleness holds power at max. Conservative and correct. -- With 1-byte path hashes a false positive is a 1/256-per-dupe collision; the EMA absorbs it. - -The echo **source** is the *last* path entry (`path[count−1]` — the node that just transmitted this copy; every retransmitting node appends itself). Rev 1 keyed on `path[0]`, the origin's first hop, which is identical for every echo of a forwarded flood — that collapsed all echoers into one max-SNR source and disabled the rogue clustering entirely. - -## Important: Link Asymmetry - -APC measures the **return path** SNR (neighbor → us), not our outgoing SNR (us → neighbor). These differ when nodes have mismatched hardware — especially nodes with poor RX sensitivity ("bad ears") — or mismatched TX power (a 30 dBm neighbor overstates what it hears from our 22 dBm ceiling by 8 dB). - -**Path loss is reciprocal** (same frequency, same physical path), so echo SNR is a good proxy for link quality in most cases. The target margin provides a safety buffer for hardware asymmetry. - -**If your network has nodes with poor RX hardware or boosted TX power**, increase the target margin: -- Default: 16 dB (good for networks with similar hardware) -- 20-22 dB: recommended for mixed hardware networks -- 24-30 dB: very conservative, for networks with known bad receivers - -See [CLI Commands](#cli-commands) for how to change the margin at runtime. - -## Architecture - -### Class: `mesh::PowerController` - -Follows the `ContentionTracker` pattern: static ring buffer, EMA, `tick()` from maintenance loop. - -**File:** `include/mesh/PowerController.h`, `src/PowerController.cpp` - -``` -PowerController - _ring[16] <- tracks recently sent packets (FNV-1a hash) - _margin_ema_x256 <- EMA of link margin (SNR - SF_threshold), fixed-point - _power_reduction_db <- current TX power reduction (0 to MAX_REDUCTION) - _target_margin_x4 <- configurable target margin (default 64 = 16 dB) - _enabled <- runtime enable/disable (object defaults true; each role - applies prefs.apc_enabled at begin() — prefs default 0 = off) - _last_echo_ms <- timestamp of most recent true echo (staleness / re-warmup) - _noecho_streak <- consecutive unechoed TX (fast-recovery tripwire) - _echo_count/_noecho_count <- finalized-entry diagnostics (CLI) - _sf <- current spreading factor (for threshold lookup) -``` - -**Constants:** -| Parameter | Value | Rationale | -|-----------|-------|-----------| -| RING_SIZE | 16 | Match ContentionTracker; handles ~3 pkts/s with 5s window | -| ECHO_WINDOW_MS | 10,000 ms | 10s: covers SF12 2-hop echoes (~7s airtime + processing). Window starts at queue time, so a contention-delayed retransmit effectively gets ~8s post-TX | -| STALE_MS | 120,000 ms (2 min) | Mobile mesh — neighbors move/die fast | -| EMA_SHIFT | 2 (alpha=1/4) | More responsive than contention's 1/8 | -| WARMUP_COUNT | 3 | Need a few echoes before acting | -| MAX_SOURCES | 3 | Track up to 3 distinct echo sources per packet | -| STEP_DOWN_DB | 3 | Max reduction increase per tick (~halving power), conservative | -| STEP_UP_DB | 6 | Max reduction decrease per tick — power restores 2x faster than it drops | -| MAX_REDUCTION_DB | 12 | Floor at 10 dBm (from 22 max) | -| CLUSTER_WIDTH_X4 | 24 | 6 dB in x4 units — echo SNRs within 6 dB of best are clustered | -| DEFAULT_TARGET_MARGIN_X4 | 64 | 16 dB above SF sensitivity (configurable at runtime) | -| HYSTERESIS_X4 | 4 | 1 dB deadband around `margin_ema − target − reduction = 0` | -| NOECHO_TRIP_COUNT | 2 | Consecutive unechoed TX (while reduced) that restore full power immediately | -| PATH_POS_ORIGINATED | 0xFF | `path_pos` sentinel: we originated, any dupe is a true echo | - -**Public API:** -- `setEnabled(bool en)` / `isEnabled()` — runtime enable/disable -- `setSF(uint8_t sf)` — set current SF for margin calculation -- `setTargetMargin(uint8_t margin_db)` / `getTargetMargin()` — configure target link margin (default 16 dB) -- `trackTransmit(uint32_t hash32, uint32_t now_ms, uint8_t path_pos)` — called when we queue a flood for TX; `path_pos` = index of our appended hash, or `PATH_POS_ORIGINATED` -- `recordEcho(uint32_t hash32, int8_t snr_x4, uint32_t now_ms, const uint8_t *path, uint8_t path_count, uint8_t hash_size, const uint8_t *self_hash)` — called on every flood dupe; true-echo gate + last-hop attribution + per-source best SNR; returns true if the dupe matched and passed the gate -- `tick(uint32_t now_ms)` — finalize expired entries into EMA, run the compensated control step, handle staleness -- `getPowerReduction() const` -> `int8_t` (0 to MAX_REDUCTION_DB; returns 0 when disabled) -- `getMarginEstimate() const` -> `float` (dB, for diagnostics) -- `getEchoCount()` / `getNoEchoCount()` — finalized-entry counters (diagnostics) -- `getLastSourceCount() const` -> `uint8_t` (echo source count from most recent entry) -- `isWarmedUp() const` / `isStale(uint32_t now_ms) const` - -**Per-source SNR tracking in EchoEntry:** -```cpp -struct EchoEntry { - uint32_t hash32; - uint32_t timestamp_ms; - uint8_t source_count; - uint8_t sf_at_track; /* SF when packet was transmitted */ - uint8_t path_pos; /* our path index, or PATH_POS_ORIGINATED */ - int8_t reduction_at_track;/* power reduction in effect at track time */ - Source sources[MAX_SOURCES]; - bool active; -}; -``` - -Each entry stores the SF at track time (`sf_at_track`) so that margin calculation uses the correct threshold even if the radio SF changes while entries are in-flight, and the reduction at track time (`reduction_at_track`) for the no-echo encoding below. - -**`recordEcho` logic:** -1. Find matching entry by hash32 -2. Check if entry has expired (beyond ECHO_WINDOW_MS) — if so, finalize and reject -3. **True-echo gate**: if the entry is a forwarded flood (`path_pos != ORIGINATED`), require `path_count > path_pos` and `path[path_pos] == self_hash` — else reject -4. Derive the source: `src = path[count−1]` (the node that transmitted this copy) -5. **Stale-gap re-warmup**: if the previous echo was > STALE_MS ago, reset the EMA and warmup counter — an EMA from before the gap must be re-earned, one fresh echo must not re-apply old reduction -6. Update the source's SNR if better, or add a new source (up to MAX_SOURCES) -7. Update `_last_echo_ms` - -**Computing "robust SNR" when finalizing an entry (1+ sources):** -1. **1 source**: use its SNR directly (no rogue detection possible, and no need) -2. **2-3 sources**: sort descending, cluster within CLUSTER_WIDTH (6 dB) of the best: - - If 2+ in cluster -> median the cluster values (2: average, 3: middle) - - If only 1 in cluster (top value is isolated = rogue) -> drop it, use next source(s) - -**No-echo entries** (0 sources): nobody downstream decoded the TX. At the reduction R in effect when it was sent, that bounds the full-power margin at `margin ≤ R` — the sample is encoded as `margin = R` (the least pessimistic consistent value). Occasional misses (collision, RX duty cycle missing the echo) therefore nudge the EMA down gently instead of crashing it; real link loss is handled by the tripwire: - -**Fast-recovery tripwire:** `NOECHO_TRIP_COUNT` (2) consecutive unechoed transmissions while reduction > 0 → reduction slams to 0, EMA and warmup reset (re-earn from fresh samples). A mobile node that walked away from its neighbor recovers full power after two lost floods instead of waiting ~40 s of EMA lag. - -**Power adjustment algorithm (in `tick()`):** -1. Finalize expired entries into the EMA (x256 fixed-point, warmup seeding for first 3 entries) -2. **Staleness takes priority** (mutually exclusive with the control step): - - If `now − _last_echo_ms > STALE_MS (2 min)` -> ramp reduction toward 0 by STEP_UP_DB (6) per tick (full recovery from max in ~10 s) - - When stale, **never increase reduction** — old EMA data is unreliable -3. Otherwise run the compensated control step: - - `excess = margin_ema − target − reduction` (all x256) - - `excess > +1 dB` -> reduction += min(STEP_DOWN_DB, excess) - - `excess < −1 dB` -> reduction −= min(STEP_UP_DB, −excess) - - inside the deadband -> hold - - clamp to [0, MAX_REDUCTION_DB] - -### Integration points (all guarded by `#ifdef CONFIG_ZEPHCORE_APC`) - -**1. Track originated packets** — `src/Mesh.cpp` `sendFlood()` (both overloads) - -After `_tables->markSeen(packet)`: -```cpp -uint32_t h = ContentionTracker::computePacketHash32(packet); -_power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis(), - PowerController::PATH_POS_ORIGINATED); -``` - -**2. Track retransmitted packets** — `src/Mesh.cpp` `routeRecvPacket()` - -Alongside existing `_contention.trackRetransmit()`, after appending our hash at path index `n`: -```cpp -_power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis(), n); -``` - -**3. Record echoes** — `src/Mesh.cpp` `onRecvPacket()` - -In the flood dupe detection block, passing the raw path for gating + attribution: -```cpp -uint8_t hs = pkt->getPathHashSize(); -uint8_t self_hash[4]; -self_id.copyHashTo(self_hash, hs); -_power_ctrl.recordEcho(h, pkt->_snr, (uint32_t)_ms->getMillis(), - pkt->path, pkt->getPathHashCount(), hs, self_hash); -``` - -**4. Tick + propagate to radio** — `src/Mesh.cpp` `maintenanceLoop()` - -```cpp -_power_ctrl.tick(now); -_radio->setTxPowerReduction(_power_ctrl.getPowerReduction()); -``` - -**5. Apply power reduction** — `adapters/radio/LoRaRadioBase.cpp` `buildModemConfig()` - -After existing TX power clamps: -```cpp -cfg.tx_power -= _tx_power_reduction_db; -if (cfg.tx_power < -9) cfg.tx_power = -9; -``` - -The config cache (`configParamsEqual`) already compares `tx_power`, so a changed reduction naturally triggers `hwConfigure()` on next TX — no explicit reconfigure needed. - -**6. Set SF** — `RepeaterMesh::begin()`, `RoomServerMesh::begin()` and `CompanionMesh::begin()` / BLE radio param change callbacks. (`tempradio` overrides do NOT update APC's SF — a temp session at a different SF computes margins against the wrong threshold; acceptable for a short-lived diagnostic mode.) - -### Radio interface - -Virtual APC methods added to `mesh::Radio` (base class): -```cpp -virtual void setTxPowerReduction(int8_t reduction_db) { (void)reduction_db; } -virtual int8_t getTxPowerReduction() const { return 0; } -``` - -`LoRaRadioBase` overrides these with a stored `_tx_power_reduction_db` member. - -### Kconfig - -In `Kconfig` under "LoRa Power Saving" menu: - -```kconfig -config ZEPHCORE_APC - bool "Adaptive Power Control (APC)" - default y -``` - -**Compiled in by default, disabled at runtime.** The `apc_enabled` pref (default 0) -gates it per-node; `set tx apc` enables and persists. Build with -`-DCONFIG_ZEPHCORE_APC=n` to exclude the code entirely (zero overhead). Works for -both companion and repeater roles. - -### CLI commands - -**`get tx`** — shows current APC state: -``` -> 16dBm (apc=on max=22 reduction=6 margin=18.5 target=16) # APC enabled -> 22dBm (apc=off) # APC disabled -``` - -**`get tx apc`** — diagnostics-first form with echo counters: -``` -> apc=on effective=16dBm max=22 reduction=6 margin=18.5 target=16 echo=123 noecho=4 -> apc=off max=22dBm target=16 -``` -`echo`/`noecho` count finalized tracked transmissions with/without at least one true -echo — the noecho:echo ratio is the first thing to check when APC misbehaves in the -field (high noecho = nobody downstream, or gating rejecting everything). - -**`get apc.margin`** — shows current target margin: -``` -> 16 dB -``` - -**`set tx apc`** — enables APC (persisted). -``` -OK - tx power=22 dBm (apc=on) -``` - -**`set tx `** — disables APC and sets fixed TX power: -``` -OK - tx power=16 dBm (apc=off) -``` - -**`set apc.margin `** — set APC target link margin (range 6-30 dB, persisted): -``` -OK - APC target margin=20 dB -``` - -The user's TX power setting (`NodePrefs.tx_power_dbm`) is always the ceiling — APC only subtracts from it. Disabling clears APC runtime state; re-enabling starts from a fresh warmup. - -**Note:** Both the APC enable state (`apc_enabled`) and the target margin (`apc_margin`) are persisted in prefs and survive reboots. The companion default margin is 20 dB (mobile, set in `main_companion.cpp`); repeater default is 16 dB. - -### Target margin — what it means and how to choose - -The target margin is the SNR margin APC tries to maintain **at the downstream neighbor's receiver** (predicted as `echo_margin − reduction`), above the minimum required for reliable reception at the current SF. - -At equilibrium `reduction ≈ margin_ema − target`, so with echo margin 26 dB and target 16, reduction settles around 9-10 dB and the neighbor keeps ~16-17 dB of real margin. - -**Example scenarios:** - -**Scenario 1: Two good radios on a rooftop, 500m apart** -Echo SNR = +15 dB at SF8 (threshold −10) → margin_ema ≈ 25 dB. -Reduction converges to ~8-9 dB over three ticks (3 dB/tick, 1 dB deadband). -The echo SNR we measure stays +15 (it's their TX power, not ours); the -*neighbor* now hears us ~16-17 dB above threshold. Stable — no further movement. - -**Scenario 2: Good radio talking to a cheap node with -5 dB RX degradation** -You hear the echo at +15 dB, but the cheap node only hears you at +10 dB. -With default margin (16 dB): APC reduction ≈ 9; the cheap node really sees ~7-8 dB -above its threshold. Fading may bite. `set apc.margin 21` shifts the buffer to -cover the asymmetry. - -**Scenario 3: Your network has radios with 10+ dB RX variation** -Some nodes have external LNAs (+3 dB), others have bad antennas (-7 dB). -Total asymmetry up to 10 dB. Set `set apc.margin 22` to ensure the worst -receiver still gets 12 dB of real margin after APC reduces power. - -**Rule of thumb:** -- Default (16 dB): most networks -- Add the worst-case RX/TX asymmetry in your network to 16 dB -- If you don't know: 20 dB is a safe middle ground (the companion default) - -## What APC does NOT see - -- **Zero-hop packets** (local advertisements): not retransmitted, no echo. APC only tracks flood packets. -- **Outgoing SNR**: APC measures return-path SNR and predicts the outgoing margin via reciprocity + self-compensation. Hardware/TX-power asymmetry is covered only by the target-margin buffer. -- **Silent leaves**: a receive-only node that never transmits produces no echoes under any scheme. The target margin plus the 12 dB reduction cap is their only protection. -- **Per-neighbor granularity**: APC produces a single global power reduction. It does not adjust power per destination — the radio can only set one TX power at a time. -- **Echoes missed by RX duty cycle**: sniff mode can sleep through an echo; the miss is encoded conservatively (see no-echo policy) but adds noise. Expect slightly less reduction with `rx_duty_cycle` on. - -## Files created/modified - -| File | Action | -|------|--------| -| `include/mesh/PowerController.h` | **CREATE** — PowerController class | -| `src/PowerController.cpp` | **CREATE** — implementation | -| `include/mesh/Radio.h` | EDIT — added virtual `setTxPowerReduction`/`getTxPowerReduction` | -| `include/mesh/Mesh.h` | EDIT — added `_power_ctrl` member + accessors | -| `src/Mesh.cpp` | EDIT — 4 integration points | -| `adapters/radio/LoRaRadioBase.h` | EDIT — added `_tx_power_reduction_db` + override methods | -| `adapters/radio/LoRaRadioBase.cpp` | EDIT — apply reduction in `buildModemConfig()`, init member | -| `Kconfig` | EDIT — added `ZEPHCORE_APC` option | -| `CMakeLists.txt` | EDIT — conditional compile of `PowerController.cpp` | -| `helpers/CommonCLI.h` | EDIT — APC callbacks (`getAPCReduction`, `getAPCMargin`, `isAPCEnabled`, `setAPCEnabled`, `getAPCTargetMargin`, `setAPCTargetMargin`, `getAPCEchoCount`, `getAPCNoEchoCount`) | -| `helpers/CommonCLI.cpp` | EDIT — `get tx`, `get tx apc`, `get/set apc.margin`, `set tx` APC enable/disable | -| `app/RepeaterMesh.h` / `app/RoomServerMesh.h` | EDIT — APC callback overrides | -| `app/RepeaterMesh.cpp` / `app/RoomServerMesh.cpp` | EDIT — `_power_ctrl` setup in `begin()` | -| `app/CompanionMesh.h` / `app/CompanionMesh.cpp` | EDIT — APC hooks + `begin()`/BLE param setup | -| `src/main_companion.cpp` | EDIT — CLI callback wiring, companion margin default 20 | - -## Verification - -1. **Build test**: `west build -b rak4631 zephcore --pristine` and `west build -b wio_tracker_l1 zephcore --pristine` — both pass -2. **Kconfig disable**: Build with `-DCONFIG_ZEPHCORE_APC=n` — zero overhead -3. **CLI**: Flash a repeater, run `get tx` — should show `> 22dBm (apc=off)` initially (runtime default is off); after `set tx apc`, `> 22dBm (apc=on max=22 reduction=0 margin=0.0 target=16)` -4. **Functional**: Two nodes in close proximity (high SNR) exchanging flood traffic through a third (echo source). After a few exchanges, `get tx apc` should show reduction converging to ~`margin − target`, then holding (no ratcheting to max, no oscillation). -5. **Gating sanity**: on a repeater in a dense area, `noecho` counting up while `echo` stays low means no downstream nodes — reduction should stay near 0. That is correct behavior, not a bug. -6. **Staleness**: Power off the echoing neighbor. Within ~2 minutes, `get tx apc` should show reduction ramping to 0 (6 dB per 5 s tick). -7. **Fast recovery**: with reduction active, kill the echo path and send two floods — reduction should drop to 0 immediately after the second unechoed window expires (~20 s), not wait for the 2-min staleness. -8. **Override**: `set tx 16` disables APC and fixes power. `set tx apc` re-enables. -9. **Margin config**: `set apc.margin 20` changes the target. `get apc.margin` confirms. -10. **Logging**: `CONFIG_ZEPHCORE_MAIN_LOG_LEVEL_DBG` shows per-entry finalization; reduction changes, tripwire and stale-gap re-warmup log at INF. - -## Resolved decisions - -1. **No-echo encoding**: an unechoed TX at reduction R samples as `margin = R` (bounds the full-power margin from above). Gentle correction for occasional misses; the 2-strike tripwire handles real link loss. (Rev 1 used margin=0 unconditionally, which crashes the EMA on a single collision.) -2. **Staleness**: 2-minute timeout, recovery at STEP_UP_DB (6) per tick — full recovery from max reduction in ~10 s once triggered. When stale, APC never increases reduction. On the first echo after a stale gap, the EMA and warmup reset — old data cannot re-apply reduction. -3. **Max reduction**: 12 dB. Combined with the compensated law it is only reached when echo margin ≥ target + 12. -4. **Primary target**: SF8/BW62.5 (SNR threshold -10.0 dB). Threshold table covers SF5-SF12. -5. **Both roles**: APC is active for companions, repeaters and room servers. -6. **Rogue filtering via SNR clustering**: with 2+ distinct echo sources, cluster within 6 dB of the best; an isolated top outlier is dropped. Source identity = last path entry (the actual transmitter of the echo copy). -7. **Per-entry SF tracking**: margin calculation uses `sfThresholdX4(sf_at_track)` so SF changes mid-flight don't corrupt margins. -8. **Echo window**: 10 s (covers SF12 2-hop echoes at ~7 s; starts at queue time). -9. **CLI override**: `set tx ` disables APC and sets fixed power; `set tx apc` re-enables (fresh warmup — disable clears runtime state). -10. **Configurable target margin**: `set apc.margin <6-30>`, persisted. Default 16 dB (repeater), 20 dB (companion). -11. **True-echo gating**: forwarded floods only count dupes carrying our path hash at the position we appended it; originated floods count any dupe. This is what makes the no-echo signal honest on repeaters (rev 1 counted parallel retransmits of the origin's copy, including dupes heard before our own TX aired). -12. **Companion APC**: No app changes needed — the app's TX power setting changes the ceiling, APC subtracts from it. The companion applies `prefs.apc_enabled` / `prefs.apc_margin` at `begin()`. diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 2761c04..467fb90 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -238,11 +238,6 @@ void CompanionMesh::begin() if (vcontactClockValid()) { _vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime(); } -#ifdef CONFIG_ZEPHCORE_APC - _power_ctrl.setSF(prefs.sf); - _power_ctrl.setTargetMargin(prefs.apc_margin); - _power_ctrl.setEnabled(prefs.apc_enabled != 0); -#endif } bool CompanionMesh::allowPacketForward(const mesh::Packet *packet) @@ -2591,9 +2586,6 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) prefs.cr = cr; prefs.client_repeat = repeat; _store->savePrefs(prefs); -#ifdef CONFIG_ZEPHCORE_APC - _power_ctrl.setSF(sf); -#endif if (_radio_reconfig_cb) _radio_reconfig_cb(); LOG_INF("SET_RADIO_PARAMS: client_repeat=%d", repeat); sendPacketOk(); diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 6103ddf..116800f 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -183,37 +183,6 @@ public: * at CMD_APP_START, CMD_SET_DEVICE_TIME, and GPS time sync. */ void vcontactClockSynced(); -#ifdef CONFIG_ZEPHCORE_APC - /* Adaptive Power Control hooks used by the USB text CLI. */ - int8_t getAPCReduction() const { - return getPowerController().getPowerReduction(); - } - float getAPCMargin() const { - return getPowerController().getMarginEstimate(); - } - bool isAPCEnabled() const { - return getPowerController().isEnabled(); - } - void setAPCEnabled(bool en) { - getPowerController().setEnabled(en); - if (!en) { - _radio->setTxPowerReduction(0); - } - } - uint8_t getAPCTargetMargin() const { - return getPowerController().getTargetMargin(); - } - void setAPCTargetMargin(uint8_t margin_db) { - getPowerController().setTargetMargin(margin_db); - } - uint32_t getAPCEchoCount() const { - return getPowerController().getEchoCount(); - } - uint32_t getAPCNoEchoCount() const { - return getPowerController().getNoEchoCount(); - } -#endif - /** * Continue contact iteration (call each main loop iteration). * Returns true if contacts are still being sent. diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 7aafb38..ead2fa1 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -191,12 +191,14 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { fs_read(&file, prefs.owner_info, sizeof(prefs.owner_info)); /* ZephCore extensions — absent in old 290-byte files; fs_read past EOF is a * no-op so these fields keep the initNodePrefs() defaults the caller passed - * in (rx_boost=1, rx_duty_cycle=0, apc_enabled=0, apc_margin=16). The - * upgrade block below forces repeater-specific values for old files. */ + * in (rx_boost=1, rx_duty_cycle=0). The upgrade block below forces + * repeater-specific values for old files. */ fs_read(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); fs_read(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); - fs_read(&file, &prefs.apc_enabled, sizeof(prefs.apc_enabled)); - fs_read(&file, &prefs.apc_margin, sizeof(prefs.apc_margin)); + /* RESERVED — formerly apc_enabled / apc_margin (APC, removed in 1.16.6). + * Still consumed so the fields after them stay at their stored offsets. */ + fs_read(&file, &prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled)); + fs_read(&file, &prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin)); /* Flood hop-ceiling extensions (absent in <296-byte files; the no-op EOF * read leaves the constructor defaults flood_max_unscoped=64, flood_max_advert=8). */ fs_read(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); @@ -238,8 +240,6 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { if (prefs.loop_detect > LOOP_DETECT_STRICT) prefs.loop_detect = LOOP_DETECT_MINIMAL; if (prefs.rx_boost > 1) prefs.rx_boost = 0; if (prefs.rx_duty_cycle > 1) prefs.rx_duty_cycle = 0; - if (prefs.apc_enabled > 1) prefs.apc_enabled = 0; - 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 < CAD_OFFSET_MIN || prefs.cad_offset > CAD_OFFSET_MAX) prefs.cad_offset = 0; @@ -337,8 +337,10 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { /* ZephCore extensions */ fs_write(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); fs_write(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); - fs_write(&file, &prefs.apc_enabled, sizeof(prefs.apc_enabled)); - fs_write(&file, &prefs.apc_margin, sizeof(prefs.apc_margin)); + /* RESERVED — formerly apc_enabled / apc_margin (removed in 1.16.6). + * Written back unchanged to hold the layout. */ + fs_write(&file, &prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled)); + fs_write(&file, &prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin)); /* Flood hop-ceiling extensions (extend the format past 294 bytes) */ fs_write(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); fs_write(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 621db8a..0e7ab7d 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -971,11 +971,6 @@ void RepeaterMesh::begin(RepeaterDataStore* store) { * Mesh::begin() → Dispatcher::begin() → Radio::begin(). */ mesh::Mesh::begin(); _contention.setBackoffMultiplier(_prefs.backoff_multiplier); -#ifdef CONFIG_ZEPHCORE_APC - _power_ctrl.setSF(_prefs.sf); - _power_ctrl.setTargetMargin(_prefs.apc_margin); - _power_ctrl.setEnabled(_prefs.apc_enabled != 0); -#endif acl.load(_store->getAclPath(), self_id); region_map.load(_store->getRegionsPath()); diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index a7dceb5..aa76001 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -285,37 +285,6 @@ public: uint32_t getDutyCycleTimeoutRestarts() const override; void resetDutyCycleTimeoutRestarts() override; -#ifdef CONFIG_ZEPHCORE_APC - /* Adaptive Power Control callbacks */ - int8_t getAPCReduction() const override { - return getPowerController().getPowerReduction(); - } - float getAPCMargin() const override { - return getPowerController().getMarginEstimate(); - } - bool isAPCEnabled() const override { - return getPowerController().isEnabled(); - } - void setAPCEnabled(bool en) override { - getPowerController().setEnabled(en); - if (!en) { - _radio->setTxPowerReduction(0); - } - } - uint8_t getAPCTargetMargin() const override { - return getPowerController().getTargetMargin(); - } - void setAPCTargetMargin(uint8_t margin_db) override { - getPowerController().setTargetMargin(margin_db); - } - uint32_t getAPCEchoCount() const override { - return getPowerController().getEchoCount(); - } - uint32_t getAPCNoEchoCount() const override { - return getPowerController().getNoEchoCount(); - } -#endif - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index 1c932bf..33e3ee0 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -761,11 +761,6 @@ void RoomServerMesh::begin(RepeaterDataStore* store) { * Mesh::begin() → Dispatcher::begin() → Radio::begin(). */ mesh::Mesh::begin(); _contention.setBackoffMultiplier(_prefs.backoff_multiplier); -#ifdef CONFIG_ZEPHCORE_APC - _power_ctrl.setSF(_prefs.sf); - _power_ctrl.setTargetMargin(_prefs.apc_margin); - _power_ctrl.setEnabled(_prefs.apc_enabled != 0); -#endif acl.load(_store->getAclPath(), self_id); region_map.load(_store->getRegionsPath()); diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index a80800a..faef6c3 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -234,37 +234,6 @@ public: uint32_t getDutyCycleTimeoutRestarts() const override; void resetDutyCycleTimeoutRestarts() override; -#ifdef CONFIG_ZEPHCORE_APC - /* Adaptive Power Control callbacks */ - int8_t getAPCReduction() const override { - return getPowerController().getPowerReduction(); - } - float getAPCMargin() const override { - return getPowerController().getMarginEstimate(); - } - bool isAPCEnabled() const override { - return getPowerController().isEnabled(); - } - void setAPCEnabled(bool en) override { - getPowerController().setEnabled(en); - if (!en) { - _radio->setTxPowerReduction(0); - } - } - uint8_t getAPCTargetMargin() const override { - return getPowerController().getTargetMargin(); - } - void setAPCTargetMargin(uint8_t margin_db) override { - getPowerController().setTargetMargin(margin_db); - } - uint32_t getAPCEchoCount() const override { - return getPowerController().getEchoCount(); - } - uint32_t getAPCNoEchoCount() const override { - return getPowerController().getNoEchoCount(); - } -#endif - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); diff --git a/zephcore/boards/common/healthcheck.conf b/zephcore/boards/common/healthcheck.conf index 58323e9..2ffff91 100644 --- a/zephcore/boards/common/healthcheck.conf +++ b/zephcore/boards/common/healthcheck.conf @@ -6,7 +6,7 @@ # enables packet logging, and sizes buffers for the extra output. # ========== ZephCore module log levels ========== -# Main mesh logic (Mesh, BaseChatMesh, RepeaterMesh, Utils, PowerController) +# Main mesh logic (Mesh, BaseChatMesh, RepeaterMesh, Utils) CONFIG_ZEPHCORE_MAIN_LOG_LEVEL_DBG=y # LoRa radio layer (Dispatcher, SX126xRadio, LoRaRadioBase, PacketPool) diff --git a/zephcore/boards/common/zephcore_common.conf b/zephcore/boards/common/zephcore_common.conf index 1c31b49..f913416 100644 --- a/zephcore/boards/common/zephcore_common.conf +++ b/zephcore/boards/common/zephcore_common.conf @@ -151,7 +151,7 @@ CONFIG_BT_DIS_FW_REV=y # of truth for the C side, injected as -DFIRMWARE_VERSION). This Kconfig value can't # read a C macro, so it must be bumped here too. Phones that read DIS and phones # that query CMD_DEVICE_QUERY should see the same version. -CONFIG_BT_DIS_FW_REV_STR="1.16.5-zephcore" +CONFIG_BT_DIS_FW_REV_STR="1.16.6-zephcore" CONFIG_BT_DIS_SW_REV=y CONFIG_BT_DIS_SW_REV_STR="Zephyr" CONFIG_BT_DIS_PNP=n diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf index b9087e7..2be2e46 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf @@ -15,3 +15,30 @@ CONFIG_BT_DIS_MODEL_NUMBER_STR="Heltec WiFi LoRa 32 V3" # Flash size — 8MB (override esp32_common.conf 4MB default) CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y + +# No GPIO power domains on this board — board.overlay deletes the upstream +# vext_ctrl / adc_ctrl power-domain-gpio nodes and drives both rails with +# regulator-fixed instead (see the comments there). +# +# This has to be said explicitly because the upstream board Kconfig.defconfig +# (zephyr/boards/heltec/heltec_wifi_lora32_v3/Kconfig.defconfig) sets +# "config POWER_DOMAIN_GPIO / default y" WITHOUT the driver's own +# "depends on DT_HAS_POWER_DOMAIN_GPIO_ENABLED" guard. So with the DT nodes +# deleted the symbol still defaults y, while the driver's "select DEVICE_DEPS" +# — which lives in the guarded definition — does not fire. The driver then +# compiles with no instances, and once CONFIG_PM_DEVICE=y made its +# CONFIG_PM_DEVICE_POWER_DOMAIN block live it called device_supported_foreach(), +# which only exists under CONFIG_DEVICE_DEPS: implicit-declaration build error. +# +# Turning the subsystem off is the accurate fix — we genuinely have no power +# domains — and it avoids paying for DEVICE_DEPS just to compile a driver that +# would have zero instances. V4/V43 use ZephCore-local board definitions with +# no such defconfig and are unaffected. +# +# POWER_DOMAIN=n is the whole fix: drivers/CMakeLists.txt only descends into +# power_domain/ under it, so the .c never reaches the compiler. Note that +# CONFIG_POWER_DOMAIN_GPIO stays =y in the resulting .config — the board +# defconfig's default is outside the driver's "if POWER_DOMAIN" block, so it +# survives. Do NOT try to pin it =n here: the symbol is not assignable with the +# parent off, and Kconfig answers with a warning while keeping y. It is inert. +CONFIG_POWER_DOMAIN=n diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 71271ab..0a9b391 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -335,9 +335,6 @@ should ONLY contain settings that can't be inferred from hardware: CONFIG_FUEL_GAUGE=y Boards with AXP2101 or other I2C fuel gauge CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM Boards with external PA CONFIG_ZEPHCORE_MAX_TX_POWER_DBM Boards with external PA - CONFIG_ZEPHCORE_APC Adaptive Power Control — OFF by default. - Reduces TX power when echo SNR shows excess margin. - See apc.md for details on target margin tuning. AUTO-DETECTED (do NOT set in board.conf): CONFIG_PWM Auto from DT buzzer nodelabel diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 708f5cb..ce46bc5 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -116,8 +116,11 @@ void CommonCLI::loadPrefs(const char* path) { ok = ok && prefs_read(&file, _prefs->owner_info, sizeof(_prefs->owner_info)); // 170 ok = ok && prefs_read(&file, &_prefs->rx_boost, sizeof(_prefs->rx_boost)); // 290 ok = ok && prefs_read(&file, &_prefs->rx_duty_cycle, sizeof(_prefs->rx_duty_cycle)); // 291 - ok = ok && prefs_read(&file, &_prefs->apc_enabled, sizeof(_prefs->apc_enabled)); // 292 - ok = ok && prefs_read(&file, &_prefs->apc_margin, sizeof(_prefs->apc_margin)); // 293 + /* 292-293: RESERVED — formerly apc_enabled / apc_margin (APC, removed in + * 1.16.6). Still read so offset 294 onward stays where deployed nodes + * wrote it; the values are ignored. */ + ok = ok && prefs_read(&file, &_prefs->_reserved_apc_enabled, sizeof(_prefs->_reserved_apc_enabled)); // 292 + ok = ok && prefs_read(&file, &_prefs->_reserved_apc_margin, sizeof(_prefs->_reserved_apc_margin)); // 293 ok = ok && prefs_read(&file, &_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 294 ok = ok && prefs_read(&file, &_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 295 ok = ok && prefs_read(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); // 296 @@ -166,8 +169,6 @@ void CommonCLI::loadPrefs(const char* path) { _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, (uint8_t)0, (uint8_t)2); _prefs->rx_boost = constrain(_prefs->rx_boost, (uint8_t)0, (uint8_t)1); _prefs->rx_duty_cycle = constrain(_prefs->rx_duty_cycle, (uint8_t)0, (uint8_t)1); - _prefs->apc_enabled = constrain(_prefs->apc_enabled, (uint8_t)0, (uint8_t)1); - _prefs->apc_margin = constrain(_prefs->apc_margin, (uint8_t)6, (uint8_t)30); _prefs->flood_max_unscoped = constrain(_prefs->flood_max_unscoped, (uint8_t)0, (uint8_t)64); _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); @@ -240,8 +241,9 @@ void CommonCLI::savePrefs(const char* path) { fs_write(&file, _prefs->owner_info, sizeof(_prefs->owner_info)); fs_write(&file, &_prefs->rx_boost, sizeof(_prefs->rx_boost)); fs_write(&file, &_prefs->rx_duty_cycle, sizeof(_prefs->rx_duty_cycle)); - fs_write(&file, &_prefs->apc_enabled, sizeof(_prefs->apc_enabled)); - fs_write(&file, &_prefs->apc_margin, sizeof(_prefs->apc_margin)); + /* 292-293: RESERVED — formerly APC, written back unchanged. */ + fs_write(&file, &_prefs->_reserved_apc_enabled, sizeof(_prefs->_reserved_apc_enabled)); + fs_write(&file, &_prefs->_reserved_apc_margin, sizeof(_prefs->_reserved_apc_margin)); fs_write(&file, &_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); fs_write(&file, &_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); fs_write(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); @@ -493,8 +495,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch float ff = _callbacks->getFloodDelayFactor(); snprintf(reply, CLI_REPLY_SIZE, "> adaptive (est=%.1f flood=%.2f)", (double)est, (double)ff); - } else if (memcmp(config, "apc.margin", 10) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %d dB", (int)_callbacks->getAPCTargetMargin()); } else if (memcmp(config, "flood.max.advert", 16) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max_advert); } else if (memcmp(config, "flood.max.unscoped", 18) == 0) { @@ -526,34 +526,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "> strict"); } - } else if (strcmp(config, "tx apc") == 0) { - if (_callbacks->isAPCEnabled()) { - int8_t apc = _callbacks->getAPCReduction(); - float margin = _callbacks->getAPCMargin(); - int effective = (int)_prefs->tx_power_dbm - (int)apc; - snprintf(reply, CLI_REPLY_SIZE, - "> apc=on effective=%ddBm max=%d reduction=%d margin=%.1f target=%d echo=%u noecho=%u", - effective, (int)_prefs->tx_power_dbm, (int)apc, (double)margin, - (int)_callbacks->getAPCTargetMargin(), - (unsigned)_callbacks->getAPCEchoCount(), - (unsigned)_callbacks->getAPCNoEchoCount()); - } else { - snprintf(reply, CLI_REPLY_SIZE, "> apc=off max=%ddBm target=%d", - (int)_prefs->tx_power_dbm, (int)_callbacks->getAPCTargetMargin()); - } } else if (strcmp(config, "tx") == 0) { - if (_callbacks->isAPCEnabled()) { - int8_t apc = _callbacks->getAPCReduction(); - float margin = _callbacks->getAPCMargin(); - int effective = (int)_prefs->tx_power_dbm - (int)apc; - snprintf(reply, CLI_REPLY_SIZE, - "> %ddBm (apc=on max=%d reduction=%d margin=%.1f target=%d)", - effective, (int)_prefs->tx_power_dbm, (int)apc, (double)margin, - (int)_callbacks->getAPCTargetMargin()); - } else { - snprintf(reply, CLI_REPLY_SIZE, "> %ddBm (apc=off)", - (int)_prefs->tx_power_dbm); - } + /* Plain number, matching upstream Arduino MeshCore's "> %d". */ + snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->tx_power_dbm); } else if (memcmp(config, "freq", 4) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %.3f", (double)_prefs->freq); } else if (memcmp(config, "public.key", 10) == 0) { @@ -892,42 +867,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch savePrefs(); strcpy(reply, "OK"); } - } else if (memcmp(config, "apc.margin ", 11) == 0) { - int val = atoi(&config[11]); - if (val >= 6 && val <= 30) { - _prefs->apc_margin = (uint8_t)val; - _callbacks->setAPCTargetMargin((uint8_t)val); - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - APC target margin=%d dB", val); - } else { - strcpy(reply, "Error: range 6-30 dB"); - } } else if (memcmp(config, "tx ", 3) == 0) { - if (strcmp(&config[3], "apc") == 0) { - _prefs->apc_enabled = 1; - _callbacks->setAPCEnabled(true); - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm (apc=on)", - (int)_prefs->tx_power_dbm); - } else { - char *end = nullptr; - long parsed = strtol(&config[3], &end, 10); - int max_tx = 30; + char *end = nullptr; + long parsed = strtol(&config[3], &end, 10); + int max_tx = 30; #ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM - max_tx = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; + max_tx = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; #endif - if (end == &config[3] || *end != '\0' || parsed < -9 || parsed > max_tx) { - snprintf(reply, CLI_REPLY_SIZE, "Error: range -9 to %d dBm, or 'apc'", max_tx); - } else { - int val = (int)parsed; - _prefs->apc_enabled = 0; - _prefs->tx_power_dbm = (int8_t)val; - savePrefs(); - _callbacks->setAPCEnabled(false); - _callbacks->setTxPower(_prefs->tx_power_dbm); - snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm (apc=off)", - (int)_prefs->tx_power_dbm); - } + if (end == &config[3] || *end != '\0' || parsed < -9 || parsed > max_tx) { + snprintf(reply, CLI_REPLY_SIZE, "Error: range -9 to %d dBm", max_tx); + } else { + _prefs->tx_power_dbm = (int8_t)parsed; + savePrefs(); + _callbacks->setTxPower(_prefs->tx_power_dbm); + snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm", + (int)_prefs->tx_power_dbm); } } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { float f = atof(&config[5]); diff --git a/zephcore/helpers/CommonCLI.h b/zephcore/helpers/CommonCLI.h index 103253a..1a66688 100644 --- a/zephcore/helpers/CommonCLI.h +++ b/zephcore/helpers/CommonCLI.h @@ -74,16 +74,6 @@ public: virtual uint32_t getDutyCycleTimeoutRestarts() const { return 0; } virtual void resetDutyCycleTimeoutRestarts() {} - // Adaptive Power Control - virtual int8_t getAPCReduction() const { return 0; } - virtual float getAPCMargin() const { return 0.0f; } - virtual bool isAPCEnabled() const { return false; } - virtual void setAPCEnabled(bool en) { (void)en; } - virtual uint8_t getAPCTargetMargin() const { return 16; } - virtual void setAPCTargetMargin(uint8_t margin_db) { (void)margin_db; } - virtual uint32_t getAPCEchoCount() const { return 0; } - virtual uint32_t getAPCNoEchoCount() const { return 0; } - // Adaptive CAD (LBT detPeak calibration) virtual int formatCadStatus(char* buf, int cap) { (void)buf; (void)cap; return 0; } virtual void applyCadPrefs() {} diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index fc88c99..c5e4142 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -74,8 +74,16 @@ struct NodePrefs { char owner_info[120]; uint8_t rx_boost; // 1 = boosted RX gain (+3dB), 0 = power save uint8_t rx_duty_cycle; // 1 = RX duty cycle, 0 = continuous RX - uint8_t apc_enabled; // 1 = APC on, 0 = fixed TX power - uint8_t apc_margin; // APC target link margin dB (6-30) + /* RESERVED — formerly apc_enabled / apc_margin (Adaptive Power Control, + * removed in 1.16.6). These two bytes are still read and written at their + * original offsets in all three prefs serializers (companion new_prefs 94/95, + * repeater prefs 292/293, RepeaterDataStore) because every field after them + * is positional: dropping them would shift the rest of the layout and make + * every already-deployed node misparse its saved prefs on upgrade. + * Do not reuse for a new setting — an upgraded node still has the old APC + * values sitting in these bytes. */ + uint8_t _reserved_apc_enabled; + uint8_t _reserved_apc_margin; uint8_t meshtimesync; // 1 = mesh time-sync clock correction on (default off) uint8_t cad_auto; // 1 = adaptive-CAD staircase acts on probe stats (default off = dry-run) int8_t cad_offset; // operating detPeak offset from family base (-4..4) @@ -150,8 +158,8 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->adc_multiplier = 0.0f; prefs->rx_boost = 1; // Default to boosted RX for better sensitivity 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->_reserved_apc_enabled = 0; // reserved (was APC), see NodePrefs + prefs->_reserved_apc_margin = 0; // reserved (was APC), see NodePrefs 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 diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index fe42417..a806b14 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -789,14 +789,11 @@ static void render_radio_mono(void) const char *rx_mode = state.lora_rx_duty_cycle ? "DC" : "CONT"; if (ui_tiny()) { - int tx = state.lora_effective_tx_power > 0 - ? state.lora_effective_tx_power : state.lora_tx_power; - snprintf(buf, sizeof(buf), "%u.%uM", freq_mhz, freq_frac / 100); draw_centered(centered_row(0, 3), buf); snprintf(buf, sizeof(buf), "SF%u BW%u", state.lora_sf, bw_int); draw_centered(centered_row(1, 3), buf); - snprintf(buf, sizeof(buf), "P%d %s", tx, rx_mode); + snprintf(buf, sizeof(buf), "P%d %s", state.lora_tx_power, rx_mode); draw_centered(centered_row(2, 3), buf); return; } @@ -817,20 +814,8 @@ static void render_radio_mono(void) mc_display_text(0, y, buf, false); y += LINE_H; - if (state.lora_apc_enabled) { - snprintf(buf, sizeof(buf), "TX:%d/%ddBm APC:on", - state.lora_effective_tx_power, state.lora_tx_power); - } else { - snprintf(buf, sizeof(buf), "TX:%ddBm APC:off", state.lora_tx_power); - } - mc_display_text(0, y, buf, false); - y += LINE_H; - - snprintf(buf, sizeof(buf), "R%d M%d.%d T%u %s/%s", - state.lora_apc_reduction, - state.lora_apc_margin_x10 / 10, - abs(state.lora_apc_margin_x10 % 10), - state.lora_apc_target_margin, packet_state, rx_mode); + snprintf(buf, sizeof(buf), "TX:%ddBm %s/%s", + state.lora_tx_power, packet_state, rx_mode); mc_display_text(0, y, buf, false); y += LINE_H; @@ -853,20 +838,14 @@ static void render_radio_color(void) uint16_t bw_frac = state.lora_bw_khz_x10 % 10; const char *packet_state = radio_state_label(); const char *rx_mode = state.lora_rx_duty_cycle ? "DC" : "CONT"; - uint16_t warn_color = (state.lora_apc_enabled && state.lora_apc_reduction > 0) - ? UI_COLOR_WARN : UI_COLOR_OK; { uint16_t state_color = state.lora_tx_active ? UI_COLOR_WARN : state.lora_in_rx ? UI_COLOR_ACTIVE : state.lora_radio_ready ? UI_COLOR_OK : UI_COLOR_DISABLED; - uint16_t tx_color = (state.lora_apc_enabled && - state.lora_apc_reduction > 0) - ? UI_COLOR_WARN : UI_COLOR_OK; + uint16_t tx_color = UI_COLOR_OK; int max_tx = state.lora_tx_power > 0 ? state.lora_tx_power : 22; - int eff_tx = state.lora_effective_tx_power > 0 - ? state.lora_effective_tx_power : state.lora_tx_power; int badge_x; mc_display_color_fill_rect(0, y - 1, DISP_W, COLOR_FONT_H + 2, @@ -895,25 +874,10 @@ static void render_radio_color(void) draw_color_segments(y, "LoRa ", buf, UI_COLOR_VALUE); y += LINE_H; - if (state.lora_apc_enabled) { - snprintf(buf, sizeof(buf), "%d/%ddBm", - state.lora_effective_tx_power, state.lora_tx_power); - } else { - snprintf(buf, sizeof(buf), "%ddBm", state.lora_tx_power); - } + snprintf(buf, sizeof(buf), "%ddBm", state.lora_tx_power); draw_color_segments(y, "TX ", buf, tx_color); - draw_metric_bar(DISP_W - 50, y + 2, 48, 5, eff_tx, max_tx, tx_color); - y += LINE_H; - - snprintf(buf, sizeof(buf), "red %d M%d.%d T%u", - state.lora_apc_reduction, - state.lora_apc_margin_x10 / 10, - abs(state.lora_apc_margin_x10 % 10), - state.lora_apc_target_margin); - draw_color_segments(y, "APC ", buf, warn_color); - draw_badge(DISP_W - color_text_width("APC") - 4, y, "APC", - !state.lora_apc_enabled ? UI_COLOR_DISABLED : - state.lora_apc_reduction > 0 ? UI_COLOR_WARN : UI_COLOR_OK); + draw_metric_bar(DISP_W - 50, y + 2, 48, 5, state.lora_tx_power, + max_tx, tx_color); y += LINE_H; char rx_count[6]; diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index 8b72459..a41f724 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -65,11 +65,6 @@ struct ui_state { uint8_t lora_cr; int8_t lora_tx_power; int16_t lora_noise_floor; - int8_t lora_effective_tx_power; - bool lora_apc_enabled; - int8_t lora_apc_reduction; - int16_t lora_apc_margin_x10; - uint8_t lora_apc_target_margin; uint8_t lora_sync_word; uint16_t lora_preamble_len; bool lora_rx_duty_cycle; diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index 2451891..8fdea15 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -908,30 +908,18 @@ void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, } } -void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, - int8_t apc_reduction, int16_t apc_margin_x10, - uint8_t apc_target_margin, uint8_t sync_word, +void ui_set_radio_runtime(uint8_t sync_word, uint16_t preamble_len, bool rx_duty_cycle, bool radio_ready, bool in_rx, bool tx_active) { struct ui_state *s = get_state(); - bool changed = s->lora_effective_tx_power != effective_tx_power || - s->lora_apc_enabled != apc_enabled || - s->lora_apc_reduction != apc_reduction || - s->lora_apc_margin_x10 != apc_margin_x10 || - s->lora_apc_target_margin != apc_target_margin || - s->lora_sync_word != sync_word || + bool changed = s->lora_sync_word != sync_word || s->lora_preamble_len != preamble_len || s->lora_rx_duty_cycle != rx_duty_cycle || s->lora_radio_ready != radio_ready || s->lora_in_rx != in_rx || s->lora_tx_active != tx_active; - s->lora_effective_tx_power = effective_tx_power; - s->lora_apc_enabled = apc_enabled; - s->lora_apc_reduction = apc_reduction; - s->lora_apc_margin_x10 = apc_margin_x10; - s->lora_apc_target_margin = apc_target_margin; s->lora_sync_word = sync_word; s->lora_preamble_len = preamble_len; s->lora_rx_duty_cycle = rx_duty_cycle; diff --git a/zephcore/helpers/ui-joystick/joystick_defs.h b/zephcore/helpers/ui-joystick/joystick_defs.h index aeda5b1..5366fbb 100644 --- a/zephcore/helpers/ui-joystick/joystick_defs.h +++ b/zephcore/helpers/ui-joystick/joystick_defs.h @@ -8,7 +8,28 @@ #pragma once -/* ===== Key codes ===== */ +/* ===== Key codes ===== + * + * These are the UI's INTERNAL codes, carried as a single char through + * JoystickUITask::enqueueKey(). They are deliberately NOT the Zephyr + * INPUT_KEY_* codes — joystick_ui_input_cb() translates between the two — and + * they are never persisted or transmitted, so they can be renumbered freely. + * + * Key-space contract (keep this true): + * 0x01-0x1F control keys (navigation, enter, cancel) + * 0x20-0x7E RESERVED for printable characters typed on a keyboard + * 0xF1-0xFF long-press and global action keys + * + * The printable range is reserved so a keyboard board (ThinkNode M9's STC8H + * matrix MCU at I2C 0x6C) can enqueue characters directly without colliding + * with a control code. The multi-tap action keys below used to sit at + * 0x42-0x45 — i.e. on 'B'..'E' — which would have made typing "BCDE" fire a + * flood advert, toggle GPS, mute the buzzer and kill the LED. + * + * Note KEY_ENTER and KEY_CANCEL are already ASCII CR and ESC, so a keyboard + * that reports plain ASCII (the convention for this class of I2C matrix MCU) + * produces correct enter/cancel with no translation at all. + */ #define KEY_ENTER 0x0D /* center/OK button click */ #define KEY_LEFT 0x01 /* joystick left */ #define KEY_RIGHT 0x02 /* joystick right */ @@ -24,11 +45,13 @@ #define KEY_TO_BOTTOM 0xF3 /* long press down → page down */ #define KEY_LOCK 0xF4 /* user button + joystick center held together → screen lock */ -/* Global action keys emitted by multi tap filter, handled in loop() */ -#define KEY_FLOOD_ADVERT 0x42 /* INPUT_KEY_B: 2 taps → flood advert */ -#define KEY_BUZZ_TOGGLE 0x44 /* INPUT_KEY_D: 3 taps → buzzer mute toggle */ -#define KEY_GPS_TOGGLE 0x43 /* INPUT_KEY_C: 4 taps → GPS on/off */ -#define KEY_LED_TOGGLE 0x45 /* INPUT_KEY_E: 5 taps → LED heartbeat toggle */ +/* Global action keys emitted by multi tap filter, handled in loop(). + * Kept above 0xF0 so they stay clear of the printable range — see the + * key-space contract above. */ +#define KEY_FLOOD_ADVERT 0xF5 /* INPUT_KEY_B: 2 taps → flood advert (unused) */ +#define KEY_BUZZ_TOGGLE 0xF6 /* INPUT_KEY_D: 3 taps → buzzer mute toggle */ +#define KEY_GPS_TOGGLE 0xF7 /* INPUT_KEY_C: 4 taps → GPS on/off */ +#define KEY_LED_TOGGLE 0xF8 /* INPUT_KEY_E: 5 taps → LED heartbeat toggle */ /* ===== Layout constants (calibrated for 128x64 OLED, 6x8 font) ===== */ /* All hard-coded offsets from old Arduino code are preserved here. diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp index a453307..b78b622 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -218,14 +218,11 @@ extern "C" void ui_set_radio_params( } } -extern "C" void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, - int8_t apc_reduction, int16_t apc_margin_x10, - uint8_t apc_target_margin, uint8_t sync_word, +extern "C" void ui_set_radio_runtime(uint8_t sync_word, uint16_t preamble_len, bool rx_duty_cycle, bool radio_ready, bool in_rx, bool tx_active) { - (void)effective_tx_power; (void)apc_enabled; (void)apc_reduction; - (void)apc_margin_x10; (void)apc_target_margin; (void)sync_word; + (void)sync_word; (void)preamble_len; (void)rx_duty_cycle; (void)radio_ready; (void)in_rx; (void)tx_active; } diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index 3f3b2c1..76fa5f8 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -49,15 +49,11 @@ WEAK void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, ARG_UNUSED(cr); ARG_UNUSED(tx_power); ARG_UNUSED(noise_floor); } -WEAK void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, - int8_t apc_reduction, int16_t apc_margin_x10, - uint8_t apc_target_margin, uint8_t sync_word, +WEAK void ui_set_radio_runtime(uint8_t sync_word, uint16_t preamble_len, bool rx_duty_cycle, bool radio_ready, bool in_rx, bool tx_active) { - ARG_UNUSED(effective_tx_power); ARG_UNUSED(apc_enabled); - ARG_UNUSED(apc_reduction); ARG_UNUSED(apc_margin_x10); - ARG_UNUSED(apc_target_margin); ARG_UNUSED(sync_word); + ARG_UNUSED(sync_word); ARG_UNUSED(preamble_len); ARG_UNUSED(rx_duty_cycle); ARG_UNUSED(radio_ready); ARG_UNUSED(in_rx); ARG_UNUSED(tx_active); } diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 6870a71..90c5103 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -340,31 +340,13 @@ extern "C" void mesh_housekeeping_ui_refresh(void) s_lora_radio->getActiveCodingRate(), s_lora_radio->getConfiguredTxPower(), s_lora_radio->getNoiseFloor()); - { - bool apc_enabled = false; - int8_t apc_reduction = 0; - int16_t apc_margin_x10 = 0; - uint8_t apc_target = s_mesh->prefs.apc_margin; - -#ifdef CONFIG_ZEPHCORE_APC - apc_enabled = s_mesh->isAPCEnabled(); - apc_reduction = s_mesh->getAPCReduction(); - apc_margin_x10 = (int16_t)(s_mesh->getAPCMargin() * 10.0f); - apc_target = s_mesh->getAPCTargetMargin(); -#endif - ui_set_radio_runtime( - s_lora_radio->getEffectiveTxPower(), - apc_enabled, - apc_reduction, - apc_margin_x10, - apc_target, - s_lora_radio->getActiveSyncWord(), - s_lora_radio->getActivePreambleLength(), - s_lora_radio->isRxDutyCycleEnabled(), - s_lora_radio->isRadioReady(), - s_lora_radio->isInRecvMode(), - s_lora_radio->isTxActive()); - } + ui_set_radio_runtime( + s_lora_radio->getActiveSyncWord(), + s_lora_radio->getActivePreambleLength(), + s_lora_radio->isRxDutyCycleEnabled(), + s_lora_radio->isRadioReady(), + s_lora_radio->isInRecvMode(), + s_lora_radio->isTxActive()); ui_set_radio_stats( s_lora_radio->getPacketsRecv(), s_lora_radio->getPacketsSent(), diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index 36488f2..0401bb1 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -85,11 +85,9 @@ void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, uint8_t cr, int8_t tx_power, int16_t noise_floor); /** - * Update extended live radio/APC details for display. + * Update extended live radio details for display. */ -void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, - int8_t apc_reduction, int16_t apc_margin_x10, - uint8_t apc_target_margin, uint8_t sync_word, +void ui_set_radio_runtime(uint8_t sync_word, uint16_t preamble_len, bool rx_duty_cycle, bool radio_ready, bool in_rx, bool tx_active); diff --git a/zephcore/include/mesh/Mesh.h b/zephcore/include/mesh/Mesh.h index edcbe20..6f99d74 100644 --- a/zephcore/include/mesh/Mesh.h +++ b/zephcore/include/mesh/Mesh.h @@ -7,9 +7,6 @@ #include #include -#ifdef CONFIG_ZEPHCORE_APC -#include -#endif #include namespace mesh { @@ -43,11 +40,6 @@ public: ContentionTracker& getContentionTracker() { return _contention; } const ContentionTracker& getContentionTracker() const { return _contention; } protected: -#ifdef CONFIG_ZEPHCORE_APC - PowerController _power_ctrl; - PowerController& getPowerController() { return _power_ctrl; } - const PowerController& getPowerController() const { return _power_ctrl; } -#endif void extendPendingRetransmit(uint32_t hash32); DispatcherAction onRecvPacket(Packet *pkt) override; diff --git a/zephcore/include/mesh/PowerController.h b/zephcore/include/mesh/PowerController.h deleted file mode 100644 index 27f6eb2..0000000 --- a/zephcore/include/mesh/PowerController.h +++ /dev/null @@ -1,135 +0,0 @@ -/* - * SPDX-License-Identifier: MIT - * Adaptive Power Control (APC) — echo-based TX power reduction - * - * Measures the full-power link budget to downstream neighbors by - * tracking true echoes: flood dupes that provably routed through - * this node (our path hash at the position we appended it). Echo - * SNR is measured from the NEIGHBOR's fixed-power TX, so it does - * not respond to our own reduction — the control law subtracts the - * current reduction itself (path reciprocity) and regulates the - * neighbor's predicted margin to the target. - * - * Rogue filtering: when 2+ distinct neighbors echo the same packet, - * clusters their SNRs within 6 dB of the best. An isolated high - * outlier (rogue, badly placed neighbor) is dropped. - */ - -#pragma once - -#include - -namespace mesh { - -class PowerController { -public: - /* path_pos sentinel for packets we originated: nobody else had the - * content before our TX, so every dupe is a true echo. */ - static constexpr uint8_t PATH_POS_ORIGINATED = 0xFF; - - PowerController(); - - /* Enable/disable APC. When disabled, APC tracking/math is bypassed. */ - void setEnabled(bool en); - bool isEnabled() const { return _enabled; } - - /* Set current spreading factor (needed for margin calculation). */ - void setSF(uint8_t sf) { _sf = sf; } - - /* Set target link margin in dB. Higher = more conservative - * (better for networks with poor-RX hardware). Default 16 dB. */ - void setTargetMargin(uint8_t margin_db) { _target_margin_x4 = (int)margin_db * 4; } - uint8_t getTargetMargin() const { return (uint8_t)(_target_margin_x4 / 4); } - - /* Called when we queue a flood packet for TX. path_pos = path index - * at which we appended our own hash (forwarded floods), or - * PATH_POS_ORIGINATED for packets we originated. */ - void trackTransmit(uint32_t hash32, uint32_t now_ms, uint8_t path_pos); - - /* Called for every received flood dupe. Verifies the dupe actually - * routed through us (true echo), attributes it to the transmitting - * neighbor (last path entry) and updates that source's best SNR. - * Returns true if the dupe matched a tracked transmit and passed - * the true-echo gate. */ - bool recordEcho(uint32_t hash32, int8_t snr_x4, uint32_t now_ms, - const uint8_t *path, uint8_t path_count, - uint8_t hash_size, const uint8_t *self_hash); - - /* Finalize expired entries, update EMA, adjust power, handle - * staleness. Call from maintenanceLoop (~5 s). */ - void tick(uint32_t now_ms); - - /* Current TX power reduction in dBm (0 to MAX_REDUCTION_DB). - * Returns 0 when disabled. */ - int8_t getPowerReduction() const { return _enabled ? _power_reduction_db : 0; } - - /* Current margin estimate in dB (for diagnostics). */ - float getMarginEstimate() const; - - /* Source count from most recently finalized entry (diagnostics). */ - uint8_t getLastSourceCount() const { return _last_source_count; } - - /* Finalized-entry counters (diagnostics): entries with at least one - * true echo vs entries that expired unechoed. */ - uint32_t getEchoCount() const { return _echo_count; } - uint32_t getNoEchoCount() const { return _noecho_count; } - - bool isWarmedUp() const { return _finalized_count >= WARMUP_COUNT; } - bool isStale(uint32_t now_ms) const; - -private: - static constexpr int RING_SIZE = 16; - static constexpr uint32_t ECHO_WINDOW_MS = 10000; /* 10s: covers SF12 2-hop echo */ - static constexpr uint32_t STALE_MS = 120000; /* 2 min */ - static constexpr int EMA_SHIFT = 2; /* alpha = 1/4 */ - static constexpr int WARMUP_COUNT = 3; - static constexpr int MAX_SOURCES = 3; - static constexpr int8_t STEP_DOWN_DB = 3; /* max reduction increase per tick */ - static constexpr int8_t STEP_UP_DB = 6; /* max reduction decrease per tick */ - static constexpr int8_t MAX_REDUCTION_DB = 12; - static constexpr int CLUSTER_WIDTH_X4 = 24; /* 6 dB in x4 */ - static constexpr int DEFAULT_TARGET_MARGIN_X4 = 64; /* 16 dB * 4 */ - static constexpr int HYSTERESIS_X4 = 4; /* 1 dB * 4 deadband */ - /* Consecutive unechoed transmissions (while reduced) that trip an - * immediate return to full power + margin re-warmup. */ - static constexpr int NOECHO_TRIP_COUNT = 2; - - struct Source { - uint8_t hash; - int8_t snr_x4; - }; - - struct EchoEntry { - uint32_t hash32; - uint32_t timestamp_ms; - uint8_t source_count; - uint8_t sf_at_track; /* SF when packet was transmitted */ - uint8_t path_pos; /* our path index, or PATH_POS_ORIGINATED */ - int8_t reduction_at_track; /* power reduction in effect at track time */ - Source sources[MAX_SOURCES]; - bool active; - }; - - EchoEntry _ring[RING_SIZE]; - int _next_idx; - int32_t _margin_ema_x256; /* fixed-point EMA (x4 * 64) */ - int _finalized_count; - uint32_t _last_echo_ms; - uint32_t _echo_count; - uint32_t _noecho_count; - uint8_t _noecho_streak; - int8_t _power_reduction_db; - bool _enabled; - uint8_t _sf; - uint8_t _last_source_count; - int _target_margin_x4; - - void finalizeEntry(int idx); - int findEntry(uint32_t hash32) const; - int8_t computeRobustSNR(const EchoEntry &entry) const; - - /* SNR threshold for a given SF (x4 fixed point). */ - static int8_t sfThresholdX4(uint8_t sf); -}; - -} /* namespace mesh */ diff --git a/zephcore/include/mesh/Radio.h b/zephcore/include/mesh/Radio.h index c9ca97e..d4f8bf3 100644 --- a/zephcore/include/mesh/Radio.h +++ b/zephcore/include/mesh/Radio.h @@ -38,8 +38,6 @@ public: virtual float getLastSNR() const { return 0; } /* Adaptive Power Control */ - virtual void setTxPowerReduction(int8_t reduction_db) { (void)reduction_db; } - virtual int8_t getTxPowerReduction() const { return 0; } /* Adaptive CAD (LBT detPeak calibration). Default no-ops for radios * without hardware CAD (SX127x). */ diff --git a/zephcore/src/Mesh.cpp b/zephcore/src/Mesh.cpp index b4467b9..a14acfb 100644 --- a/zephcore/src/Mesh.cpp +++ b/zephcore/src/Mesh.cpp @@ -32,10 +32,6 @@ void Mesh::maintenanceLoop() Dispatcher::maintenanceLoop(); uint32_t now = (uint32_t)_ms->getMillis(); _contention.tick(now); -#ifdef CONFIG_ZEPHCORE_APC - _power_ctrl.tick(now); - _radio->setTxPowerReduction(_power_ctrl.getPowerReduction()); -#endif } void Mesh::extendPendingRetransmit(uint32_t hash32) @@ -126,11 +122,6 @@ DispatcherAction Mesh::routeRecvPacket(Packet *packet) packet->setPathHashCount(n + 1); uint32_t h = ContentionTracker::computePacketHash32(packet); _contention.trackRetransmit(h, (uint32_t)_ms->getMillis()); -#ifdef CONFIG_ZEPHCORE_APC - /* We appended our own hash at path index n — true echoes must - * carry it there. */ - _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis(), n); -#endif uint32_t d = getRetransmitDelay(packet); return ACTION_RETRANSMIT_DELAYED(packet->getPathHashCount(), d); // give priority to closer sources } @@ -266,16 +257,6 @@ DispatcherAction Mesh::onRecvPacket(Packet *pkt) /* Record dupes for contention tracking + reactive backoff */ if (pkt->isRouteFlood()) { uint32_t h = ContentionTracker::computePacketHash32(pkt); -#ifdef CONFIG_ZEPHCORE_APC - { - uint8_t hs = pkt->getPathHashSize(); - uint8_t self_hash[4]; /* max path hash size (path_len bits 7:6 + 1) */ - self_id.copyHashTo(self_hash, hs); - _power_ctrl.recordEcho(h, pkt->_snr, (uint32_t)_ms->getMillis(), - pkt->path, pkt->getPathHashCount(), hs, - self_hash); - } -#endif if (_contention.recordDupeIfTracked(h, (uint32_t)_ms->getMillis())) { extendPendingRetransmit(h); } else if (passivelyTrackFloods()) { @@ -558,14 +539,6 @@ void Mesh::sendFlood(Packet *packet, uint32_t delay_millis, uint8_t path_hash_si packet->header |= ROUTE_TYPE_FLOOD; packet->setPathHashSizeAndCount(path_hash_size, 0); _tables->markSeen(packet); /* mark as already sent in case it is rebroadcast back to us */ -#ifdef CONFIG_ZEPHCORE_APC - { - uint32_t h = ContentionTracker::computePacketHash32(packet); - _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis(), - PowerController::PATH_POS_ORIGINATED); - } -#endif - uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { pri = 2; @@ -594,14 +567,6 @@ void Mesh::sendFlood(Packet *packet, uint16_t *transport_codes, uint32_t delay_m packet->transport_codes[1] = transport_codes[1]; packet->setPathHashSizeAndCount(path_hash_size, 0); _tables->markSeen(packet); /* mark as already sent in case it is rebroadcast back to us */ -#ifdef CONFIG_ZEPHCORE_APC - { - uint32_t h = ContentionTracker::computePacketHash32(packet); - _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis(), - PowerController::PATH_POS_ORIGINATED); - } -#endif - uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { pri = 2; diff --git a/zephcore/src/PowerController.cpp b/zephcore/src/PowerController.cpp deleted file mode 100644 index b1f594c..0000000 --- a/zephcore/src/PowerController.cpp +++ /dev/null @@ -1,347 +0,0 @@ -/* - * SPDX-License-Identifier: MIT - * Adaptive Power Control — echo-based TX power reduction - */ - -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_apc, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); - -/* SNR demodulation thresholds per SF (x4 fixed point, SX126x DS table) */ -static constexpr int8_t snr_threshold_x4[] = { - -10, /* SF5: -2.5 dB */ - -20, /* SF6: -5.0 dB */ - -30, /* SF7: -7.5 dB */ - -40, /* SF8: -10.0 dB */ - -50, /* SF9: -12.5 dB */ - -60, /* SF10: -15.0 dB */ - -70, /* SF11: -17.5 dB */ - -80, /* SF12: -20.0 dB */ -}; - -namespace mesh { - -PowerController::PowerController() - : _next_idx(0), _margin_ema_x256(0), _finalized_count(0), - _last_echo_ms(0), _echo_count(0), _noecho_count(0), _noecho_streak(0), - _power_reduction_db(0), _enabled(true), - _sf(8), _last_source_count(0), _target_margin_x4(DEFAULT_TARGET_MARGIN_X4) -{ - memset(_ring, 0, sizeof(_ring)); -} - -void PowerController::setEnabled(bool en) -{ - if (_enabled == en) return; - _enabled = en; - if (!_enabled) { - /* Drop APC runtime state while disabled so no tracking work runs. */ - memset(_ring, 0, sizeof(_ring)); - _next_idx = 0; - _margin_ema_x256 = 0; - _finalized_count = 0; - _last_echo_ms = 0; - _echo_count = 0; - _noecho_count = 0; - _noecho_streak = 0; - _last_source_count = 0; - _power_reduction_db = 0; - } -} - -int8_t PowerController::sfThresholdX4(uint8_t sf) -{ - int idx = (int)sf - 5; - if (idx < 0) idx = 0; - if (idx > 7) idx = 7; - return snr_threshold_x4[idx]; -} - -int PowerController::findEntry(uint32_t hash32) const -{ - for (int i = 0; i < RING_SIZE; i++) { - if (_ring[i].active && _ring[i].hash32 == hash32) { - return i; - } - } - return -1; -} - -void PowerController::trackTransmit(uint32_t hash32, uint32_t now_ms, uint8_t path_pos) -{ - if (!_enabled) return; - - /* If ring slot is occupied, finalize it first */ - if (_ring[_next_idx].active) { - finalizeEntry(_next_idx); - } - - EchoEntry &e = _ring[_next_idx]; - e.hash32 = hash32; - e.timestamp_ms = now_ms; - e.source_count = 0; - e.sf_at_track = _sf; - e.path_pos = path_pos; - e.reduction_at_track = _power_reduction_db; - memset(e.sources, 0, sizeof(e.sources)); - e.active = true; - - _next_idx = (_next_idx + 1) % RING_SIZE; -} - -bool PowerController::recordEcho(uint32_t hash32, int8_t snr_x4, uint32_t now_ms, - const uint8_t *path, uint8_t path_count, - uint8_t hash_size, const uint8_t *self_hash) -{ - if (!_enabled) return false; - - int idx = findEntry(hash32); - if (idx < 0) return false; - - EchoEntry &e = _ring[idx]; - - /* Check if entry has expired */ - if (now_ms - e.timestamp_ms > ECHO_WINDOW_MS) { - finalizeEntry(idx); - return false; - } - - /* True-echo gating: for forwarded floods, only a dupe carrying our - * hash at the position we appended it proves our TX was received. - * Parallel retransmits of the origin's copy — including dupes heard - * before our own TX even airs — don't route through us and say - * nothing about our reach. */ - if (e.path_pos != PATH_POS_ORIGINATED) { - if (path_count <= e.path_pos) return false; - if (memcmp(&path[(size_t)e.path_pos * hash_size], self_hash, - hash_size) != 0) { - return false; - } - } - - /* Echo source = the node that transmitted this copy = last path - * entry (every retransmitting node appends itself; nodes that - * can't append don't retransmit). */ - uint8_t src_hash = (path_count > 0) - ? path[(size_t)(path_count - 1) * hash_size] : 0; - - /* Re-contact after a stale gap: the EMA predates the gap and must - * be re-earned before it can drive reduction again. */ - if (_last_echo_ms != 0 && now_ms - _last_echo_ms > STALE_MS) { - LOG_INF("APC: echo after stale gap, re-warming margin estimate"); - _margin_ema_x256 = 0; - _finalized_count = 0; - } - - /* Update existing source or add new one */ - for (int i = 0; i < e.source_count; i++) { - if (e.sources[i].hash == src_hash) { - if (snr_x4 > e.sources[i].snr_x4) { - e.sources[i].snr_x4 = snr_x4; - } - _last_echo_ms = now_ms; - return true; - } - } - - if (e.source_count < MAX_SOURCES) { - e.sources[e.source_count].hash = src_hash; - e.sources[e.source_count].snr_x4 = snr_x4; - e.source_count++; - } - - _last_echo_ms = now_ms; - return true; -} - -int8_t PowerController::computeRobustSNR(const EchoEntry &entry) const -{ - if (entry.source_count == 0) { - /* Not reached in practice — finalizeEntry handles no-echo. */ - return sfThresholdX4(entry.sf_at_track); - } - - if (entry.source_count == 1) { - return entry.sources[0].snr_x4; - } - - /* 2-3 sources: sort descending, then cluster + rogue filter */ - int8_t sorted[MAX_SOURCES]; - int n = entry.source_count; - for (int i = 0; i < n; i++) { - sorted[i] = entry.sources[i].snr_x4; - } - /* Simple insertion sort (max 3 elements) */ - for (int i = 1; i < n; i++) { - int8_t key = sorted[i]; - int j = i - 1; - while (j >= 0 && sorted[j] < key) { - sorted[j + 1] = sorted[j]; - j--; - } - sorted[j + 1] = key; - } - - /* Count how many are within CLUSTER_WIDTH of the best */ - int cluster_count = 1; - for (int i = 1; i < n; i++) { - if (sorted[0] - sorted[i] <= CLUSTER_WIDTH_X4) { - cluster_count++; - } - } - - if (cluster_count >= 2) { - /* 2+ in cluster: median of the cluster values */ - /* For 2 values: average. For 3 values: middle one. */ - if (cluster_count == 2) { - return (int8_t)(((int)sorted[0] + (int)sorted[1]) / 2); - } - /* cluster_count == 3 (all 3 within 6 dB) */ - return sorted[1]; /* median */ - } - - /* Only 1 in top cluster → rogue. Drop it, use next. */ - if (n >= 3 && sorted[1] - sorted[2] <= CLUSTER_WIDTH_X4) { - /* sources[1] and [2] cluster together — median them */ - return (int8_t)(((int)sorted[1] + (int)sorted[2]) / 2); - } - /* Fall back to second-best */ - return sorted[1]; -} - -void PowerController::finalizeEntry(int idx) -{ - if (!_ring[idx].active) return; - - EchoEntry &e = _ring[idx]; - _last_source_count = e.source_count; - - int32_t margin_x4; - if (e.source_count == 0) { - /* Nobody downstream decoded this TX. At the reduction R in - * effect when it was sent, that bounds the full-power margin: - * margin - R <= 0 => margin <= R. Encode the least - * pessimistic consistent value (R) so occasional misses - * (collision, RX duty cycle) correct gently; the no-echo - * streak below handles real link loss hard. */ - margin_x4 = (int32_t)e.reduction_at_track * 4; - _noecho_count++; - _noecho_streak++; - } else { - int8_t robust_snr = computeRobustSNR(e); - margin_x4 = (int32_t)robust_snr - - (int32_t)sfThresholdX4(e.sf_at_track); - _echo_count++; - _noecho_streak = 0; - } - - /* margin_x4 is in x4 units. Convert to x256 for EMA. */ - int32_t sample_x256 = margin_x4 << 6; /* x4 * 64 = x256 */ - int32_t diff = sample_x256 - _margin_ema_x256; - - if (_finalized_count < WARMUP_COUNT) { - /* During warmup, seed the EMA faster */ - if (_finalized_count == 0) { - _margin_ema_x256 = sample_x256; - } else { - _margin_ema_x256 += diff >> 1; - } - } else { - /* Normal EMA update: ema += (sample - ema) >> shift */ - _margin_ema_x256 += diff >> EMA_SHIFT; - } - - _finalized_count++; - e.active = false; - - /* Fast recovery: consecutive unechoed transmissions while reduced - * mean the EMA no longer reflects reality — restore full power NOW - * and re-earn the reduction from fresh samples. */ - if (_noecho_streak >= NOECHO_TRIP_COUNT && _power_reduction_db > 0) { - LOG_INF("APC: %u consecutive no-echo TX, restoring full power", - (unsigned)_noecho_streak); - _power_reduction_db = 0; - _margin_ema_x256 = 0; - _finalized_count = 0; - _noecho_streak = 0; - } - - LOG_DBG("APC finalize: sources=%d margin=%.1f ema=%.1f", - (int)_last_source_count, - (double)(margin_x4 / 4.0f), - (double)getMarginEstimate()); -} - -void PowerController::tick(uint32_t now_ms) -{ - if (!_enabled) return; - - /* Finalize expired entries */ - for (int i = 0; i < RING_SIZE; i++) { - if (_ring[i].active && now_ms - _ring[i].timestamp_ms > ECHO_WINDOW_MS) { - finalizeEntry(i); - } - } - - if (!isWarmedUp()) return; - - int8_t old_reduction = _power_reduction_db; - - /* Staleness takes priority: ramp back to full power if no echoes. - * When stale, never increase reduction — old EMA data is unreliable. */ - if (isStale(now_ms)) { - if (_power_reduction_db > 0) { - _power_reduction_db -= STEP_UP_DB; - if (_power_reduction_db < 0) { - _power_reduction_db = 0; - } - } - } else { - /* Compensated control: echo SNR is measured from the - * neighbor's fixed-power TX and does NOT respond to our own - * reduction, so subtract it ourselves (path reciprocity). - * excess = margin the neighbor has ABOVE target at our - * current reduced power; regulate it to the deadband. */ - int32_t excess_x256 = _margin_ema_x256 - - ((int32_t)_target_margin_x4 << 6) - - ((int32_t)_power_reduction_db << 8); - int32_t hyst_x256 = HYSTERESIS_X4 << 6; - - if (excess_x256 > hyst_x256) { - int step = (int)(excess_x256 >> 8); - if (step > STEP_DOWN_DB) step = STEP_DOWN_DB; - _power_reduction_db += step; - if (_power_reduction_db > MAX_REDUCTION_DB) { - _power_reduction_db = MAX_REDUCTION_DB; - } - } else if (excess_x256 < -hyst_x256) { - int step = (int)((-excess_x256) >> 8); - if (step > STEP_UP_DB) step = STEP_UP_DB; - _power_reduction_db -= step; - if (_power_reduction_db < 0) { - _power_reduction_db = 0; - } - } - } - - if (_power_reduction_db != old_reduction) { - LOG_INF("APC: reduction %d -> %d dBm (margin=%.1f)", - (int)old_reduction, (int)_power_reduction_db, - (double)getMarginEstimate()); - } -} - -float PowerController::getMarginEstimate() const -{ - return (float)_margin_ema_x256 / 256.0f; -} - -bool PowerController::isStale(uint32_t now_ms) const -{ - if (_last_echo_ms == 0) return false; - return now_ms - _last_echo_ms > STALE_MS; -} - -} /* namespace mesh */ diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 00b0fc0..3a878a3 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -744,33 +744,6 @@ public: lora_radio.resetCadStats(); } -#ifdef CONFIG_ZEPHCORE_APC - int8_t getAPCReduction() const override { - return companion_mesh.getAPCReduction(); - } - float getAPCMargin() const override { - return companion_mesh.getAPCMargin(); - } - bool isAPCEnabled() const override { - return companion_mesh.isAPCEnabled(); - } - void setAPCEnabled(bool en) override { - companion_mesh.setAPCEnabled(en); - } - uint8_t getAPCTargetMargin() const override { - return companion_mesh.getAPCTargetMargin(); - } - void setAPCTargetMargin(uint8_t margin_db) override { - companion_mesh.setAPCTargetMargin(margin_db); - } - uint32_t getAPCEchoCount() const override { - return companion_mesh.getAPCEchoCount(); - } - uint32_t getAPCNoEchoCount() const override { - return companion_mesh.getAPCNoEchoCount(); - } -#endif - mesh::LocalIdentity& getSelfId() override { return companion_mesh.self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override { @@ -1415,7 +1388,6 @@ int main(void) * zeroed cad_probe_interval / cad_auto and, earlier, the GPS settings). */ initNodePrefs(&companion_mesh.prefs); /* Companion-specific overrides vs. initNodePrefs defaults: */ - companion_mesh.prefs.apc_margin = 20; /* mobile: more conservative than the 16 default */ companion_mesh.prefs.auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; /* low-batt cutoff (0=off) */ companion_mesh.prefs.gps_interval = CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC; /* 5-min duty cycle (0=always-on) */ @@ -1513,30 +1485,13 @@ int main(void) lora_radio.getActiveCodingRate(), lora_radio.getConfiguredTxPower(), lora_radio.getNoiseFloor()); -#ifdef CONFIG_ZEPHCORE_APC ui_set_radio_runtime( - lora_radio.getEffectiveTxPower(), - companion_mesh.isAPCEnabled(), - companion_mesh.getAPCReduction(), - (int16_t)(companion_mesh.getAPCMargin() * 10.0f), - companion_mesh.getAPCTargetMargin(), lora_radio.getActiveSyncWord(), lora_radio.getActivePreambleLength(), lora_radio.isRxDutyCycleEnabled(), lora_radio.isRadioReady(), lora_radio.isInRecvMode(), lora_radio.isTxActive()); -#else - ui_set_radio_runtime( - lora_radio.getEffectiveTxPower(), - false, 0, 0, companion_mesh.prefs.apc_margin, - lora_radio.getActiveSyncWord(), - lora_radio.getActivePreambleLength(), - lora_radio.isRxDutyCycleEnabled(), - lora_radio.isRadioReady(), - lora_radio.isInRecvMode(), - lora_radio.isTxActive()); -#endif ui_set_radio_stats(lora_radio.getPacketsRecv(), lora_radio.getPacketsSent(), lora_radio.getPacketsRecvErrors()); @@ -1594,30 +1549,13 @@ int main(void) companion_mesh.prefs.cad_offset, companion_mesh.prefs.cad_probe_interval, companion_mesh.prefs.cad_busycap); -#ifdef CONFIG_ZEPHCORE_APC ui_set_radio_runtime( - lora_radio.getEffectiveTxPower(), - companion_mesh.isAPCEnabled(), - companion_mesh.getAPCReduction(), - (int16_t)(companion_mesh.getAPCMargin() * 10.0f), - companion_mesh.getAPCTargetMargin(), lora_radio.getActiveSyncWord(), lora_radio.getActivePreambleLength(), lora_radio.isRxDutyCycleEnabled(), lora_radio.isRadioReady(), lora_radio.isInRecvMode(), lora_radio.isTxActive()); -#else - ui_set_radio_runtime( - lora_radio.getEffectiveTxPower(), - false, 0, 0, companion_mesh.prefs.apc_margin, - lora_radio.getActiveSyncWord(), - lora_radio.getActivePreambleLength(), - lora_radio.isRxDutyCycleEnabled(), - lora_radio.isRadioReady(), - lora_radio.isInRecvMode(), - lora_radio.isTxActive()); -#endif /* Restore runtime ADC multiplier override (0 = keep DT default) */ zephyr_board.setAdcMultiplier(companion_mesh.prefs.adc_multiplier); diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 7720ef5..201f667 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -375,24 +375,7 @@ static void refresh_repeater_ui_radio_state(void) lora_radio.getConfiguredTxPower(), lora_radio.getNoiseFloor()); - bool apc_enabled = false; - int8_t apc_reduction = 0; - int16_t apc_margin_x10 = 0; - uint8_t apc_target = repeater_mesh_ptr->getNodePrefs()->apc_margin; - -#ifdef CONFIG_ZEPHCORE_APC - apc_enabled = repeater_mesh_ptr->isAPCEnabled(); - apc_reduction = repeater_mesh_ptr->getAPCReduction(); - apc_margin_x10 = (int16_t)(repeater_mesh_ptr->getAPCMargin() * 10.0f); - apc_target = repeater_mesh_ptr->getAPCTargetMargin(); -#endif - ui_set_radio_runtime( - lora_radio.getEffectiveTxPower(), - apc_enabled, - apc_reduction, - apc_margin_x10, - apc_target, lora_radio.getActiveSyncWord(), lora_radio.getActivePreambleLength(), lora_radio.isRxDutyCycleEnabled(), @@ -472,7 +455,7 @@ static void repeater_event_loop(void) ui_set_clock(rtc_clock.getCurrentTime()); #ifdef ZEPHCORE_LORA - /* Refresh live radio/APC state (noise floor, TX power + /* Refresh live radio state (noise floor, TX power * reduction, RX/TX mode, packet counters). */ refresh_repeater_ui_radio_state();