Add a board-aware battery SOC system replacing the two independent
linear approximations that existed in the UI helpers.
New helpers/battery_curve.{h,c}: a 21-point (5% step) OCV lookup table
with integer linear interpolation. The default generic LiPo curve is a
weak symbol — any board can override it by dropping a battery_curve.c
into its board directory. CMakeLists.txt selects the board-specific file
when present, falling back to the generic.
Board.h gains getBattPercent() (default 0); ZephyrBoard implements it
via battery_curve_lookup().
Board-specific curves added for boards with measured cell data:
t1000_e, rak_wismesh_tag, thinknode_m6, sensecap_solar, wio_tracker_l1.
ADC multiplier corrections applied across all nRF52840 boards:
- Boards using a correctly-derived 3600×ratio formula get +0.5% to
compensate for nRF SAADC gain error (7200→7236, 6300→6332, etc.)
- rak_wismesh_tag, rak3401_1watt, gat562_30s had multipliers copied
from Arduino's 3.0V AREF formula; corrected to 3600×1.73×1.005=6259
- xiao_nrf52840 (10911) left unchanged — empirically calibrated value
above the theoretical, assumed already correct for that hardware
get adc.multiplier now reports current mV reading and the board's
curve 100% target, making field calibration self-guiding.
getEstAirtimeFor() set LDRO from `sf >= 11`, correct only at BW125. Now
matches the driver's should_enable_ldro() (t_sym > 16.38 ms) so the
estimate's DE tracks hardware DE on every SF/BW. Also drop dead
calcRxDelay() + unused MAX_RX_DELAY_MILLIS.
- Lift duplicated identity-gen block from main_companion.cpp +
main_repeater.cpp into ZephyrRNG::generateFirstBootIdentity().
Both mains shrink from ~40 lines to a 3-line helper call.
- Add LocalIdentity::fromSeed() so seed-derived keygen doesn't need
a one-shot RNG wrapper; delete SeededRNG.
- Drop the per-byte ADC sampling loop: getBattMilliVolts() does an
8-sample average + 10ms regulator settle internally, costing
300-480ms of real wall-time and actively destroying the LSB jitter
it was meant to harvest. Jitter mixer already dwarfs it.
- Centralize the printk + sys_reboot pattern as
Utils::cryptoPanicReboot(); drop the 2000ms pre-reboot k_msleep
(printk is synchronous, sleep just blocked the mesh thread on
the ZephyrRNG::random() retry-failure path).
- Inline sample_cpu_jitter health check via online scalars instead
of a 512-byte deltas[] array. Saves 1.5KB stack churn across boot
and tracks every sample instead of only the first 128.
- extract_via_aes_ctr now uses Utils::sha256 instead of open-coding
psa_hash_compute.
Audit-driven sweep found additional compiler-optimization-sensitive
patterns beyond the login password compare just fixed:
P4.F3 (HIGH) — Utils::MACThenDecrypt verified packet MACs with
plain memcmp. Runs on EVERY encrypted-then-MAC'd packet in the
mesh; a timing oracle here lets attackers forge MACs byte-by-byte
across the whole mesh layer. Replaced with constantTimeEqual.
P4.F4 (MEDIUM) — Multiple memset(secret, 0, ...) calls on
stack-resident crypto buffers (Ed25519 seed, ADC noise pool, AES
key derived in extract_via_aes_ctr, HWINFO unique ID) were
subject to dead-store elimination under -Os. GCC/Clang routinely
elide these when the buffer is never read after; the wipe vanishes
and the secret persists on stack until next call overwrites.
Replaced with secureZeroize using volatile pointer writes.
P4.F5 (LOW) — Identity::validatePrivateKey boot self-test compared
shared secrets with plain memcmp. Boot-only, no attacker
observation channel, but hygiene matters and the fix is one line.
Also added secret-wipe for ss1/ss2 on all return paths.
Promoted the local ct_memeq() previously added to RepeaterMesh.cpp
into Utils::constantTimeEqual + Utils::secureZeroize (Utils.h/cpp)
so the login compare and MAC compare share the same audited helper.
Both helpers verified by Thumb-2 disassembly on rak3401_1watt:
- constantTimeEqual: loop branches on iterator, accumulator
load-modify-stored to stack every iteration, final return uses
CLZ+LSR (no conditional branch on result).
- secureZeroize: STRB.W to memory in a counted loop, not replaced
with memset builtin and not eliminated.
After Phase J, the joystick UI rewrites a sent channel message's
path_len from OUT_PATH_SENT (0xFE) to OUT_PATH_SENT_HEARD (0xFD) or
OUT_PATH_SENT_UNHEARD (0xFC) once the feedback window resolves.
buildChannelReplyPrefix only excluded OUT_PATH_SENT, so a reply to
one of the user's own messages (post-feedback) would treat it as
incoming and build a garbled "@[<random body>]" prefix.
Also cleans up an awkward `class ContentionTracker&` qualifier in
Mesh.h.
After broadcasting a group message, wait 5s to see if any neighbor
repeated the flood and use the outcome to mark the on-device entry
and the BLE-app mirror.
- ContentionTracker gets extractDupeCount(hash): finds the tracked
entry, captures dupe_count, finalizes (folds into EMA, marks
inactive), returns the count or -1.
- BaseChatMesh::sendGroupMessage gains an optional out_hash param;
when set, the FNV-1a packet hash is also pre-registered with the
contention tracker so heard retransmits get counted (originated
floods weren't tracked before, only relays).
- Mesh::getContentionTracker() promoted to public so the UI can
query after the feedback window.
JoystickUITask grows a 4-slot pending-channel table with per-slot
k_timer (5s one-shot). startPendingChannel() broadcasts, adds the
local _ch_previews entry with path_len = OUT_PATH_SENT, and starts
the feedback timer. The timer ISR sets a feedback_due flag; the
loop's processPendingChannelFeedback() picks it up, calls
extractDupeCount(), and rewrites the preview's path_len to
OUT_PATH_SENT_HEARD (0xFD) or OUT_PATH_SENT_UNHEARD (0xFC) — which
formatHopCount renders as "sent+" / "sent?".
The deferred BLE-app mirror queues only on outcome with body prefix
"(>>✓) " (heard) or "(>>✗) " (not heard). queueLocalSentChannelMessage
gains a heard_repeat parameter for the selection.
Both channel send entry points (sendComposedMessage's channel branch
and sendChannelMessage) now route through startPendingChannel().
Both mesh::Packet::writePath and ::copyPath did a raw memcpy of the
decoded hash_count*hash_size bytes from src to dest with no bound on
src. Two call sites used phone-supplied or LoRa-anon-supplied buffers
where the path_len byte was attacker-controlled:
- CompanionMesh CMD_SEND_CHANNEL_DATA accepted len>=4 and called
writePath with no src bound; a paired phone could leak up to ~65
bytes of syswq stack into the outgoing LoRa channel-data frame.
- RepeaterMesh handleAnonRegionsReq / handleAnonOwnerReq /
handleAnonClockReq read reply_path_len from an unauthenticated
LoRa anon-request payload and called copyPath without any src
bound. Any LoRa neighbor could leak repeater stack into the
reply path.
Hardened the API: both functions now require an explicit src_len
and reject (return 0) when the decoded byte count exceeds it.
Updated all 14 call sites across Packet/Mesh/Dispatcher/BaseChatMesh/
CompanionMesh/RepeaterMesh. Trusted callers (internal MAX_PATH_SIZE
buffers) pass MAX_PATH_SIZE; untrusted callers pass real remaining
length. Added len-5 plumbing through the anon-handler signatures.
CMD_SEND_CHANNEL_DATA also gained a local len>=5 + path_bytes
sanity check for early rejection.
every LBT-retried flood packet loses its priority (fixed)
witching between LBT and non-LBT mode (or any cad.mode change) could silently skip full reconfiguration and leave the radio in the wrong mode (fixed)
Add .gitattributes rules so .c/.h/.cpp/.hpp are always stored as LF
(prevents EOL drift from editors with autocrlf-true defaults), and
renormalize the 30 source files that had drifted to CRLF in the index.
Pure mechanical change — `git diff --ignore-cr-at-eol` is empty.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- flood retransmit jitter now capped at min(2000ms, 6·airtime) instead of
fixed 2000ms — spreads tighter at SF7, unchanged at SF8
- reactive per-dupe backoff cap now min(2000ms, 12·airtime), keeps
semantic of "push past ~12 relay slots"
- contention ring 16 → 24 for 50-neighbor hilltops
- companions passively track heard floods (warms EMA without forwarding)
and spread their own TX by up to min(1000ms, 3·airtime), hopefully
fixing repeaters missing companion's first transmission
config cleanup:
- move BLE TX buffer bumps (ACL_TX=12 etc.) from zephcore_common.conf to
esp32_common.conf — the Espressif blob needs them, nRF doesn't, and
the bumps were overflowing nRF52840 RAM
- remove CONFIG_ZEPHCORE_MAX_CONTACTS=510 overrides from 5 nRF52840
companion boards; Kconfig default of 350 fits with comfortable margin
(wio prod: 91% → 79% RAM)