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:
1 parent
2c1c2c8e99
commit
8031e60b55
46 files changed
+3663
-2020
No files matched your search
@@ -2,6 +2,17 @@
|
||||
#include <helpers/CompanionReaderConfig.h>
|
||||
#include <helpers/ota/OtaMemoryPolicy.h>
|
||||
|
||||
// Compact STM32 Companion images retain their existing application/filesystem
|
||||
// boundary. The persistent preference stays in the shared wire image even
|
||||
// when the optional private responder is excluded from an image.
|
||||
#ifndef MESH_ENABLE_LOST_REPLY
|
||||
#if defined(STM32_PLATFORM)
|
||||
#define MESH_ENABLE_LOST_REPLY 0
|
||||
#else
|
||||
#define MESH_ENABLE_LOST_REPLY 1
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// STM32WL has only 224 KiB of application flash. Keep its established image
|
||||
// within that boundary; larger targets provide the programmable alert engine.
|
||||
#ifndef COMPANION_FEATURE_NOTIFICATIONS
|
||||
|
||||
@@ -1052,6 +1052,8 @@ bool DataStore::loadPrefsInt(const char *filename,
|
||||
// Older images have no independent debug preference. Never inherit a
|
||||
// runtime-on value when loading one of those images.
|
||||
loaded_prefs.usb_debug_enabled = 0;
|
||||
// Old images cannot opt in using an unrelated runtime preference value.
|
||||
loaded_prefs.lost_reply = 0;
|
||||
double loaded_lat = node_lat;
|
||||
double loaded_lon = node_lon;
|
||||
// The original image ended after ble_pin at byte 84. Later releases only
|
||||
@@ -1059,7 +1061,14 @@ bool DataStore::loadPrefsInt(const char *filename,
|
||||
// actually emitted by those releases, but reject a truncated field/group
|
||||
// or an unknown tail before any value reaches the live preferences.
|
||||
static const uint32_t MIN_PREFS_SIZE = 84;
|
||||
static const uint16_t KNOWN_PREFS_SIZES[] = {
|
||||
#if defined(STM32_PLATFORM)
|
||||
// All published STM32 image lengths fit in one byte. The file size below
|
||||
// remains wide so oversized files cannot alias an accepted legacy length.
|
||||
using PrefsImageSize = uint8_t;
|
||||
#else
|
||||
using PrefsImageSize = uint16_t;
|
||||
#endif
|
||||
static const PrefsImageSize KNOWN_PREFS_SIZES[] = {
|
||||
84, 85, 90, 91, 92, 93, 140, 141, 142, 143, 144, 155,
|
||||
156, 157, 158, 159,
|
||||
159 + sizeof(loaded_prefs.bluetooth_name),
|
||||
@@ -1120,6 +1129,13 @@ bool DataStore::loadPrefsInt(const char *filename,
|
||||
332,
|
||||
#else
|
||||
235,
|
||||
#endif
|
||||
#ifdef TBEAM_1W
|
||||
243, // Opt-in lost reply, appended after independent USB debug
|
||||
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
||||
333,
|
||||
#else
|
||||
236,
|
||||
#endif
|
||||
};
|
||||
const uint32_t prefs_size = file.size();
|
||||
@@ -1281,6 +1297,7 @@ bool DataStore::loadPrefsInt(const char *filename,
|
||||
sizeof(loaded_prefs.gps_sync_interval_hours));
|
||||
readOptionalField(&loaded_prefs.usb_debug_enabled,
|
||||
sizeof(loaded_prefs.usb_debug_enabled));
|
||||
readOptionalField(&loaded_prefs.lost_reply, sizeof(loaded_prefs.lost_reply));
|
||||
#endif
|
||||
|
||||
// Any bytes left over form only part of a historically appended field.
|
||||
@@ -1290,6 +1307,7 @@ bool DataStore::loadPrefsInt(const char *filename,
|
||||
file.close();
|
||||
if (!success) return false;
|
||||
loaded_prefs.usb_debug_enabled = loaded_prefs.usb_debug_enabled == 1 ? 1 : 0;
|
||||
loaded_prefs.lost_reply = loaded_prefs.lost_reply <= 2 ? loaded_prefs.lost_reply : 0;
|
||||
if (!_prefs.copyPersistedValuesFrom(loaded_prefs)) return false;
|
||||
node_lat = loaded_lat;
|
||||
node_lon = loaded_lon;
|
||||
@@ -1318,7 +1336,7 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
|
||||
if (file) {
|
||||
#if defined(STM32_PLATFORM) && defined(__GNUC__)
|
||||
using namespace mesh::companion_prefs;
|
||||
uint8_t image[235];
|
||||
uint8_t image[236];
|
||||
const uint8_t pad[4] = {};
|
||||
size_t used = 0;
|
||||
for (const PrefField& field : PREF_FIELDS) {
|
||||
@@ -1337,7 +1355,7 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
|
||||
#if defined(STM32_PLATFORM)
|
||||
// Keep the explicit wire field list, but make one bounded atomic write.
|
||||
// Other platforms retain direct writes and their existing stack footprint.
|
||||
uint8_t image[235];
|
||||
uint8_t image[236];
|
||||
size_t image_length = 0;
|
||||
const auto writeField = [&](const void* data, size_t size) -> bool {
|
||||
if (image_length > sizeof(image)
|
||||
@@ -1467,11 +1485,11 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
|
||||
#endif
|
||||
// Pack only the explicit persisted tail, never the class's padded layout.
|
||||
// memcpy retains the GPS field's established native byte representation.
|
||||
uint8_t tail[9] = {
|
||||
uint8_t tail[10] = {
|
||||
_prefs.flood_retry_attempts, _prefs.flood_retry_max_path,
|
||||
_prefs.flood_retry_group_max_path, _prefs.flood_retry_advert_enabled,
|
||||
_prefs.one_key_dm_enabled, _prefs.bluetooth_enabled, 0, 0,
|
||||
_prefs.usb_debug_enabled};
|
||||
_prefs.usb_debug_enabled, _prefs.lost_reply};
|
||||
memcpy(tail + 6, &_prefs.gps_sync_interval_hours,
|
||||
sizeof(_prefs.gps_sync_interval_hours));
|
||||
success = success && writeField(tail, sizeof(tail));
|
||||
|
||||
@@ -1785,8 +1785,68 @@ bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* en
|
||||
}
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
void MyMesh::maybeReplyToLostQuestion(const ContactInfo& from,
|
||||
uint32_t sender_timestamp, const char* text) {
|
||||
if ((_prefs.lost_reply != mesh::companion::LostReplyNo
|
||||
&& _prefs.lost_reply != mesh::companion::LostReplyYes)
|
||||
|| from.type != ADV_TYPE_CHAT || isTransientContact(from)
|
||||
|| !mesh::companion::isLostReplyQuestion(text)) return;
|
||||
|
||||
// Contact cache references are borrowed. Preserve identity before servicing
|
||||
// other sends, then acquire the recipient only when ready to compose it.
|
||||
uint8_t recipient_key[PUB_KEY_SIZE];
|
||||
memcpy(recipient_key, from.id.pub_key, sizeof(recipient_key));
|
||||
const uint32_t now = _ms->getMillis();
|
||||
if (!lost_reply_limiter.canReply(recipient_key, sender_timestamp, now)) return;
|
||||
|
||||
expireExpectedAcks();
|
||||
AckTableEntry* available = NULL;
|
||||
for (AckTableEntry& entry : expected_ack_table) {
|
||||
if (entry.ack == 0) { available = &entry; break; }
|
||||
}
|
||||
// Automatic answers must leave ordinary sends and their ACK/RX reserve
|
||||
// intact. A busy application can retry its question after capacity returns.
|
||||
if (available == NULL || _mgr->getFreeCount() <= 2) return;
|
||||
ContactInfo* recipient = lookupPersistentContactByPubKey(recipient_key, PUB_KEY_SIZE);
|
||||
if (recipient == NULL || recipient->type != ADV_TYPE_CHAT) return;
|
||||
const char* answer = _prefs.lost_reply == mesh::companion::LostReplyYes ? "Yes" : "No";
|
||||
const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
|
||||
uint32_t expected_ack = 0, timeout = 0;
|
||||
// The final argument receives the actual packet hash for ACK cancellation. No
|
||||
// semantic message retry key is supplied, so user sends are not replaced.
|
||||
uint8_t retry_key[MAX_HASH_SIZE] = {};
|
||||
const int result = sendMessageDetached(*recipient, timestamp, answer,
|
||||
expected_ack, timeout, retry_key);
|
||||
// Automatic ACKs use the existing per-entry expiry service. Do not move an
|
||||
// application or terminal send's shared BaseChatMesh timeout deadline.
|
||||
if (result == MSG_SEND_FAILED) return;
|
||||
|
||||
lost_reply_limiter.rememberReply(recipient_key, sender_timestamp, now);
|
||||
if (expected_ack != 0) {
|
||||
clearExpectedAck(*available, false);
|
||||
available->msg_sent = now;
|
||||
available->expires_at = futureMillis(timeout);
|
||||
available->ack = expected_ack;
|
||||
available->message_timestamp = timestamp;
|
||||
available->contact = recipient;
|
||||
memcpy(available->retry_key, retry_key, sizeof(available->retry_key));
|
||||
mesh::Utils::sha256(available->text_fingerprint, sizeof(available->text_fingerprint),
|
||||
recipient_key, PUB_KEY_SIZE,
|
||||
(const uint8_t*)answer, strlen(answer));
|
||||
// No application or terminal transport owns this automatic send.
|
||||
available->reply_route = NULL;
|
||||
expireExpectedAcks();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const char *text) {
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
// Answer independently of app inbox capacity, including held/retried DMs.
|
||||
maybeReplyToLostQuestion(from, sender_timestamp, text);
|
||||
#endif
|
||||
#if MESH_ENABLE_ONE_KEY_DM
|
||||
uint8_t dm_id[ONE_KEY_DM_ID_SIZE];
|
||||
makeOneKeyDMId(dm_id, sender_timestamp, text);
|
||||
@@ -4551,12 +4611,18 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
|
||||
s.rx_ps_rx_us = _prefs.rx_ps_rx_us;
|
||||
s.rx_ps_sleep_us = _prefs.rx_ps_sleep_us;
|
||||
s.power_saving = _prefs.powersaving_enabled;
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
s.lost_reply = _prefs.lost_reply;
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
s.mqtt_enabled = _mqtt_enabled;
|
||||
#endif
|
||||
s.repeat = _prefs.client_repeat != 0;
|
||||
s.capabilities = WebConfigServer::CAP_LOCATION | WebConfigServer::CAP_AIRTIME
|
||||
| WebConfigServer::CAP_RX_DELAY | WebConfigServer::CAP_POWER_SAVING;
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
s.capabilities |= WebConfigServer::CAP_LOST_REPLY;
|
||||
#endif
|
||||
#ifdef BLE_PIN_CODE
|
||||
s.capabilities |= WebConfigServer::CAP_BLUETOOTH_NAME
|
||||
| WebConfigServer::CAP_BLUETOOTH_MAC
|
||||
@@ -9450,9 +9516,9 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
" stats-core / stats-radio / stats-radio-diag / stats-packets\r\n"
|
||||
" get prv.key (when private key export is enabled)\r\n"
|
||||
" get password (this role has no admin password)\r\n"
|
||||
" erase (erase stored settings and identity)\r\n");
|
||||
terminalOutput().print(" board\r\n");
|
||||
terminalOutput().print(" version\r\n");
|
||||
" erase (erase stored settings and identity)\r\n"
|
||||
" board\r\n"
|
||||
" version\r\n");
|
||||
terminalOutput().print(" get storage.layout\r\n");
|
||||
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
|
||||
terminalOutput().print(" get flash.health\r\n");
|
||||
@@ -9477,8 +9543,8 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
" set display.inbox <history|pending|unread>\r\n"
|
||||
" set display.rotation <0|90|180|270>\r\n"
|
||||
" get display.touch\r\n"
|
||||
" set display.touch <on|off> (this boot only)\r\n");
|
||||
terminalOutput().print(" get public.key\r\n");
|
||||
" set display.touch <on|off> (this boot only)\r\n"
|
||||
" get public.key\r\n");
|
||||
terminalOutput().print(
|
||||
" set {name|lat|lon|freq|tx|af} {value}\r\n"
|
||||
" get wifi|espnow|2.4ghz\r\n"
|
||||
@@ -9495,8 +9561,10 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
terminalOutput().print(" get bluetooth.stealth\r\n");
|
||||
terminalOutput().print(" set bluetooth.stealth on|off\r\n");
|
||||
#endif
|
||||
terminalOutput().print(" set pin <0-999999>\r\n");
|
||||
terminalOutput().print(" powersaving [on|off]\r\n");
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
terminalOutput().print(" get lost.reply\r\n" " set lost.reply <off|no|yes>\r\n");
|
||||
#endif
|
||||
terminalOutput().print(" set pin <0-999999>\r\n" " powersaving [on|off]\r\n");
|
||||
#if MESH_USB_LOGGING_AVAILABLE
|
||||
terminalOutput().print(
|
||||
" get usb.logging\r\n"
|
||||
@@ -9642,6 +9710,10 @@ static bool isCompanionRadioPrefsCommand(const char* command) {
|
||||
// Called only for directly attached USB/BLE/TCP/browser clients. Commands
|
||||
// received over LoRa use handleCommand() with a nonzero sender timestamp.
|
||||
bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_size) {
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
if (mesh::companion::handleLostReplyCommand(
|
||||
_prefs, command, reply, reply_size, [this]() { return savePrefs(); })) return true;
|
||||
#endif
|
||||
#ifdef MESH_BUTTON_AUDIO_HIL
|
||||
// Present only in an explicitly instrumented physical-test build.
|
||||
if (_ui && _ui->handleButtonAudioTest(command, reply, reply_size)) return true;
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
#include <helpers/ArduinoHelpers.h>
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <helpers/CompanionDelayedReplies.h>
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
#include <helpers/CompanionLostReply.h>
|
||||
#endif
|
||||
#include <helpers/CompanionMotaControl.h>
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include <helpers/LogicalMessageCache.h>
|
||||
@@ -807,6 +810,11 @@ private:
|
||||
};
|
||||
#define EXPECTED_ACK_TABLE_SIZE 8
|
||||
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
|
||||
#if MESH_ENABLE_LOST_REPLY
|
||||
mesh::companion::LostReplyLimiter lost_reply_limiter;
|
||||
void maybeReplyToLostQuestion(const ContactInfo& from, uint32_t sender_timestamp,
|
||||
const char* text);
|
||||
#endif
|
||||
#if MESH_ENABLE_ONE_KEY_DM
|
||||
// A recent ACK proves that peer can decrypt our normal text packets. Retry
|
||||
// attempts still send the introduction in case its contact was later erased.
|
||||
|
||||
@@ -115,6 +115,8 @@ public:
|
||||
// Debug verbosity is independent of the packet/output master switch. Keep
|
||||
// this after the published GPS cadence so legacy images default to quiet.
|
||||
uint8_t usb_debug_enabled = 0;
|
||||
// Appended opt-in private-message response: 0=off, 1=no, 2=yes.
|
||||
uint8_t lost_reply = 0;
|
||||
|
||||
private:
|
||||
class RadioPrefs : public CommonRadioPrefs {
|
||||
@@ -253,22 +255,22 @@ public:
|
||||
// untouched, and leave a normally constructed snapshot's adapters local.
|
||||
bool copyPersistedValuesFrom(const CompanionNodePrefs& source) {
|
||||
if (&source == this) return true;
|
||||
static_assert(sizeof(usb_debug_enabled) == sizeof(uint8_t),
|
||||
"The published USB debug preference is one byte");
|
||||
static_assert(sizeof(lost_reply) == sizeof(uint8_t),
|
||||
"The published lost reply preference is one byte");
|
||||
const uintptr_t first = reinterpret_cast<uintptr_t>(&airtime_factor);
|
||||
const uintptr_t last = reinterpret_cast<uintptr_t>(&usb_debug_enabled);
|
||||
const uintptr_t last = reinterpret_cast<uintptr_t>(&lost_reply);
|
||||
if (last < first
|
||||
|| last - first > sizeof(*this) - sizeof(usb_debug_enabled)) {
|
||||
|| last - first > sizeof(*this) - sizeof(lost_reply)) {
|
||||
return false;
|
||||
}
|
||||
const size_t size = static_cast<size_t>(last - first)
|
||||
+ sizeof(usb_debug_enabled);
|
||||
+ sizeof(lost_reply);
|
||||
const auto bounded = [size](const CompanionNodePrefs& values) -> bool {
|
||||
const uintptr_t object = reinterpret_cast<uintptr_t>(&values);
|
||||
const uintptr_t begin =
|
||||
reinterpret_cast<uintptr_t>(&values.airtime_factor);
|
||||
const uintptr_t end =
|
||||
reinterpret_cast<uintptr_t>(&values.usb_debug_enabled);
|
||||
reinterpret_cast<uintptr_t>(&values.lost_reply);
|
||||
const uintptr_t radio_begin = reinterpret_cast<uintptr_t>(&values.radio);
|
||||
const uintptr_t custom_begin = reinterpret_cast<uintptr_t>(&values.custom);
|
||||
// Subtractions happen only after the corresponding ordering checks.
|
||||
@@ -276,7 +278,7 @@ public:
|
||||
return begin >= object && begin - object <= sizeof(values)
|
||||
&& size <= sizeof(values) - static_cast<size_t>(begin - object)
|
||||
&& end >= begin
|
||||
&& end - begin == size - sizeof(values.usb_debug_enabled)
|
||||
&& end - begin == size - sizeof(values.lost_reply)
|
||||
&& radio_begin >= begin && size <= radio_begin - begin
|
||||
&& custom_begin >= begin && size <= custom_begin - begin;
|
||||
};
|
||||
|
||||
@@ -29,8 +29,8 @@ constexpr PrefField prefField() {
|
||||
static_assert(Offset < PrefsLat && Length <= UINT8_MAX,
|
||||
"compact preference field bounds");
|
||||
static_assert(Offset + Length <=
|
||||
__builtin_offsetof(CompanionNodePrefs, usb_debug_enabled)
|
||||
+ sizeof(CompanionNodePrefs::usb_debug_enabled),
|
||||
__builtin_offsetof(CompanionNodePrefs, lost_reply)
|
||||
+ sizeof(CompanionNodePrefs::lost_reply),
|
||||
"preference field stays before runtime adapters");
|
||||
static_assert(Offset + Length <= sizeof(CompanionNodePrefs),
|
||||
"preference field stays inside its object");
|
||||
@@ -114,6 +114,7 @@ static constexpr PrefField PREF_FIELDS[] = {
|
||||
PREF_FIELD(bluetooth_enabled, 1),
|
||||
PREF_FIELD(gps_sync_interval_hours, 2),
|
||||
PREF_FIELD(usb_debug_enabled, 1),
|
||||
PREF_FIELD(lost_reply, 1),
|
||||
};
|
||||
#undef PREF_FIELD
|
||||
#pragma GCC diagnostic pop
|
||||
@@ -122,7 +123,7 @@ constexpr size_t wireSize(size_t count) {
|
||||
return count == 0 ? 0 : PREF_FIELDS[count - 1].length + wireSize(count - 1);
|
||||
}
|
||||
static_assert(wireSize(MANDATORY_FIELDS) == 84, "published mandatory image size");
|
||||
static_assert(wireSize(FIELD_COUNT) == 235, "published STM32 preference image size");
|
||||
static_assert(wireSize(FIELD_COUNT) == 236, "published STM32 preference image size");
|
||||
|
||||
static const void* prefValue(const PrefField& field,
|
||||
const CompanionNodePrefs& values,
|
||||
|
||||
Reference in new issue
Block a user