fix(companion): CLI NaN, APC on TX changes, tz-aware alarm, late ACKs, ping timeout and widths, TX limits

- set af / dutycycle / rxdelay reject nan.
- A TX power change from the app or CLI re-baselines APC.
- CMD_SET_RADIO_PARAMS stores repeat as 0/1.
- The alarm reschedules itself when the time zone changes.
- A late ACK for an earlier DM attempt marks it delivered.
- A new auto-advert interval counts from the last advert.
- Both UIs take the TX power range from radioctl, up to MAX_LORA_TX_POWER
  (30 dBm on the GAT562 30S).
- The ping timeout comes from the airtime; 3-byte path hash mode pings with
  4-byte trace hashes, and trace flags are right for width 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-12 18:00:49 +02:00
co-authored by Claude Opus 5.5
parent cd294613ea
commit 28d8291e33
14 changed files with 76 additions and 29 deletions
+10 -1
View File
@@ -1940,7 +1940,7 @@ void MyMesh::handleCmdFrame(size_t len) {
_prefs.cr = cr;
_prefs.freq = (float)freq / 1000.0;
_prefs.bw = (float)bw / 1000.0;
_prefs.client_repeat = repeat;
_prefs.client_repeat = repeat ? 1 : 0; // a bool for every reader, whatever byte the app sent
savePrefs();
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
@@ -1971,6 +1971,7 @@ void MyMesh::handleCmdFrame(size_t len) {
// Store what the radio actually applied, not the raw request -- on a
// board with an external-PA gain curve those can differ (see setTxPower()).
_prefs.tx_power_dbm = radio_driver.getTxPower();
if (_prefs.tx_apc) applyApc(); // the new ceiling: re-baseline APC, as the device's own setting does
savePrefs();
writeOKFrame();
}
@@ -2882,10 +2883,18 @@ void MyMesh::loop() {
_last_rtc_save_ms = millis();
}
if (_prefs.advert_auto_interval_sec != _auto_advert_sec) {
// Interval changed (Settings, CLI, app): count the new one from the last
// advert instead of waiting out what was left of the old one.
bool first = _auto_advert_sec == UINT32_MAX; // boot: keep the advert due straight away
_auto_advert_sec = _prefs.advert_auto_interval_sec;
if (!first) _next_auto_advert_ms = _last_auto_advert_ms + _auto_advert_sec * 1000UL;
}
if (_prefs.advert_auto_interval_sec > 0 && millisHasNowPassed(_next_auto_advert_ms)) {
// The position only where Privacy lets adverts carry it, at its precision.
mesh::Packet* pkt = createOwnAdvert();
if (pkt) sendZeroHop(pkt);
_last_auto_advert_ms = _ms->getMillis();
_next_auto_advert_ms = futureMillis(_prefs.advert_auto_interval_sec * 1000UL);
}
}
+3 -1
View File
@@ -254,7 +254,7 @@ public:
unsigned long sent_ms;
};
uint32_t sendPing(const uint8_t* dest_pubkey, uint8_t hash_width = 1);
uint32_t sendPing(const uint8_t* dest_pubkey, uint8_t hash_width = 1, uint32_t* est_timeout = nullptr);
void setPingCallback(PingCallback cb, void* arg);
void clearPingResult(uint32_t tag);
PingResult* getPingResult(uint32_t tag);
@@ -684,6 +684,8 @@ private:
unsigned long _bot_last_ch_reply_ms;
unsigned long _bot_last_room_reply_ms;
unsigned long _next_auto_advert_ms;
unsigned long _last_auto_advert_ms = 0;
uint32_t _auto_advert_sec = UINT32_MAX; // the interval _next_auto_advert_ms was set with (MAX: not yet)
// Per-contact DM reply throttle: a small ring of the most recent recipients so
// one chatty contact can't be spammed while a different sender is still served.
+4 -3
View File
@@ -120,7 +120,7 @@ bool MyMesh::cliRadioCommand(const char* command, char* reply, int n) {
if (memcmp(command, "set af ", 7) == 0) {
char* end;
float af = strtof(&command[7], &end);
if (end == &command[7] || af < 0 || af > 9) snprintf(reply, n, "ERROR: af must be 0-9");
if (end == &command[7] || !(af >= 0 && af <= 9)) snprintf(reply, n, "ERROR: af must be 0-9");
else { _prefs.airtime_factor = af; savePrefs(); snprintf(reply, n, "OK"); }
return true;
}
@@ -131,7 +131,7 @@ bool MyMesh::cliRadioCommand(const char* command, char* reply, int n) {
}
if (memcmp(command, "set dutycycle ", 14) == 0) {
float dc = atof(&command[14]);
if (dc < 1 || dc > 100) { snprintf(reply, n, "ERROR: dutycycle must be 1-100"); return true; }
if (!(dc >= 1 && dc <= 100)) // written so "nan" fails too { snprintf(reply, n, "ERROR: dutycycle must be 1-100"); return true; }
_prefs.airtime_factor = 100.0f / dc - 1.0f;
savePrefs();
float a = 100.0f / (_prefs.airtime_factor + 1.0f);
@@ -172,6 +172,7 @@ bool MyMesh::cliRadioCommand(const char* command, char* reply, int n) {
if (p < -9 || p > MAX_LORA_TX_POWER) { snprintf(reply, n, "Error, must be -9 to %d", MAX_LORA_TX_POWER); return true; }
radio_driver.setTxPower(p);
_prefs.tx_power_dbm = radio_driver.getTxPower(); // what the radio applied (see CMD_SET_RADIO_TX_POWER)
if (_prefs.tx_apc) applyApc(); // the new APC ceiling
savePrefs();
snprintf(reply, n, "OK");
return true;
@@ -179,7 +180,7 @@ bool MyMesh::cliRadioCommand(const char* command, char* reply, int n) {
if (strcmp(command, "get rxdelay") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.rx_delay_base)); return true; }
if (memcmp(command, "set rxdelay ", 12) == 0) {
float d = atof(&command[12]);
if (d < 0 || d > 20.0f) snprintf(reply, n, "Error, must be 0-20");
if (!(d >= 0 && d <= 20.0f)) snprintf(reply, n, "Error, must be 0-20");
else { _prefs.rx_delay_base = d; savePrefs(); snprintf(reply, n, "OK"); }
return true;
}
+8 -3
View File
@@ -25,8 +25,9 @@ int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) {
// ── Ping/Trace functionality ─────────────────────────────────────────────────
uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width) {
if (hash_width == 0 || hash_width > 2) return 0;
uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width, uint32_t* est_timeout) {
// A trace's hashes are 1 << flags bytes wide: 1, 2, 4 (no 3).
if (hash_width != 1 && hash_width != 2 && hash_width != 4) return 0;
// Generate random tag and auth code
uint32_t tag, auth;
@@ -61,9 +62,13 @@ uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width) {
memcpy(path, dest_pubkey, path_len);
// Create and send trace packet
auto pkt = createTrace(tag, auth, hash_width - 1); // flags = hash_width - 1
auto pkt = createTrace(tag, auth, hash_width == 4 ? 2 : hash_width - 1); // flags = log2(hash_width)
if (pkt) {
sendDirect(pkt, path, path_len);
if (est_timeout) { // as CMD_SEND_TRACE_PATH answers the app: from the airtime, one hop
uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2);
*est_timeout = calcDirectTimeoutMillisFor(t, 1);
}
return tag;
}
@@ -101,6 +101,9 @@ private:
_alarm_check_ms = now_ms;
uint32_t now_wall = rtc_clock.getCurrentTime();
if (now_wall < 1000000000UL) return; // need a real time sync first
// A time zone change (Settings, CLI tz.offset, the app) moves the local
// alarm time: reschedule here rather than trusting every caller to say so.
if (_alarm_tz != _prefs->tz_offset_hours) { _alarm_tz = _prefs->tz_offset_hours; _alarm_next_fire = 0; }
if (_alarm_next_fire == 0) _alarm_next_fire = computeAlarmNextFire(now_wall);
if (now_wall < _alarm_next_fire) return;
if (now_wall - _alarm_next_fire < ALARM_CATCHUP_SECS) {
@@ -124,6 +127,7 @@ private:
NodePrefs* _prefs = nullptr;
UiEventQueue* _events = nullptr;
uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid
int8_t _alarm_tz = 0; // the tz_offset_hours _alarm_next_fire was computed with
uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz
bool _timer_running = false;
uint32_t _timer_deadline_ms = 0;
@@ -459,6 +459,28 @@ public:
e.ack_status = ACK_OK;
#ifdef HIST_ARCHIVE
if (_arc) _arc->dmUpdate(e);
#endif
return;
}
}
// A late ACK for an earlier attempt still proves delivery. Only the latest
// attempt's tag is kept, so rebuild the earlier ones (BaseChatMesh::
// composeMsgPacket(): timestamp, attempt & 3, text, our public key).
for (int i = 0; i < _dm_hist_count; i++) {
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
if (!e.outgoing || e.ack_status != ACK_PENDING || e.attempt == 0) continue;
uint8_t buf[5 + MSG_TEXT_BUF];
int tl = strnlen(e.text, MSG_TEXT_BUF - 1);
memcpy(buf, &e.msg_ts, 4);
memcpy(&buf[5], e.text, tl);
for (uint8_t a = 0; a < e.attempt && a < 4; a++) { // the hash only sees attempt & 3
buf[4] = (uint8_t)(a & 3);
uint32_t crc;
mesh::Utils::sha256((uint8_t*)&crc, 4, buf, 5 + tl, the_mesh.self_id.pub_key, PUB_KEY_SIZE);
if (crc != ack_crc) continue;
e.ack_status = ACK_OK;
#ifdef HIST_ARCHIVE
if (_arc) _arc->dmUpdate(e);
#endif
return;
}
+10 -5
View File
@@ -4,18 +4,17 @@
// view can keep showing it. The reply arrives through MyMesh's ping callback,
// which is a plain function pointer -- routed here via a single static instance.
//
// The ping timeout is still decided by the view (NearbyScreen's
// PING_TIMEOUT_MS), which calls clear() when it gives up.
// The view gives up after timeoutMs() (from the airtime, so it scales with
// the spreading factor) and calls clear().
class PingEngine {
public:
enum StartResult : uint8_t { STARTED = 0, BUSY, UNSUPPORTED, SEND_FAILED };
enum StartResult : uint8_t { STARTED = 0, BUSY, SEND_FAILED };
void begin(NodePrefs* prefs) { _prefs = prefs; s_instance = this; }
StartResult start(const uint8_t* pub_key) {
if (_active || !pub_key) return BUSY;
if (_prefs && _prefs->path_hash_mode > 1) return UNSUPPORTED; // no 3-byte path hash support
_active = true;
_tag = 0;
@@ -25,7 +24,11 @@ public:
// Always install the callback before sending so the response cannot race it.
the_mesh.setPingCallback(onPingResult, NULL);
_tag = the_mesh.sendPing(pub_key, _prefs ? _prefs->path_hash_mode + 1 : 1);
// The trace's hash width follows the path hash mode; a trace has no 3-byte
// width, so mode 2 pings with 4 bytes of the key.
uint8_t mode = _prefs ? _prefs->path_hash_mode : 0;
_timeout_ms = 3000;
_tag = the_mesh.sendPing(pub_key, mode >= 2 ? 4 : mode + 1, &_timeout_ms);
if (_tag == 0) {
clear();
return SEND_FAILED;
@@ -41,6 +44,7 @@ public:
}
bool isActive() const { return _active; }
uint32_t timeoutMs() const { return _timeout_ms; }
void getResult(int16_t& snr_out_x4, int16_t& snr_back_x4, uint32_t& rtt_ms) const {
snr_out_x4 = _snr_out_x4;
snr_back_x4 = _snr_back_x4;
@@ -70,6 +74,7 @@ private:
int16_t _snr_out_x4 = 0;
int16_t _snr_back_x4 = 0;
uint32_t _rtt_ms = 0;
uint32_t _timeout_ms = 3000;
};
// Header-only Core, single TU (see UiCore.h) -- the one definition lives here.
@@ -11,6 +11,11 @@
namespace radioctl {
// The TX power range the device's own settings offer (the app and CLI take
// -9 up). The top is the board's limit: 30 dBm on the GAT562 30S, not 22.
static const int TX_MIN_DBM = 2;
static const int TX_MAX_DBM = MAX_LORA_TX_POWER;
// freq / bw / sf / cr -> the radio (or the repeater profile, while relaying with one).
static void applyParams() { the_mesh.applyRepeaterRadio(); }
+2 -3
View File
@@ -449,7 +449,7 @@ void UITask::refreshNode() {
setTextFmt(_node_ping, "Ping %lu ms - SNR out %.1f back %.1f", (unsigned long)rtt,
out / 4.0f, back / 4.0f);
_pinging = false;
} else if (millis() - _ping_started_ms > 3000) {
} else if (millis() - _ping_started_ms > _core->ping.timeoutMs()) {
_core->ping.clear();
setText(_node_ping, "Ping: no reply");
_pinging = false;
@@ -472,8 +472,7 @@ void UITask::nodeAction(uint8_t action) {
_next_nearby_ms = millis() + 250;
lv_label_set_text(_node_ping, "Pinging...");
} else {
lv_label_set_text(_node_ping, r == PingEngine::UNSUPPORTED ? "Ping needs 1-2 byte path hashes"
: "Ping failed");
lv_label_set_text(_node_ping, "Ping failed");
}
break;
}
@@ -10,12 +10,8 @@
namespace radioview {
enum : uint8_t { R_PRESET, R_SF, R_BW, R_CR, R_TX, R_APC };
static const int TX_MIN = 2;
#ifdef LORA_TX_POWER
static const int TX_MAX = LORA_TX_POWER;
#else
static const int TX_MAX = 22;
#endif
static const int TX_MIN = radioctl::TX_MIN_DBM;
static const int TX_MAX = radioctl::TX_MAX_DBM;
static lv_obj_t* s_overlay = nullptr; // frequency entry
static lv_obj_t* s_ta = nullptr;
@@ -66,7 +66,6 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
char _ping_snr_back_str[24];
bool _pinging;
unsigned long _ping_started_ms;
static const unsigned long PING_TIMEOUT_MS = 3000UL;
// ── helpers ──────────────────────────────────────────────────────────────────
static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) {
@@ -266,7 +265,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
if (snr_back != 0)
snprintf(_ping_snr_back_str, sizeof(_ping_snr_back_str), "SNR back: %.1f", snr_back / 4.0f);
_pinging = false;
} else if (_pinging && millis() - _ping_started_ms >= PING_TIMEOUT_MS) {
} else if (_pinging && millis() - _ping_started_ms >= _task->pingTimeoutMs()) {
snprintf(_ping_time_str, sizeof(_ping_time_str), "RTT: timeout");
_ping_snr_out_str[0] = '\0';
_ping_snr_back_str[0] = '\0';
@@ -1036,8 +1036,8 @@ public:
return enter;
}
if (_selected == TX_POWER && p) {
if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; }
if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; }
if (right && p->tx_power_dbm < radioctl::TX_MAX_DBM) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; }
if (left && p->tx_power_dbm > radioctl::TX_MIN_DBM) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; }
}
if (_selected == RADIO_PRESET && p && enter) {
_picker.open(p, radioTarget(p), "Radio Preset");
+2 -3
View File
@@ -356,10 +356,9 @@ void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); }
void UITask::gotoBatteryCurve() { setCurrScreen(batt_curve_screen); }
bool UITask::startPing(const uint8_t* pub_key) {
PingEngine::StartResult r = _core->ping.start(pub_key);
if (r == PingEngine::UNSUPPORTED) showAlert("Ping not supported with 3-byte path hashes", 3000);
return r == PingEngine::STARTED;
return _core->ping.start(pub_key) == PingEngine::STARTED;
}
uint32_t UITask::pingTimeoutMs() const { return _core->ping.timeoutMs(); }
bool UITask::isPingActive() const { return _core->ping.isActive(); }
void UITask::getPingResult(int16_t& snr_out_x4, int16_t& snr_back_x4, uint32_t& rtt_ms) const {
_core->ping.getResult(snr_out_x4, snr_back_x4, rtt_ms);
+1
View File
@@ -473,6 +473,7 @@ public:
// Ping helpers (engine in ui-core/PingEngine.h)
bool startPing(const uint8_t* pub_key);
bool isPingActive() const;
uint32_t pingTimeoutMs() const;
void getPingResult(int16_t& snr_out_x4, int16_t& snr_back_x4, uint32_t& rtt_ms) const;
void clearPing();