Add Companion lost-status replies and repair room mailbox test adapters

Save an opt-in off/no/yes answer to private Am I lost questions from known
chat contacts. Keep reply traffic and ACK state bounded, preserve pending
user delivery, and expose the setting through local CLI and ESP32 WebConfig.

Retain compact STM32 application/filesystem boundaries, qualify the Tag BLE
queue allowance, and exercise saved-image compatibility and browser controls.
Fix stale room mailbox protocol dependencies in the ACL/route test fixtures.

Validation: ten Companion builds, sanitized production-path regressions,
preference fault/compatibility tests, browser tests, and 86 capacity/build
recipe checks passed. No live radio was flashed for this feature.
This commit is contained in:
mikecarper committed 2026-10-10 19:24:42 -07:00
1 parent 2c1c2c8e99
commit 8031e60b55
46 files changed
+3663 -2020

No files matched your search

+12
View File
@@ -671,6 +671,18 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp,
return rc;
}
int BaseChatMesh::sendMessageDetached(const ContactInfo& recipient,
uint32_t timestamp, const char* text,
uint32_t& expected_ack,
uint32_t& est_timeout,
uint8_t* packet_hash) {
const unsigned long previous_timeout = txt_send_timeout;
const int result = sendMessage(recipient, timestamp, 0, text, expected_ack,
est_timeout, packet_hash);
txt_send_timeout = previous_timeout;
return result;
}
int BaseChatMesh::sendCommandData(const ContactInfo& recipient,
uint32_t timestamp, uint8_t attempt,
uint8_t txt_type, const char* text,
+5
View File
@@ -100,6 +100,11 @@ class BaseChatMesh : public mesh::Mesh {
protected:
void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4);
// Autonomous replies have independent ACK tracking and must preserve the
// application/terminal send's shared timeout.
int sendMessageDetached(const ContactInfo& recipient, uint32_t timestamp,
const char* text, uint32_t& expected_ack,
uint32_t& est_timeout, uint8_t* packet_hash);
BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables)
: mesh::Mesh(radio, ms, rng, rtc, mgr, tables)
{
+135
View File
@@ -0,0 +1,135 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
namespace companion {
// This is an owner-selected answer, never an assessment of a GPS position.
// Values are also the append-only Companion preference representation.
enum LostReply : uint8_t { LostReplyOff = 0, LostReplyNo = 1, LostReplyYes = 2 };
inline bool lostReplyWhitespace(char c) {
return c == ' ' || uint8_t(c - '\t') <= uint8_t('\r' - '\t');
}
inline bool isLostReplyQuestion(const char* text) {
if (text == NULL) return false;
while (lostReplyWhitespace(*text)) ++text;
static const char question[] = "am i lost";
for (size_t i = 0; i < sizeof(question) - 1; ++i) {
char c = *text++;
if (c >= 'A' && c <= 'Z') c = char(c + ('a' - 'A'));
if (c != question[i]) return false;
}
if (*text == '?') ++text;
while (lostReplyWhitespace(*text)) ++text;
return *text == 0;
}
inline const char* lostReplyName(uint8_t value) {
return value == LostReplyNo ? "no" : value == LostReplyYes ? "yes" : "off";
}
// Only directly attached clients call this handler. No radio CLI route may
// change the locally saved answer, even with a remote CLI permission flag.
template<typename Prefs, typename Save>
bool handleLostReplyCommand(Prefs& prefs, const char* command, char* reply,
size_t capacity, Save save) {
if (strcmp(command, "get lost.reply") == 0) {
snprintf(reply, capacity, "> %s", lostReplyName(prefs.lost_reply));
return true;
}
const char prefix[] = "set lost.reply ";
if (strncmp(command, prefix, sizeof(prefix) - 1) != 0) return false;
const char* value = command + sizeof(prefix) - 1;
uint8_t selected;
if (strcmp(value, "off") == 0) selected = LostReplyOff;
else if (strcmp(value, "no") == 0) selected = LostReplyNo;
else if (strcmp(value, "yes") == 0) selected = LostReplyYes;
else {
snprintf(reply, capacity, "Error: use off, no or yes");
return true;
}
const uint8_t previous = prefs.lost_reply;
prefs.lost_reply = selected;
if (!save()) {
prefs.lost_reply = previous;
snprintf(reply, capacity, "Error: could not save lost reply");
} else {
snprintf(reply, capacity, "> lost.reply %s", value);
}
return true;
}
// Four retained full peer identities bound both memory and response traffic.
// A peer slot cannot be evicted during its minute-long cooldown. Exact radio
// retries remain suppressed while that peer's last answered request is kept;
// this runtime cache is intentionally cleared by a reboot.
class LostReplyLimiter {
public:
static constexpr size_t PeerCount = 4;
static constexpr size_t KeySize = 32;
static constexpr uint32_t PeerIntervalMillis = 60000;
static constexpr uint32_t GlobalIntervalMillis = 10000;
LostReplyLimiter() : last_reply_(0), replied_(false) {
memset(peers_, 0, sizeof(peers_));
}
bool canReply(const uint8_t* key, uint32_t timestamp, uint32_t now) const {
return replySlot(key, timestamp, now) >= 0;
}
void rememberReply(const uint8_t* key, uint32_t timestamp, uint32_t now) {
const int chosen = replySlot(key, timestamp, now);
if (chosen < 0) return;
Peer& peer = peers_[chosen];
memcpy(peer.key, key, KeySize);
peer.timestamp = timestamp;
peer.replied_at = now;
peer.valid = true;
last_reply_ = now;
replied_ = true;
}
private:
struct Peer {
uint8_t key[KeySize];
uint32_t timestamp;
uint32_t replied_at;
bool valid;
};
Peer peers_[PeerCount];
uint32_t last_reply_;
bool replied_;
// Share the full identity/cooldown scan between admission and commit.
// Inlining both copies costs flash on constrained Companion targets.
#if defined(__GNUC__)
__attribute__((noinline))
#endif
int replySlot(const uint8_t* key, uint32_t timestamp, uint32_t now) const {
if (key == NULL || (replied_ && uint32_t(now - last_reply_) < GlobalIntervalMillis))
return -1;
int available = -1;
for (size_t i = 0; i < PeerCount; ++i) {
const Peer& peer = peers_[i];
if (peer.valid && memcmp(peer.key, key, KeySize) == 0) {
return peer.timestamp != timestamp
&& uint32_t(now - peer.replied_at) >= PeerIntervalMillis ? int(i) : -1;
}
if (available < 0 && (!peer.valid
|| uint32_t(now - peer.replied_at) >= PeerIntervalMillis)) available = int(i);
}
return available;
}
};
static_assert(sizeof(LostReplyLimiter) <= 192, "lost reply state stays bounded");
} // namespace companion
} // namespace mesh
+1 -1
View File
@@ -18,7 +18,7 @@
// separately, see wcIsAdminPasswordKey below.
static const char* const WC_ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "bluetooth.name", "bluetooth.mac", "bluetooth.stealth",
"name", "bluetooth.name", "bluetooth.mac", "bluetooth.stealth", "lost.reply",
"lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.fem.txgain", "radio.rxps", "powersaving",
"repeat",
File diff suppressed because it is too large. Load diff
+17
View File
@@ -2092,6 +2092,8 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
radio["bluetooth_name"] = (const char*)node.bluetooth_name;
radio["bluetooth_mac"] = (const char*)node.bluetooth_mac;
radio["bluetooth_stealth"] = node.bluetooth_stealth;
static const char* const LOST_REPLY_MODES[] = { "off", "no", "yes" };
radio["lost_reply"] = LOST_REPLY_MODES[node.lost_reply <= 2 ? node.lost_reply : 0];
radio["lat"] = node.lat;
radio["lon"] = node.lon;
radio["advert_interval"] = node.advert_interval;
@@ -2215,6 +2217,21 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
return;
}
if (set.containsKey("lost.reply")) {
NodeSnapshot node = {};
_cb->getNodeSnapshot(node);
if (!(node.capabilities & CAP_LOST_REPLY)) {
req->send(400, "application/json", "{\"error\":\"setting unavailable\",\"key\":\"lost.reply\"}");
return;
}
const char* value = set["lost.reply"];
if (!value || (strcmp(value, "off") != 0 && strcmp(value, "no") != 0
&& strcmp(value, "yes") != 0)) {
req->send(400, "application/json", "{\"error\":\"expected off, no or yes\",\"key\":\"lost.reply\"}");
return;
}
}
if (_mode == MODE_SETUP && _initial_setup && !set.containsKey("password")
&& (reboot_after || set.containsKey("wifi.ssid"))) {
req->send(400, "application/json",
+2
View File
@@ -92,6 +92,7 @@ public:
CAP_ESPNOW_CHANNEL = 1UL << 15,
CAP_BLUETOOTH_MAC = 1UL << 16,
CAP_BLUETOOTH_STEALTH = 1UL << 17,
CAP_LOST_REPLY = 1UL << 18,
CAP_DELAYS = CAP_RX_DELAY | CAP_TX_DELAY,
};
@@ -103,6 +104,7 @@ public:
char bluetooth_name[32]; // empty = transport's default device name
char bluetooth_mac[24]; // empty = chipset factory address
bool bluetooth_stealth; // independent of the address policy
uint8_t lost_reply; // 0=off, 1=no, 2=yes (Companion only)
char admin_password[32]; // empty = trusted LAN/no login prompt
double lat;
double lon;