normalize source-file line endings to LF

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>
This commit is contained in:
liquidraverandClaude Opus 4.7 committed 2026-05-05 14:56:44 +02:00
1 parent 28e12c7eef
commit 5e7adfb130
31 files changed
+6788 -6783

No files matched your search

+166 -166
View File
@@ -1,166 +1,166 @@
/*
* SPDX-License-Identifier: Apache-2.0
* Adaptive Contention Window — dupe-counting based delay estimation
*/
#include <mesh/ContentionTracker.h>
#include <mesh/Packet.h>
#include <string.h>
namespace mesh {
ContentionTracker::ContentionTracker()
: _next_idx(0), _ema_x256(0), _finalized_count(0),
_last_retransmit_ms(0), _backoff_multiplier(DEFAULT_BACKOFF_MULT)
{
memset(_ring, 0, sizeof(_ring));
}
/* FNV-1a over payload_type + first 8 payload bytes */
uint32_t ContentionTracker::computePacketHash32(const Packet *pkt)
{
uint32_t h = 0x811c9dc5u; /* FNV-1a offset basis */
uint8_t t = pkt->getPayloadType();
h = (h ^ t) * 0x01000193u;
int n = pkt->payload_len < 8 ? pkt->payload_len : 8;
for (int i = 0; i < n; i++) {
h = (h ^ pkt->payload[i]) * 0x01000193u;
}
return h;
}
int ContentionTracker::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 ContentionTracker::finalizeEntry(int idx)
{
if (!_ring[idx].active) return;
uint32_t sample_x256 = (uint32_t)_ring[idx].dupe_count << 8;
int32_t diff = (int32_t)sample_x256 - (int32_t)_ema_x256;
if (_finalized_count < WARMUP_PACKETS) {
/* Warmup: seed EMA with fast convergence */
if (_finalized_count == 0) {
_ema_x256 = sample_x256;
} else {
_ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> 1));
}
} else {
_ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> EMA_SHIFT));
}
_finalized_count++;
_ring[idx].active = false;
}
void ContentionTracker::trackRetransmit(uint32_t hash32, uint32_t now_ms)
{
_last_retransmit_ms = now_ms;
/* Evict oldest if ring slot occupied */
if (_ring[_next_idx].active) {
finalizeEntry(_next_idx);
}
Entry &e = _ring[_next_idx];
e.hash32 = hash32;
e.first_seen_ms = now_ms;
e.dupe_count = 0;
e.reactive_added_ms = 0;
e.active = true;
_next_idx = (_next_idx + 1) % RING_SIZE;
}
bool ContentionTracker::recordDupeIfTracked(uint32_t hash32, uint32_t now_ms)
{
int idx = findEntry(hash32);
if (idx < 0) return false;
Entry &e = _ring[idx];
if (now_ms - e.first_seen_ms > WINDOW_MS) {
finalizeEntry(idx);
return false;
}
if (e.dupe_count < 255) {
e.dupe_count++;
}
return true;
}
uint16_t ContentionTracker::getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const
{
int idx = findEntry(hash32);
if (idx < 0) return 0;
uint32_t per_dupe = (uint32_t)(_backoff_multiplier * (float)airtime_ms);
if (per_dupe == 0) return 0;
/* Effective cap: ~12 relay-slots (airtime-scaled), absolute ceiling REACTIVE_HARD_CAP_MS */
uint32_t effective_cap = 12 * airtime_ms;
if (effective_cap > REACTIVE_HARD_CAP_MS) effective_cap = REACTIVE_HARD_CAP_MS;
if (_ring[idx].reactive_added_ms >= effective_cap) return 0;
uint32_t remaining = effective_cap - _ring[idx].reactive_added_ms;
if (per_dupe > remaining) per_dupe = remaining;
return per_dupe > 0xFFFF ? 0xFFFF : (uint16_t)per_dupe;
}
void ContentionTracker::addReactiveExtension(uint32_t hash32, uint16_t added_ms)
{
int idx = findEntry(hash32);
if (idx < 0) return;
uint32_t total = (uint32_t)_ring[idx].reactive_added_ms + added_ms;
_ring[idx].reactive_added_ms = total > 0xFFFF ? 0xFFFF : (uint16_t)total;
}
void ContentionTracker::tick(uint32_t now_ms)
{
for (int i = 0; i < RING_SIZE; i++) {
if (_ring[i].active && now_ms - _ring[i].first_seen_ms > WINDOW_MS) {
finalizeEntry(i);
}
}
/* Decay EMA toward 0 if no retransmit in STALE_MS */
if (_last_retransmit_ms != 0 && now_ms - _last_retransmit_ms > STALE_MS) {
if (_ema_x256 > 0) {
_ema_x256 -= _ema_x256 >> EMA_SHIFT;
}
}
}
float ContentionTracker::getContentionEstimate() const
{
return (float)_ema_x256 / 256.0f;
}
float ContentionTracker::getFloodDelayFactor() const
{
if (!isWarmedUp()) return 0.5f; /* conservative default before warmup */
float est = getContentionEstimate();
if (est <= 0.0f) return MIN_FLOOD_FACTOR;
/* Arduino-like center near 0.5 in light contention, rising smoothly
* toward 0.8 as contention increases. */
float factor = MIN_FLOOD_FACTOR + (MAX_FLOOD_FACTOR - MIN_FLOOD_FACTOR) *
(est / (est + FLOOD_EST_HALFPOINT));
if (factor > MAX_FLOOD_FACTOR) factor = MAX_FLOOD_FACTOR;
return factor;
}
} /* namespace mesh */
/*
* SPDX-License-Identifier: Apache-2.0
* Adaptive Contention Window — dupe-counting based delay estimation
*/
#include <mesh/ContentionTracker.h>
#include <mesh/Packet.h>
#include <string.h>
namespace mesh {
ContentionTracker::ContentionTracker()
: _next_idx(0), _ema_x256(0), _finalized_count(0),
_last_retransmit_ms(0), _backoff_multiplier(DEFAULT_BACKOFF_MULT)
{
memset(_ring, 0, sizeof(_ring));
}
/* FNV-1a over payload_type + first 8 payload bytes */
uint32_t ContentionTracker::computePacketHash32(const Packet *pkt)
{
uint32_t h = 0x811c9dc5u; /* FNV-1a offset basis */
uint8_t t = pkt->getPayloadType();
h = (h ^ t) * 0x01000193u;
int n = pkt->payload_len < 8 ? pkt->payload_len : 8;
for (int i = 0; i < n; i++) {
h = (h ^ pkt->payload[i]) * 0x01000193u;
}
return h;
}
int ContentionTracker::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 ContentionTracker::finalizeEntry(int idx)
{
if (!_ring[idx].active) return;
uint32_t sample_x256 = (uint32_t)_ring[idx].dupe_count << 8;
int32_t diff = (int32_t)sample_x256 - (int32_t)_ema_x256;
if (_finalized_count < WARMUP_PACKETS) {
/* Warmup: seed EMA with fast convergence */
if (_finalized_count == 0) {
_ema_x256 = sample_x256;
} else {
_ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> 1));
}
} else {
_ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> EMA_SHIFT));
}
_finalized_count++;
_ring[idx].active = false;
}
void ContentionTracker::trackRetransmit(uint32_t hash32, uint32_t now_ms)
{
_last_retransmit_ms = now_ms;
/* Evict oldest if ring slot occupied */
if (_ring[_next_idx].active) {
finalizeEntry(_next_idx);
}
Entry &e = _ring[_next_idx];
e.hash32 = hash32;
e.first_seen_ms = now_ms;
e.dupe_count = 0;
e.reactive_added_ms = 0;
e.active = true;
_next_idx = (_next_idx + 1) % RING_SIZE;
}
bool ContentionTracker::recordDupeIfTracked(uint32_t hash32, uint32_t now_ms)
{
int idx = findEntry(hash32);
if (idx < 0) return false;
Entry &e = _ring[idx];
if (now_ms - e.first_seen_ms > WINDOW_MS) {
finalizeEntry(idx);
return false;
}
if (e.dupe_count < 255) {
e.dupe_count++;
}
return true;
}
uint16_t ContentionTracker::getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const
{
int idx = findEntry(hash32);
if (idx < 0) return 0;
uint32_t per_dupe = (uint32_t)(_backoff_multiplier * (float)airtime_ms);
if (per_dupe == 0) return 0;
/* Effective cap: ~12 relay-slots (airtime-scaled), absolute ceiling REACTIVE_HARD_CAP_MS */
uint32_t effective_cap = 12 * airtime_ms;
if (effective_cap > REACTIVE_HARD_CAP_MS) effective_cap = REACTIVE_HARD_CAP_MS;
if (_ring[idx].reactive_added_ms >= effective_cap) return 0;
uint32_t remaining = effective_cap - _ring[idx].reactive_added_ms;
if (per_dupe > remaining) per_dupe = remaining;
return per_dupe > 0xFFFF ? 0xFFFF : (uint16_t)per_dupe;
}
void ContentionTracker::addReactiveExtension(uint32_t hash32, uint16_t added_ms)
{
int idx = findEntry(hash32);
if (idx < 0) return;
uint32_t total = (uint32_t)_ring[idx].reactive_added_ms + added_ms;
_ring[idx].reactive_added_ms = total > 0xFFFF ? 0xFFFF : (uint16_t)total;
}
void ContentionTracker::tick(uint32_t now_ms)
{
for (int i = 0; i < RING_SIZE; i++) {
if (_ring[i].active && now_ms - _ring[i].first_seen_ms > WINDOW_MS) {
finalizeEntry(i);
}
}
/* Decay EMA toward 0 if no retransmit in STALE_MS */
if (_last_retransmit_ms != 0 && now_ms - _last_retransmit_ms > STALE_MS) {
if (_ema_x256 > 0) {
_ema_x256 -= _ema_x256 >> EMA_SHIFT;
}
}
}
float ContentionTracker::getContentionEstimate() const
{
return (float)_ema_x256 / 256.0f;
}
float ContentionTracker::getFloodDelayFactor() const
{
if (!isWarmedUp()) return 0.5f; /* conservative default before warmup */
float est = getContentionEstimate();
if (est <= 0.0f) return MIN_FLOOD_FACTOR;
/* Arduino-like center near 0.5 in light contention, rising smoothly
* toward 0.8 as contention increases. */
float factor = MIN_FLOOD_FACTOR + (MAX_FLOOD_FACTOR - MIN_FLOOD_FACTOR) *
(est / (est + FLOOD_EST_HALFPOINT));
if (factor > MAX_FLOOD_FACTOR) factor = MAX_FLOOD_FACTOR;
return factor;
}
} /* namespace mesh */
+732 -732
View File
File diff suppressed because it is too large. Load diff
+106 -106
View File
@@ -1,106 +1,106 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephCore Packet implementation
*/
#include <mesh/Packet.h>
#include <mesh/Utils.h>
#include <string.h>
namespace mesh {
Packet::Packet()
{
header = 0;
path_len = 0;
payload_len = 0;
}
bool Packet::isValidPathLen(uint8_t path_len)
{
uint8_t hash_count = path_len & 63;
uint8_t hash_size = (path_len >> 6) + 1;
if (hash_size == 4) return false; // Reserved for future
return hash_count * hash_size <= MAX_PATH_SIZE;
}
size_t Packet::writePath(uint8_t *dest, const uint8_t *src, uint8_t path_len)
{
uint8_t hash_count = path_len & 63;
uint8_t hash_size = (path_len >> 6) + 1;
size_t len = hash_count * hash_size;
if (len > MAX_PATH_SIZE) {
return 0; // Error
}
memcpy(dest, src, len);
return len;
}
uint8_t Packet::copyPath(uint8_t *dest, const uint8_t *src, uint8_t path_len)
{
size_t written = writePath(dest, src, path_len);
return written > 0 ? path_len : 0;
}
int Packet::getRawLength() const
{
return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0);
}
void Packet::calculatePacketHash(uint8_t *hash) const
{
uint8_t t = getPayloadType();
if (t == PAYLOAD_TYPE_TRACE) {
uint8_t buf[2 + MAX_PACKET_PAYLOAD];
buf[0] = t;
memcpy(buf + 1, &path_len, sizeof(path_len));
memcpy(buf + 2, payload, payload_len);
Utils::sha256(hash, MAX_HASH_SIZE, buf, 2 + payload_len);
} else {
uint8_t buf[1 + MAX_PACKET_PAYLOAD];
buf[0] = t;
memcpy(buf + 1, payload, payload_len);
Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + payload_len);
}
}
uint8_t Packet::writeTo(uint8_t dest[]) const
{
uint8_t i = 0;
dest[i++] = header;
if (hasTransportCodes()) {
memcpy(&dest[i], &transport_codes[0], 2); i += 2;
memcpy(&dest[i], &transport_codes[1], 2); i += 2;
}
dest[i++] = path_len;
i += writePath(&dest[i], path, path_len);
memcpy(&dest[i], payload, payload_len); i += payload_len;
return i;
}
bool Packet::readFrom(const uint8_t src[], uint8_t len)
{
uint8_t i = 0;
if (len < 2) return false;
header = src[i++];
if (hasTransportCodes()) {
if (len < 6) return false;
memcpy(&transport_codes[0], &src[i], 2); i += 2;
memcpy(&transport_codes[1], &src[i], 2); i += 2;
} else {
transport_codes[0] = transport_codes[1] = 0;
}
path_len = src[i++];
if (!isValidPathLen(path_len)) return false;
uint8_t bl = getPathByteLen();
if ((uint16_t)i + bl > len) return false;
memcpy(path, &src[i], bl); i += bl;
if (i >= len) return false;
payload_len = len - i;
if (payload_len > sizeof(payload)) return false;
memcpy(payload, &src[i], payload_len);
return true;
}
} /* namespace mesh */
/*
* SPDX-License-Identifier: Apache-2.0
* ZephCore Packet implementation
*/
#include <mesh/Packet.h>
#include <mesh/Utils.h>
#include <string.h>
namespace mesh {
Packet::Packet()
{
header = 0;
path_len = 0;
payload_len = 0;
}
bool Packet::isValidPathLen(uint8_t path_len)
{
uint8_t hash_count = path_len & 63;
uint8_t hash_size = (path_len >> 6) + 1;
if (hash_size == 4) return false; // Reserved for future
return hash_count * hash_size <= MAX_PATH_SIZE;
}
size_t Packet::writePath(uint8_t *dest, const uint8_t *src, uint8_t path_len)
{
uint8_t hash_count = path_len & 63;
uint8_t hash_size = (path_len >> 6) + 1;
size_t len = hash_count * hash_size;
if (len > MAX_PATH_SIZE) {
return 0; // Error
}
memcpy(dest, src, len);
return len;
}
uint8_t Packet::copyPath(uint8_t *dest, const uint8_t *src, uint8_t path_len)
{
size_t written = writePath(dest, src, path_len);
return written > 0 ? path_len : 0;
}
int Packet::getRawLength() const
{
return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0);
}
void Packet::calculatePacketHash(uint8_t *hash) const
{
uint8_t t = getPayloadType();
if (t == PAYLOAD_TYPE_TRACE) {
uint8_t buf[2 + MAX_PACKET_PAYLOAD];
buf[0] = t;
memcpy(buf + 1, &path_len, sizeof(path_len));
memcpy(buf + 2, payload, payload_len);
Utils::sha256(hash, MAX_HASH_SIZE, buf, 2 + payload_len);
} else {
uint8_t buf[1 + MAX_PACKET_PAYLOAD];
buf[0] = t;
memcpy(buf + 1, payload, payload_len);
Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + payload_len);
}
}
uint8_t Packet::writeTo(uint8_t dest[]) const
{
uint8_t i = 0;
dest[i++] = header;
if (hasTransportCodes()) {
memcpy(&dest[i], &transport_codes[0], 2); i += 2;
memcpy(&dest[i], &transport_codes[1], 2); i += 2;
}
dest[i++] = path_len;
i += writePath(&dest[i], path, path_len);
memcpy(&dest[i], payload, payload_len); i += payload_len;
return i;
}
bool Packet::readFrom(const uint8_t src[], uint8_t len)
{
uint8_t i = 0;
if (len < 2) return false;
header = src[i++];
if (hasTransportCodes()) {
if (len < 6) return false;
memcpy(&transport_codes[0], &src[i], 2); i += 2;
memcpy(&transport_codes[1], &src[i], 2); i += 2;
} else {
transport_codes[0] = transport_codes[1] = 0;
}
path_len = src[i++];
if (!isValidPathLen(path_len)) return false;
uint8_t bl = getPathByteLen();
if ((uint16_t)i + bl > len) return false;
memcpy(path, &src[i], bl); i += bl;
if (i >= len) return false;
payload_len = len - i;
if (payload_len > sizeof(payload)) return false;
memcpy(payload, &src[i], payload_len);
return true;
}
} /* namespace mesh */
+278 -278
View File
@@ -1,278 +1,278 @@
/*
* SPDX-License-Identifier: Apache-2.0
* Adaptive Power Control — echo-based TX power reduction
*/
#include <mesh/PowerController.h>
#include <mesh/Packet.h>
#include <string.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(zephcore_apc, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
/* SNR thresholds per SF (x4 fixed point, matching radio_common.h) */
static constexpr int8_t snr_threshold_x4[] = {
-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), _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;
_last_source_count = 0;
_power_reduction_db = 0;
}
}
int8_t PowerController::sfThresholdX4(uint8_t sf)
{
int idx = (int)sf - 7;
if (idx < 0) idx = 0;
if (idx > 5) idx = 5;
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)
{
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;
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,
uint8_t first_hop_hash, uint32_t now_ms)
{
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;
}
/* Update existing source or add new one */
for (int i = 0; i < e.source_count; i++) {
if (e.sources[i].hash == first_hop_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 = first_hop_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) {
return sfThresholdX4(entry.sf_at_track); /* no echo = margin 0 */
}
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;
int8_t robust_snr = computeRobustSNR(e);
int32_t margin_x4 = (int32_t)robust_snr - (int32_t)sfThresholdX4(e.sf_at_track);
/* 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;
LOG_DBG("APC finalize: sources=%d robust_snr=%.1f margin=%.1f ema=%.1f",
(int)_last_source_count,
(double)(robust_snr / 4.0f),
(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;
int32_t margin_x256 = _margin_ema_x256;
int32_t target_x256 = _target_margin_x4 << 6;
int32_t hyst_x256 = HYSTERESIS_X4 << 6;
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_DOWN_DB;
if (_power_reduction_db < 0) {
_power_reduction_db = 0;
}
}
} else if (margin_x256 > target_x256 + hyst_x256) {
/* Margin very good — step down */
if (_power_reduction_db < MAX_REDUCTION_DB) {
_power_reduction_db += STEP_DOWN_DB;
if (_power_reduction_db > MAX_REDUCTION_DB) {
_power_reduction_db = MAX_REDUCTION_DB;
}
}
} else if (margin_x256 < target_x256 - hyst_x256) {
/* Margin too low — step up (reduce the reduction) */
if (_power_reduction_db > 0) {
_power_reduction_db -= STEP_UP_DB;
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 */
/*
* SPDX-License-Identifier: Apache-2.0
* Adaptive Power Control — echo-based TX power reduction
*/
#include <mesh/PowerController.h>
#include <mesh/Packet.h>
#include <string.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(zephcore_apc, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
/* SNR thresholds per SF (x4 fixed point, matching radio_common.h) */
static constexpr int8_t snr_threshold_x4[] = {
-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), _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;
_last_source_count = 0;
_power_reduction_db = 0;
}
}
int8_t PowerController::sfThresholdX4(uint8_t sf)
{
int idx = (int)sf - 7;
if (idx < 0) idx = 0;
if (idx > 5) idx = 5;
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)
{
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;
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,
uint8_t first_hop_hash, uint32_t now_ms)
{
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;
}
/* Update existing source or add new one */
for (int i = 0; i < e.source_count; i++) {
if (e.sources[i].hash == first_hop_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 = first_hop_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) {
return sfThresholdX4(entry.sf_at_track); /* no echo = margin 0 */
}
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;
int8_t robust_snr = computeRobustSNR(e);
int32_t margin_x4 = (int32_t)robust_snr - (int32_t)sfThresholdX4(e.sf_at_track);
/* 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;
LOG_DBG("APC finalize: sources=%d robust_snr=%.1f margin=%.1f ema=%.1f",
(int)_last_source_count,
(double)(robust_snr / 4.0f),
(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;
int32_t margin_x256 = _margin_ema_x256;
int32_t target_x256 = _target_margin_x4 << 6;
int32_t hyst_x256 = HYSTERESIS_X4 << 6;
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_DOWN_DB;
if (_power_reduction_db < 0) {
_power_reduction_db = 0;
}
}
} else if (margin_x256 > target_x256 + hyst_x256) {
/* Margin very good — step down */
if (_power_reduction_db < MAX_REDUCTION_DB) {
_power_reduction_db += STEP_DOWN_DB;
if (_power_reduction_db > MAX_REDUCTION_DB) {
_power_reduction_db = MAX_REDUCTION_DB;
}
}
} else if (margin_x256 < target_x256 - hyst_x256) {
/* Margin too low — step up (reduce the reduction) */
if (_power_reduction_db > 0) {
_power_reduction_db -= STEP_UP_DB;
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 */