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

+2
View File
@@ -221,6 +221,7 @@ jobs:
python3 -B test/test_companion_primary_radio_persistence.py -v
python3 -B test/test_companion_response_bounds.py -v
python3 -B test/test_companion_ack_delivery.py -v
python3 -B test/test_companion_lost_reply.py -v
python3 -B test/test_companion_delayed_reply_delivery.py -v
python3 -B test/test_companion_hardware_serial.py -v
python3 -B test/test_companion_hardware_serial_gps.py -v
@@ -567,6 +568,7 @@ jobs:
python3 -B test/test_heltec_v4_wifi_setup_page.py -v
python3 -B test/test_sensor_webconfig_capabilities.py -v
python3 -B test/test_webconfig_ui_runtime.py
python3 -B test/test_webconfig_lost_reply.py -v
MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_web_ui.py -v
MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_mail_web_ui.py -v
+1 -1
View File
@@ -5,7 +5,7 @@
* **[Repeater forwarding policies](https://mikecarper.github.io/MeshCore/flood_filtering/).** Actions include dropping, rate limiting, assigning scopes, selecting retries, and controlling forwarding priority. Separate policies cover ordinary radio forwarding, bridges, and profile crossover.
* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering, secondary-radio changes, radio schedules, and clock updates to a whole fleet, existing configured or home regions, a GPS center and radius, one node, or a semicolon-separated target list. The publisher signs each complete command once, splitting longer requests across two packets; receivers hold its public verification key, with replay protection and acknowledgements before radio handoff.
* **[Flood retries that account for network topology](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#flood-and-advert-settings).** Configure retry counts, path limits, packet-type limits, target prefixes, ignored repeaters, and channel-specific eligibility. Bridge buckets keep retrying until traffic is heard on the intended sides of a relay or the retry budget expires. Qualifying echoes cancel unnecessary attempts.
* **[Enhanced direct messages and direct-route retries](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#direct-retry-settings).** Adapt the coding rate across bounded retry attempts and cancel unnecessary retries when a qualifying echo is heard. [Single Key DMs](https://mikecarper.github.io/MeshCore/companion_protocol/#private-text-when-only-the-sender-knows-the-recipient) let compatible Companions send private text when only the sender knows the recipient's key, using a signed, encrypted introduction. The recipient accepts the sender through the app or opts into automatic acceptance; compact STM32WL builds omit this feature.
* **[Enhanced direct messages and direct-route retries](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#direct-retry-settings).** Adapt the coding rate across bounded retry attempts and cancel unnecessary retries when a qualifying echo is heard. [Single Key DMs](https://mikecarper.github.io/MeshCore/companion_protocol/#private-text-when-only-the-sender-knows-the-recipient) let compatible Companions send private text when only the sender knows the recipient's key, using a signed, encrypted introduction. The recipient accepts the sender through the app or opts into automatic acceptance; compact STM32WL builds omit this feature. An optional [Companion lost-status responder](https://mikecarper.github.io/MeshCore/companion_lost_reply/) answers private "Am I lost?" questions with a saved Yes or No.
* **[Enhanced telemetry history](https://mikecarper.github.io/MeshCore/telemetry_decoder/).** Send compact temperature and battery-voltage snapshots of up to 165 samples per packet, plus external-voltage history in chunks. Read retained GPS history through the CLI; GPS history is not included in the automatic telemetry packets. Decode snapshots and CLI history in the browser.
* **[Room topics, mailboxes, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Regular users manage personal mailboxes with public or selected-sender access, explicit receipts, and mailbox-only delivery for sleeping trackers. Room users can manage their own return routes and skip older unread chat by date or keep the newest N messages. Explicit ACL roles persist; full-key bans and per-user post/request limits apply to room sessions. ESP32 images with WebConfig provide local Wi-Fi chat, an information board, and an admin dashboard for mailboxes, routes, delivery, activity, catch-up and access. The existing app's chat and catch-up formats remain supported.
* **[Full firmware profiles, Companion transports, and ESP32 bridges](https://mikecarper.github.io/MeshCore/full_profiles/).** Full combines the exact board and role's supported features with runtime controls: Companion transports and firmware serving, or qualified ESP32 infrastructure services and MQTT/ESP-NOW/RS-232 bridges. Includes capacity exceptions, matching application slots, exact-board/role partition migration packages, and nRF52 Full/Reduced supported-sensor packages with LoRa OTA. The picker links supported migration routes; USB layout changes use the matching merged image, since an app-only image cannot change the partition table.
+1 -1
View File
@@ -4192,7 +4192,7 @@ apply_companion_radio_full_profile() {
# Every nRF52 Full Companion lends the upper half of its offline queue
# to the cold mOTA context. Keep 256 slots for everyday use and reserve
# runtime space for Bluetooth, displays, tasks and filesystem buffers.
append_platformio_build_unflags "-DOFFLINE_QUEUE_SIZE=512 -DOFFLINE_QUEUE_SIZE=128 -DOFFLINE_QUEUE_SIZE=16"
append_platformio_build_unflags "-DOFFLINE_QUEUE_SIZE=512 -DOFFLINE_QUEUE_SIZE=240 -DOFFLINE_QUEUE_SIZE=128 -DOFFLINE_QUEUE_SIZE=16"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOFFLINE_QUEUE_SIZE=256 -DOTA_SHARED_COMPANION_QUEUE=1"
case "$env_name" in
Heltec_t096_companion_radio_full_*)
+1
View File
@@ -95,6 +95,7 @@ over the normal binary USB, BLE, or TCP connection:
| [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Companion text terminal and binary command `0x42`; read-only, no private-key export feature required |
| `get prv.key` | Local terminal and binary command `0x42`; requires private key export enabled |
| `get password` | Reports that Companion has no admin password; infrastructure returns its password locally |
| [`get/set lost.reply`](companion_lost_reply.md) | Local Companion CLI and framed command `0x42` when `MESH_ENABLE_LOST_REPLY` is enabled; saved `off`, `no` or `yes` private-message auto response |
| `get wifi.pwd`, `get mqttN.password`, `get mqttN.token` | Local terminal and binary command `0x42`; corresponding WiFi/MQTT feature required |
| [`board`](cli_commands.md#show-the-hardware-name) | Every Companion text terminal and command `0x42` |
| [`version`](cli_commands.md#get-the-version) | Every Companion text terminal and protocol-v14 command `0x42`; returns the untruncated build identity |
+81
View File
@@ -0,0 +1,81 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/companion_lost_reply/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Companion lost-status auto response
Development Companion builds containing this feature can automatically answer a private `Am I lost?` message with `Yes`
or `No`. You choose the saved answer. The default is `off`, and the firmware
does not infer lost status from GPS, distance, or a missing phone connection.
This is included in nRF52, ESP32 and RP2040 Companion profiles, including
T1000-E and RAK Tag. Compact STM32 Companion images omit it to preserve their
existing application and filesystem boundaries. A custom build can explicitly
enable `MESH_ENABLE_LOST_REPLY=1` when its flash budget permits; the normal
flash and RAM checks still apply.
Configure it through a directly connected local Companion CLI:
~~~text
get lost.reply
set lost.reply no
set lost.reply yes
set lost.reply off
~~~
The setting survives reboot and takes effect immediately. USB, BLE and other
local Companion transports can use framed command `0x42`. Builds with a text
terminal also accept these commands there. LoRa CLI cannot read or change this
local setting. A failed save restores the previous answer and reports an error.
Older saved preference images load the new setting as `off`.
ESP32 Companions with WebConfig also have a **Lost-status auto reply**
selection on the radio configuration page. Choose Off, Reply No or Reply Yes
and save. The control is shown only on Companion firmware with this capability.
The existing mobile app protocol needs no new message type; a native app toggle
is not added by this firmware change.
## Dog tracker example
The owner's Companion stores the tracker as a normal radio contact. Set the
answer to `no` during normal operation. If the dog is lost, set it to `yes`.
When the tracker sends `Am I lost?` privately to that reachable Companion, it
receives the selected answer, even when the owner's phone is disconnected.
The same configured answer applies to all eligible contacts on this Companion.
A tracker client can use that answer to choose its reporting behavior, but its
check-in schedule, sleep/wake policy and GPS reporting must be configured in
the tracker client firmware. The tracker must stay awake for the separate
private answer; a delivery ACK for the question is not that answer. These plain
text answers have no request ID, so a delayed answer cannot be precisely
correlated with a newer check-in. The responding Companion must be powered on
and reachable when a question arrives. For mail that waits while a tracker sleeps,
use [a personal room mailbox](room_services.md#sleeping-tracker-example).
## Matching and traffic limits
Only an authenticated ordinary private message from a known persistent radio
contact is eligible. The contact must be an ordinary Companion/chat contact;
room-server and repeater contacts are excluded. Temporary unknown senders,
public channels, room chat posts and CLI messages do not trigger a response.
The exact phrase `am I lost` is matched without regard to ASCII letter case,
with an optional final question mark and surrounding whitespace. Extra words
or commands do not match. The reply is ordinary encrypted private text, `Yes`
or `No`, and neither answer can trigger another automatic reply.
Automatic responses are limited to one per contact per minute and one globally
per ten seconds. A bounded four-peer cache uses full public keys and the
incoming request timestamp to suppress retries. Its state clears on reboot.
The same retained request is not answered again; a fresh check uses a new
timestamp. An old retry can receive another answer after a newer request
replaces its saved timestamp or its peer slot is evicted. Queueing an answer
records the request even if later delivery fails. An unanswered poll means
unknown, and the tracker should trust replies only from its configured
Companion's full public key.
The responder leaves packet capacity for normal receive/ACK work, uses a free
ACK-tracking entry, and does not replace pending user messages or their
delivery deadlines. Busy packet or ACK capacity, or a failed send, can prevent an answer;
the tracker should retry later. A full offline app inbox does not disable the
responder. Normal message reception and notification behavior remain in place.
+8
View File
@@ -17,6 +17,7 @@ A reboot clears it.
| ESP32 without PSRAM | 256 |
| nRF52840 with at most 300 contact slots | 256 |
| nRF52840 with more than 300 contacts and the contact cache (non-mOTA-shared queue) | 256 total: up to 15 held DMs use slots until accepted |
| RAK WisMesh Tag ordinary BLE Companion | 240 total: up to 15 held DMs use slots until accepted |
| nRF52840 with more than 300 contacts but no contact cache | 208 ordinary frames plus a separate 15-DM queue |
| nRF52 Full Companion with the memory correction | 256 normally; 128 while mOTA owns shared storage |
| RP2040 | 256 |
@@ -60,6 +61,13 @@ Uncached high-contact profiles keep the conservative 208-frame default and
separate held queue. An explicit target `OFFLINE_QUEUE_SIZE` still takes
precedence.
The ordinary RAK WisMesh Tag BLE profile uses 240 frames to retain the runtime
RAM reserve for Bluetooth, filesystem buffers, and the main task. It keeps
350 contact slots and 40 group channels. All 240 slots are available when no
DMs are held; 15 held DMs leave 225 ordinary slots. This saves 2,832 bytes
compared with 256 frames. The Tag Full Companion retains 256 frames normally
and 128 while mOTA borrows queue storage.
Each queue slot currently costs 177 bytes. A 256-frame queue reserves 45,312
bytes, while a 512-frame queue reserves 90,624 bytes. There is no 256-frame
protocol limit: the queue length and indexes can represent 512 or more. The
+3
View File
@@ -105,6 +105,9 @@ Room Server provides the room-side queue and polling protocol. A tracker still
needs client firmware that schedules check-ins and interprets authenticated
commands. Ordinary mailbox text is never executed as a CLI command, and does
not itself enable a tracker, wake it remotely or change its GPS reporting rate.
For a reachable owner's Companion, the optional
[lost-status auto responder](companion_lost_reply.md) can also answer a private
`Am I lost?` question with a saved `Yes` or `No`.
### Wi-Fi mailbox management
+94
View File
@@ -0,0 +1,94 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/test-results/companion_lost_reply/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Companion lost-status response validation
Validated on 2026-10-10. These are production-path host tests, browser tests
and firmware builds. No lab radio was flashed for this feature; this report
does not qualify a sleeping tracker client or a live LoRa exchange.
## Automated checks
The responder test executes the actual private receive callback, message
composition/send functions, ACK handling and local CLI policy. Seven compiled
variants cover normal and shared one-key DM queues, a text terminal, explicit
STM32 enablement, default STM32 exclusion and explicit exclusion elsewhere.
AddressSanitizer and UndefinedBehaviorSanitizer checks passed.
Coverage includes exact question matching, Yes/No loop avoidance, known full
identities, temporary/unknown contacts, room/channel/CLI exclusions, a full
offline inbox, duplicate requests, cooldowns, clock wrap, busy packet/ACK
capacity, send failures and preservation of pending user messages/deadlines.
Queueing the response preserves the shared send timer. ACK reception retains
the existing shared-timer behavior; independent per-message expiry continues
to protect pending user deliveries.
Excluded builds have no responder state, automatic send, successful setting
command, save or WebConfig capability.
Saved preference tests cover all three values, all 256 stored byte values,
legacy images loading off, reboot, atomic save failures, recovery copies and
runtime adapter preservation. Existing CLI, delivery, inbox, notifications
and preference regression suites passed after updating adapters for the new
append-only preference byte.
WebConfig tests execute the actual JSON and settings handlers with
ArduinoJson. Chromium exercises Off -> No -> Yes -> Off, Companion-only
visibility and the absence of the control on infrastructure roles. Existing
browser, authorization, secret masking and generated asset checks passed.
Both new suites are included in the unit-test workflow.
## Capacity and reliability
The responder cache is 184 bytes on ARM and retains four complete public keys.
The saved preference uses an existing padding byte in the tested ARM preference
object and one new byte in its explicit storage image. No heap allocation is
introduced by the responder.
Compact STM32 Companion images exceeded their existing application boundary
when the responder was included. Those default to `MESH_ENABLE_LOST_REPLY=0`,
preserving their existing features and filesystem layout. Explicit opt-in is
tested synthetically and remains subject to the normal capacity checks.
The ordinary RAK WisMesh Tag BLE queue uses 240 frames, retaining 350 contacts
and 40 channels and saving 2,832 bytes. Source comparison showed that its prior
256-frame recipe was already below the current runtime RAM allowance before
the new 184-byte responder cache. Full retains its 256-frame shared queue.
Sanitized tests exercise real FIFO wrapping, downloads and held-DM isolation
at 240 slots, plus the production Full override.
Queueing an answer suppresses that request even if later delivery fails. A
tracker needs a fresh timestamp for a later poll, must stay awake for the
private answer, and must authenticate its configured Companion. Plain Yes/No
answers do not contain a poll ID, so delayed replies cannot be precisely
correlated with a newer poll. An unanswered poll means unknown.
## Firmware build qualification
All ten final builds passed flash and internal RAM policy checks. Compact
STM32 and both Full targets were also packaged using the normal
`v1.17.1.9-halo-keymind-cascade-dev` label. Ordinary targets used their normal
PlatformIO recipes. Full nRF52 UF2 boundaries and applicable Bluetooth DFU
handoff checks passed. These are local qualification artifacts, not new
published release assets.
| Companion target | Responder | Flash used / limit (bytes) | Link-time internal RAM available / required (bytes) |
| --- | --- | --- | --- |
| RAK 3x72 USB | Excluded | 228,924 / 229,376 | 24,728 / 17,408 |
| Tiny Relay USB | Excluded | 228,944 / 229,376 | 24,728 / 17,408 |
| Wio-E5 Mini USB | Excluded | 228,888 / 229,376 | 24,704 / 17,408 |
| Wio-E5 USB | Excluded | 229,008 / 229,376 | 24,736 / 17,408 |
| T1000-E Full | Included | 607,480 / 708,608 | 87,064 / 44,864 |
| RAK WisMesh Tag Full | Included | 649,408 / 712,704 | 82,112 / 44,864 |
| T1000-E USB | Included | 426,756 / 708,608 | 48,248 / 38,944 |
| Pico W USB | Included | 640,180 / 1,568,768 | 101,712 / 17,408 |
| Heltec V4 BLE | Included | 1,550,217 / 6,553,600 | 263,448 / 118,560 |
| RAK WisMesh Tag BLE | Included | 459,564 / 712,704 | 46,316 / 44,864 |
The STM32 historical-size table uses byte entries while retaining a 32-bit
input file size; exhaustive tests reject oversized aliases and partial
images. Tiny Relay and Wio-E5 use the size option already used by neighboring
STM32 recipes. Existing terminal help was combined into fewer writes while
retaining its text, order and 256-byte maximum per write. Supported images
also show the new lost-reply commands. Contacts, channels and filesystem
boundaries are unchanged on those compact boards.
+2
View File
@@ -40,6 +40,7 @@ Completed validation reports, experiments, and test guides are collected here. O
## Companion, USB, BLE, and display
- [Companion lost-status replies](companion_lost_reply.md)
- [Contact-cache and NimBLE RAM qualification](companion_contact_cache_results.md)
- [USB Companion ASCII default validation](companion_usb_ascii_validation.md)
- [USB Companion client compatibility](companion_usb_client_validation.md)
@@ -54,6 +55,7 @@ Completed validation reports, experiments, and test guides are collected here. O
## Build, storage, memory, and checklists
- [Personal room mailbox checks](room_mailboxes.md)
- [Stock nRF52 bootloader version audit](bootloader_version_stock_audit.md)
- [CLI setting dispatch audit](cli_settings_audit.md)
- [Hardware validation checklist](hardware_validation_checklist.md)
@@ -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
+23 -5
View File
@@ -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));
+79 -7
View File
@@ -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;
+8
View File
@@ -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.
+9 -7
View File
@@ -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,
+1
View File
@@ -81,6 +81,7 @@ nav:
- WiFi.md
- flood_filtering.md
- gps_tracking.md
- companion_lost_reply.md
- nrf52_power_management.md
- CLI and protocol reference:
- cli_commands.md
+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;
@@ -9,6 +9,9 @@
#include <helpers/ClientACLResponse.h>
#include <helpers/ClientPathPersistence.h>
#include <helpers/RoutingPolicy.h>
#ifndef TEST_SENSOR
#include <helpers/RoomMailProtocol.h>
#endif
#define REQ_TYPE_GET_ACCESS_LIST 0x05
#define REQ_TYPE_GET_TELEMETRY_DATA 0x04
@@ -16,6 +19,9 @@
#define SERVER_RESPONSE_DELAY 300
#define OUT_PATH_UNKNOWN 0xFF
#define PERM_ACL_ROLE_MASK 7
#define PERM_ACL_GUEST 0
#define PERM_ACL_READ_ONLY 1
#define PERM_ACL_READ_WRITE 2
#define PERM_ACL_ADMIN 3
namespace mesh {
@@ -141,6 +147,7 @@ public:
size_t = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY);
int telemetryPrefix(uint8_t*, size_t);
MemoryFS fs;
MemoryFS* _fs = &fs;
mesh::RoomAccessPolicy room_access;
mesh::LogicalMessageCache<8> recent_room_polls;
TARGET_CLASS() { metadata_filesystem = &fs; assert(room_access.load(&fs)); }
@@ -223,7 +230,75 @@ static void print_legacy(const uint8_t* body, unsigned count, size_t length) {
for (size_t i = 0; i < length; ++i) std::cout << hex[body[i] >> 4] << hex[body[i] & 15];
std::cout << '\n';
}
#ifndef TEST_SENSOR
static void verify_mailbox_access() {
const uint8_t roles[] = {0, 1, 2, 3, 4, 5, 6, 7, 131, 255};
for (uint8_t permissions : roles) {
for (bool active : {false, true}) {
TARGET_CLASS target;
memset(target.sender.id.pub_key, 0xa1, PUB_KEY_SIZE);
target.sender.permissions = permissions;
target.sender.last_activity = active ? 12 : 0;
uint8_t settings[] = {mesh::ROOM_MAIL_REQUEST_SUBTYPE, 0, 0, 0, 0, 0, 0};
const int length = request(target, &target.sender, settings, sizeof(settings));
const uint8_t role = permissions & PERM_ACL_ROLE_MASK;
const bool admitted = role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE
|| role == PERM_ACL_ADMIN || (role == PERM_ACL_GUEST && active);
if (!admitted) assert(length == 0);
else {
assert(length == 17 && target.reply_data[4] == mesh::ROOM_MAIL_REQUEST_SUBTYPE);
assert(target.reply_data[5] == 0 && target.reply_data[6] == 0);
assert(target.reply_data[11] == 0); // Default closed mailbox.
}
assert(target.fs.files.empty()); // A read never provisions an inbox.
uint8_t recipient[PUB_KEY_SIZE]; memset(recipient, 0xb2, sizeof(recipient));
uint8_t provision[] = {mesh::ROOM_MAIL_REQUEST_SUBTYPE, 8, 0, 0, 0, 0, 1};
uint8_t reply[32] = {};
assert(mesh::handleRoomMailRequest(&target.fs, recipient, false, 1,
provision, sizeof(provision), reply, sizeof(reply)) == 7 && reply[2] == 0);
std::vector<uint8_t> send(38);
send[0] = mesh::ROOM_MAIL_REQUEST_SUBTYPE; send[1] = 3;
memcpy(send.data() + 2, recipient, PUB_KEY_SIZE);
mesh::room_mail_protocol_detail::put16(send.data() + 34, 2);
send[36] = 'h'; send[37] = 'i';
const int sent = request(target, &target.sender, send.data(), send.size());
const bool can_send = role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN;
if (!admitted) assert(sent == 0);
else {
assert(sent == 11 && target.reply_data[5] == 3);
assert(target.reply_data[6] == (can_send ? 0 : 6));
}
mesh::RoomMailStatus status;
assert(mesh::getRoomMailStatus(&target.fs, recipient, status) == mesh::RoomMailResult::Success);
assert(status.count == unsigned(admitted && can_send));
}
}
TARGET_CLASS banned;
memset(banned.sender.id.pub_key, 0xa1, PUB_KEY_SIZE);
banned.sender.permissions = PERM_ACL_READ_WRITE;
uint8_t recipient[PUB_KEY_SIZE]; memset(recipient, 0xb2, sizeof(recipient));
assert(banned.room_access.addBan(&banned.fs, recipient) == mesh::RoomAccessPolicy::BanResult::Saved);
std::vector<uint8_t> send(38);
send[0] = mesh::ROOM_MAIL_REQUEST_SUBTYPE; send[1] = 3;
memcpy(send.data() + 2, recipient, PUB_KEY_SIZE);
mesh::room_mail_protocol_detail::put16(send.data() + 34, 2);
send[36] = 'h'; send[37] = 'i';
assert(request(banned, &banned.sender, send.data(), send.size()) == 7);
assert(banned.reply_data[6] == 6);
assert(!banned.fs.files.count(mesh::ROOM_MAIL_PRIMARY_PATH));
for (size_t length = 0; length < send.size(); ++length) {
std::vector<uint8_t> truncated(send.begin(), send.begin() + length);
const int used = request(banned, &banned.sender, truncated.data(), length);
assert(used == 0 || used == 7);
}
std::cout << "Room mailbox guest, reader, writer and recipient-ban boundaries passed\n";
}
#endif
int main() {
#ifndef TEST_SENSOR
verify_mailbox_access();
#endif
uint8_t query[] = {REQ_TYPE_GET_ACCESS_LIST, 0, 0};
uint8_t secret[PUB_KEY_SIZE] = {};
unsigned paths = 0;
+491
View File
@@ -0,0 +1,491 @@
#include "CompanionFeatures.h"
#include "production_lost_include.inc"
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <stdint.h>
#include <string>
#include <vector>
#define PUB_KEY_SIZE 32
#define MAX_HASH_SIZE 8
#define MAX_TEXT_LEN 150
#define MAX_FRAME_SIZE 172
#define EXPECTED_ACK_TABLE_SIZE 8
#define TXT_TYPE_PLAIN 0
#define TXT_TYPE_CLI_COMMAND 1
#define TXT_TYPE_SIGNED_PLAIN 2
#define ADV_TYPE_CHAT 1
#define ADV_TYPE_ROOM 3
#define OUT_PATH_UNKNOWN 255
#define MSG_SEND_FAILED 0
#define MSG_SEND_SENT_FLOOD 1
#define MSG_SEND_SENT_DIRECT 2
#define PAYLOAD_TYPE_TXT_MSG 2
#define PAYLOAD_TYPE_ACK 3
#define PAYLOAD_TYPE_RESPONSE 1
#define ONE_KEY_DM_ID_SIZE 8
#include "production_constants.inc"
namespace mesh {
struct Identity { uint8_t pub_key[PUB_KEY_SIZE] = {}; };
struct GroupChannel { uint8_t secret[16] = {}; };
struct Packet {
static bool isValidPathLen(uint8_t value) { return value != 254; }
uint8_t path_len = 0;
uint8_t path[8] = {};
uint8_t payload[5 + MAX_TEXT_LEN + 1] = {};
uint8_t recipient[PUB_KEY_SIZE] = {};
bool flood = false, used = false, do_not_retransmit = false;
uint8_t hash = 0;
void calculatePacketHash(uint8_t* out) { memset(out, hash, MAX_HASH_SIZE); }
bool isRouteFlood() const { return flood; }
bool isRouteDirect() const { return !flood; }
int getSNR() const { return 1; }
int getPathHashCount() const { return 0; }
void markDoNotRetransmit() { do_not_retransmit = true; }
};
namespace Utils {
static void sha256(uint8_t* out, size_t count, const uint8_t* a, size_t alen,
const uint8_t* b, size_t blen) {
// Hashing/encryption are the transport boundary; production composes the
// actual private payload and retains its independently matched ACK here.
uint32_t value = 2166136261U;
for (size_t i = 0; i < alen; ++i) value = (value ^ a[i]) * 16777619U;
for (size_t i = 0; i < blen; ++i) value = (value ^ b[i]) * 16777619U;
for (size_t i = 0; i < count; ++i) out[i] = uint8_t(value >> ((i % 4) * 8));
}
}
}
struct ContactInfo {
mesh::Identity id;
uint8_t type = ADV_TYPE_CHAT, out_path_len = 0, flags = 0;
bool transient = false;
uint32_t lastmod = 0;
uint8_t path[8] = {};
const uint8_t* getPath() const { return path; }
const uint8_t* getSharedSecret(const mesh::Identity&) const { return id.pub_key; }
bool isRemoteCLIAllowed() const { return flags & 0x10; }
bool setPath(const uint8_t* value, uint8_t length) {
if (!mesh::Packet::isValidPathLen(length) || length > sizeof(path)) return false;
memcpy(path, value, length); out_path_len = length; return true;
}
};
struct Clock {
uint32_t now = 0, sequence = 100;
uint32_t getMillis() const { return now; }
uint32_t getCurrentTimeUnique() { return sequence++; }
uint32_t getCurrentTime() const { return sequence; }
};
struct Manager { int free = 16; int getFreeCount() const { return free; } };
struct BaseSerialInterface {
unsigned writes = 0;
bool isConnected() const { return false; }
bool isReplyRouteAvailable(BaseSerialInterface*) const { return true; }
size_t writeFrameToRoute(BaseSerialInterface*, const uint8_t*, size_t n) { ++writes; return n; }
size_t writeFrame(const uint8_t*, size_t n) { ++writes; return n; }
};
struct UI { void setMessageNotificationOverride(bool) {} };
struct Terminal {
void print(const char*) {}
template<typename... T> void printf(const char*, T...) {}
};
struct TerminalDisplay { bool shouldShowChannel(bool) const { return false; } };
struct ChannelDetails { char name[32] = {}; };
static const uint8_t EMERGENCY_CHANNEL_SECRET[16] = {1};
struct Random { int nextInt(int, int) { return 0; } };
struct LostSnapshot { uint8_t lost_reply = 0; uint32_t capabilities = 0; };
struct WebConfigServer { static constexpr uint32_t CAP_LOST_REPLY = 1UL << 18; };
class MyMesh {
public:
struct Prefs { uint8_t lost_reply = 0; } _prefs;
Clock clock, *_ms = &clock;
Manager manager, *_mgr = &manager;
BaseSerialInterface serial, *_serial = &serial;
UI ui, *_ui = &ui;
mesh::Identity self_id;
#include "production_lost_state.inc"
#include "production_entry.inc"
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE] = {};
unsigned long txt_send_timeout = 0, next_ack_expiry = 0;
bool has_next_ack_expiry = false;
uint8_t next_ack_idx = 3;
std::vector<ContactInfo> contacts;
mesh::Packet packets[16];
mesh::Packet incoming;
unsigned queued = 0, dropped = 0, sends = 0, saves = 0, cancellations = 0;
unsigned normal_marks = 0, contact_writes = 0, replacements = 0;
unsigned path_updates = 0, path_retries = 0, contact_responses = 0;
bool queue_available = true, radio_available = true, allocation_available = true;
bool save_available = true, delivered = false;
std::string last_answer;
uint8_t last_recipient[PUB_KEY_SIZE] = {};
uint8_t out_frame[MAX_FRAME_SIZE] = {};
uint8_t app_target_ver = 3;
uint16_t emergency_client_repeats[EMERGENCY_CLIENT_REPEAT_TABLE_SIZE] = {};
uint8_t emergency_client_repeat_next = 0;
mesh::Packet* emergency_client_repeat_packet = NULL;
uint16_t emergency_client_repeat_key = 0;
unsigned long emergency_client_repeat_send_at = 0;
Random random;
Terminal terminal;
TerminalDisplay _terminal_display;
uint8_t held_dm_count = 0;
unsigned offline_queue_len = 0;
struct Held { uint8_t sender_key[32] = {}, id[ONE_KEY_DM_ID_SIZE] = {}; } held_dms[1];
MyMesh() { contacts.reserve(8); }
ContactInfo& contact(uint8_t key, uint8_t suffix = 0) {
contacts.emplace_back();
contacts.back().id.pub_key[0] = key;
contacts.back().id.pub_key[31] = suffix;
return contacts.back();
}
void receive(ContactInfo& from, uint32_t timestamp, const char* question = "Am I lost?") {
onMessageRecv(from, &incoming, timestamp, question);
}
bool isTransientContact(const ContactInfo& value) const { return value.transient; }
ContactInfo* lookupPersistentContactByPubKey(const uint8_t* key, int n) {
assert(n == PUB_KEY_SIZE);
for (ContactInfo& c : contacts) if (!c.transient && memcmp(c.id.pub_key, key, n) == 0) return &c;
return NULL;
}
void markConnectionActive(const ContactInfo&) { ++normal_marks; }
void scheduleContactWrite(const ContactInfo&) { ++contact_writes; }
bool queueMessage(const ContactInfo&, uint8_t, mesh::Packet*, uint32_t,
const uint8_t*, size_t, const char*) {
if (queue_available) { ++queued; return true; } ++dropped; return false;
}
bool addToOfflineQueue(const uint8_t*, int) { ++queued; return true; }
bool savePrefs() { ++saves; return save_available; }
void makeOneKeyDMId(uint8_t* id, uint32_t timestamp, const char*) { memset(id, uint8_t(timestamp), ONE_KEY_DM_ID_SIZE); }
bool wasDeliveredOneKeyDM(const uint8_t*, const uint8_t*) { return delivered; }
void removeHeldOneKeyDM(uint8_t) { held_dm_count = 0; }
unsigned getOfflineQueueCapacity() const { return 1; }
void rememberDeliveredOneKeyDM(const uint8_t*, const uint8_t*) {}
bool hasTerminalOutput() const { return false; }
Terminal& terminalOutput() { return terminal; }
void rememberOneKeyAck(ContactInfo&) {}
uint8_t findChannelIdx(const mesh::GroupChannel&) { return 0; }
bool getChannel(int, ChannelDetails&) { return false; }
uint16_t emergencyClientRepeatKey(mesh::Packet*) { return 0; }
bool hasEmergencyClientRepeat(uint16_t) { return false; }
mesh::Packet* obtainNewPacket() { return NULL; }
Random* getRNG() { return &random; }
Clock* getRTCClock() { return &clock; }
unsigned long futureMillis(int millis) const { return uint32_t(clock.now + millis); }
uint32_t getTransmitAirtime(mesh::Packet*) { return 10; }
uint32_t calcFloodTimeoutMillisFor(uint32_t) { return 6000; }
uint32_t calcDirectTimeoutMillisFor(uint32_t, uint8_t) { return 3000; }
bool checkConnectionsAck(const uint8_t*, ContactInfo*& peer) { peer = NULL; return false; }
bool canMutateContacts() const { return true; }
void onContactPathUpdated(const ContactInfo&) { ++path_updates; }
void onContactResponse(const ContactInfo&, uint8_t*, uint8_t) { ++contact_responses; }
void handleReturnPathRetry(const ContactInfo&, const uint8_t*, uint8_t) { ++path_retries; }
bool hasActiveRetries(const uint8_t*) const { return false; }
void cancelActiveRetries(const uint8_t*) { ++cancellations; }
void replaceActiveRetries(mesh::Packet*, const uint8_t*) { ++replacements; }
void replaceActiveMessageRetries(mesh::Packet*, const uint8_t*, uint32_t) { ++replacements; }
mesh::Packet* createDatagram(uint8_t type, const mesh::Identity& to,
const uint8_t*, const uint8_t* data, int len) {
assert(type == PAYLOAD_TYPE_TXT_MSG);
if (!allocation_available) return NULL;
for (mesh::Packet& p : packets) if (!p.used) {
p.used = true; --manager.free;
memset(p.payload, 0, sizeof(p.payload)); memcpy(p.payload, data, len);
memcpy(p.recipient, to.pub_key, 32); p.hash = uint8_t(clock.sequence);
return &p;
}
return NULL;
}
void releasePacket(mesh::Packet* p) { if (p->used) { p->used = false; ++manager.free; } }
bool sendFloodScoped(const ContactInfo&, mesh::Packet* p, uint32_t) { return send(p); }
bool sendDirect(mesh::Packet* p, const uint8_t*, uint8_t, uint32_t) { return send(p); }
bool send(mesh::Packet* p) {
if (radio_available) {
++sends; last_answer = reinterpret_cast<const char*>(p->payload + 5);
memcpy(last_recipient, p->recipient, 32);
assert(p->payload[4] == TXT_TYPE_PLAIN);
}
releasePacket(p); return radio_available;
}
void clearExpectedAck(AckTableEntry&, bool = true);
void expireExpectedAcks();
bool processAck(const uint8_t*, ContactInfo*&);
void onAckRecv(mesh::Packet*, uint32_t);
bool onContactPathRecv(ContactInfo&, uint8_t*, uint8_t, uint8_t*, uint8_t,
uint8_t, uint8_t*, uint8_t);
void snapshot(LostSnapshot&);
void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*);
void onCLICommandRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*, char*);
bool onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const uint8_t*, const char*);
void onChannelMessageRecv(const mesh::GroupChannel&, mesh::Packet*, uint32_t, const char*);
bool handleDirectCommand(const char*, char*, size_t);
bool handleCommand(const char*, uint32_t, char*);
mesh::Packet* composeMsgPacket(const ContactInfo&, uint32_t, uint8_t, const char*, uint32_t&);
int sendMessage(const ContactInfo&, uint32_t, uint8_t, const char*, uint32_t&, uint32_t&,
uint8_t* = NULL, const uint8_t* = NULL, const uint8_t* = NULL,
uint32_t = 0);
int sendMessageDetached(const ContactInfo&, uint32_t, const char*, uint32_t&,
uint32_t&, uint8_t*);
};
#include "production_methods.inc"
#if MESH_ENABLE_LOST_REPLY
static void matchingAndOrdinaryInbox() {
for (const char* text : {"am i lost", "Am I lost?", "\t AM I LOST? \r\n", "\vam i lost\f"}) {
MyMesh m; ContactInfo& peer = m.contact(1); m._prefs.lost_reply = 2;
m.receive(peer, 7, text);
assert(m.sends == 1 && m.last_answer == "Yes" && m.queued == 1);
assert(m.last_recipient[0] == 1 && m.serial.writes == 0);
assert(m.has_next_ack_expiry && m.expected_ack_table[0].reply_route == NULL);
}
for (const char* text : {"", "a", "am i los", "am i lost??", "am i lost ?", "am i lost?",
"please am i lost?", "am i lost? yes", "am i lost\n?", "Yes", "No",
"!mail send 0123 Am I lost?", "set lost.reply yes", "am i lost\xc2\xa0"}) {
MyMesh m; auto& peer = m.contact(1); m._prefs.lost_reply = 2;
m.receive(peer, 7, text); assert(m.sends == 0 && m.queued == 1);
}
for (uint8_t setting : {uint8_t(0), uint8_t(255)}) {
MyMesh m; auto& peer = m.contact(1); m._prefs.lost_reply = setting;
m.receive(peer, 7); assert(m.sends == 0 && m.queued == 1);
}
MyMesh full; auto& peer = full.contact(1); full._prefs.lost_reply = 1;
LostSnapshot saved; full.snapshot(saved);
assert(saved.lost_reply == 1 && (saved.capabilities & WebConfigServer::CAP_LOST_REPLY));
full.queue_available = false; full.offline_queue_len = full.getOfflineQueueCapacity();
for (uint32_t question = 1; question <= 8; ++question) {
full.clock.now = question * 60000; full.receive(peer, question);
}
assert(full.sends == 8 && full.last_answer == "No" && full.dropped == 8);
#if MESH_ENABLE_ONE_KEY_DM
MyMesh held; auto& h = held.contact(2); held._prefs.lost_reply = 2;
held.queue_available = false; held.offline_queue_len = 1; held.held_dm_count = 1;
memcpy(held.held_dms[0].sender_key, h.id.pub_key, 32);
held.makeOneKeyDMId(held.held_dms[0].id, 7, "Am I lost?");
held.receive(h, 7); assert(held.sends == 1);
held.delivered = true; held.clock.now = 60000; held.receive(h, 7); assert(held.sends == 1);
#endif
}
static void admissionAndLoops() {
MyMesh m; auto& peer = m.contact(1); m._prefs.lost_reply = 2;
for (uint8_t type : {uint8_t(0), uint8_t(2), uint8_t(ADV_TYPE_ROOM)}) {
peer.type = type; m.receive(peer, 7); assert(m.sends == 0);
}
peer.type = ADV_TYPE_CHAT; peer.transient = true; m.receive(peer, 7); assert(m.sends == 0);
peer.transient = false;
ContactInfo unknown; unknown.id.pub_key[0] = 2; m.receive(unknown, 7); assert(m.sends == 0);
ContactInfo collision = peer; collision.id.pub_key[31] = 1;
m.receive(collision, 7); assert(m.sends == 0);
uint8_t prefix[4] = {}; m.onSignedMessageRecv(peer, &m.incoming, 7, prefix, "Am I lost?");
mesh::GroupChannel channel; m.onChannelMessageRecv(channel, &m.incoming, 7, "Am I lost?");
char reply[160] = {}; m.onCLICommandRecv(peer, &m.incoming, 7, "Am I lost?", reply);
peer.flags = 0x10; reply[0] = 0;
m.onCLICommandRecv(peer, &m.incoming, 7, "Am I lost?", reply);
assert(m.sends == 0);
m.receive(peer, 7); assert(m.sends == 1);
m.receive(peer, 8, "Yes"); m.receive(peer, 9, "No"); assert(m.sends == 1);
}
static void retriesCooldownAndFullKeys() {
MyMesh m; auto& one = m.contact(1); auto& same_prefix = m.contact(1, 1);
m._prefs.lost_reply = 2;
m.receive(one, 7); assert(m.sends == 1);
for (uint32_t time : {uint32_t(1), uint32_t(59999), uint32_t(60000), uint32_t(3600000)}) {
m.clock.now = time; m.receive(one, 7); assert(m.sends == 1);
}
m.clock.now = 59999; m.receive(one, 8); assert(m.sends == 1);
m.clock.now = 60000; m.receive(one, 8); assert(m.sends == 2);
m.clock.now = 69999; m.receive(same_prefix, 8); assert(m.sends == 2);
m.clock.now = 70000; m.receive(same_prefix, 8); assert(m.sends == 3 && m.last_recipient[31] == 1);
m.clock.now = 120000; m.receive(one, 9); assert(m.sends == 4);
using mesh::companion::LostReplyLimiter;
LostReplyLimiter limiter;
uint8_t keys[5][32] = {};
for (size_t i = 0; i < 5; ++i) keys[i][31] = uint8_t(i);
for (size_t i = 0; i < 4; ++i) {
uint32_t now = uint32_t(i * 10000);
assert(limiter.canReply(keys[i], 1, now)); limiter.rememberReply(keys[i], 1, now);
}
assert(!limiter.canReply(keys[4], 1, 40000));
assert(!limiter.canReply(keys[0], 2, 59999));
assert(limiter.canReply(keys[4], 1, 60000));
LostReplyLimiter wrap;
const uint32_t start = 0xfffffff0U;
wrap.rememberReply(keys[0], 1, start);
assert(!wrap.canReply(keys[0], 2, uint32_t(start + 59999U)));
assert(wrap.canReply(keys[0], 2, uint32_t(start + 60000U)));
assert(!wrap.canReply(keys[1], 1, uint32_t(start + 9999U)));
assert(wrap.canReply(keys[1], 1, uint32_t(start + 10000U)));
}
static void queueReserveUserOwnershipAndAck() {
MyMesh normal; auto& ordinary = normal.contact(3);
normal.clock.now = 100; normal.txt_send_timeout = 42;
uint32_t expected = 0, timeout = 0;
assert(normal.sendMessage(ordinary, 1, 0, "ordinary", expected, timeout) != MSG_SEND_FAILED);
assert(expected != 0 && normal.txt_send_timeout == normal.futureMillis(timeout));
MyMesh m; auto& peer = m.contact(1); m._prefs.lost_reply = 2;
auto& user = m.expected_ack_table[0];
user.ack = 10; user.expires_at = 123456; user.msg_sent = 20;
user.contact = &peer; user.reply_route = &m.serial;
user.retry_key[0] = 31; user.text_fingerprint[0] = 47;
const auto snapshot = user;
m.txt_send_timeout = 654321;
m.receive(peer, 7);
assert(m.sends == 1 && m.txt_send_timeout == 654321 && m.next_ack_idx == 3);
assert(memcmp(&user, &snapshot, sizeof(user)) == 0 && m.replacements == 0);
auto& automatic = m.expected_ack_table[1];
assert(automatic.ack != 0 && automatic.contact == &peer && automatic.reply_route == NULL);
uint32_t ack = automatic.ack; ContactInfo* matched = NULL;
assert(m.processAck(reinterpret_cast<const uint8_t*>(&ack), matched));
assert(matched == &peer && automatic.ack == 0 && m.cancellations == 1);
assert(memcmp(&user, &snapshot, sizeof(user)) == 0 && m.serial.writes == 0);
assert(!m.processAck(reinterpret_cast<const uint8_t*>(&ack), matched));
for (auto& entry : m.expected_ack_table) { entry.ack = 1; entry.expires_at = 123456; }
m.clock.now = 60000; m.receive(peer, 8); assert(m.sends == 1);
m.expected_ack_table[1].ack = 0;
for (int available : {0, 1, 2}) {
m.manager.free = available; m.receive(peer, 8); assert(m.sends == 1);
}
m.manager.free = 3; m.receive(peer, 8); assert(m.sends == 2);
assert(m.txt_send_timeout == 654321 && m.replacements == 0);
for (int failure = 0; failure < 3; ++failure) {
MyMesh f; auto& p = f.contact(1); f._prefs.lost_reply = 1; f.txt_send_timeout = 1234;
if (failure == 0) f.allocation_available = false;
if (failure == 1) f.radio_available = false;
if (failure == 2) p.out_path_len = 254;
f.receive(p, 7); assert(f.sends == 0 && f.txt_send_timeout == 1234);
assert(f.manager.free == 16 && f.expected_ack_table[0].ack == 0);
f.allocation_available = true; f.radio_available = true; p.out_path_len = OUT_PATH_UNKNOWN;
f.receive(p, 7); assert(f.sends == 1 && f.last_answer == "No");
}
}
static void automaticAckReceivePreservesUserExpiry() {
for (bool encoded_path : {false, true}) {
for (uint32_t user_deadline : {uint32_t(1000), uint32_t(9000)}) {
MyMesh m; auto& owner = m.contact(1); auto& user_peer = m.contact(2);
m.clock.now = 100; m._prefs.lost_reply = 2;
auto& user = m.expected_ack_table[0];
user.ack = 10; user.msg_sent = 20; user.expires_at = user_deadline;
user.contact = &user_peer; user.reply_route = &m.serial;
user.retry_key[0] = 31; user.text_fingerprint[0] = 47;
const auto snapshot = user;
m.txt_send_timeout = user_deadline;
m.receive(owner, 7);
auto& automatic = m.expected_ack_table[1];
const uint32_t automatic_ack = automatic.ack;
assert(automatic_ack != 0 && automatic_ack != user.ack);
assert(m.txt_send_timeout == user_deadline);
assert(m.next_ack_expiry == std::min<uint32_t>(user_deadline, automatic.expires_at));
mesh::Packet ack_packet; ack_packet.flood = true;
uint8_t ack_bytes[4], path[2] = {3, 4};
memcpy(ack_bytes, &automatic_ack, sizeof(ack_bytes));
if (encoded_path) {
assert(m.onContactPathRecv(owner, path, 2, path, 2,
PAYLOAD_TYPE_ACK, ack_bytes, sizeof(ack_bytes)));
assert(m.path_updates == 1 && owner.out_path_len == 2);
} else {
m.onAckRecv(&ack_packet, automatic_ack);
assert(ack_packet.do_not_retransmit && m.path_retries == 1);
}
// BaseChatMesh keeps its existing shared-timer ACK behavior. Companion
// owns each delivery's deadline separately and services it from loop().
assert(m.txt_send_timeout == 0 && automatic.ack == 0);
assert(memcmp(&user, &snapshot, sizeof(user)) == 0);
assert(m.has_next_ack_expiry && m.next_ack_expiry == user_deadline);
assert(m.cancellations == 1 && m.serial.writes == 0 && m.next_ack_idx == 3);
// A late duplicate cannot clear a newer shared timer or the user entry.
m.txt_send_timeout = 99999;
if (encoded_path) {
assert(m.onContactPathRecv(owner, path, 2, path, 2,
PAYLOAD_TYPE_ACK, ack_bytes, sizeof(ack_bytes)));
} else {
ack_packet.do_not_retransmit = false;
m.onAckRecv(&ack_packet, automatic_ack);
assert(!ack_packet.do_not_retransmit && m.path_retries == 1);
}
assert(m.txt_send_timeout == 99999 && m.cancellations == 1);
assert(memcmp(&user, &snapshot, sizeof(user)) == 0);
assert(m.has_next_ack_expiry && m.next_ack_expiry == user_deadline);
// Expiry still works through the actual Companion service without the
// compatibility timer being involved, including after the automatic ACK.
m.txt_send_timeout = 0;
m.clock.now = user_deadline - 1; m.expireExpectedAcks();
assert(memcmp(&user, &snapshot, sizeof(user)) == 0);
assert(m.has_next_ack_expiry && m.next_ack_expiry == user_deadline);
m.clock.now = user_deadline; m.expireExpectedAcks();
assert(user.ack == 0 && !m.has_next_ack_expiry && m.next_ack_expiry == 0);
assert(m.serial.writes == 0 && m.cancellations == 1);
}
}
}
static void localPolicyAndRemoteBoundary() {
MyMesh m; char reply[160] = {};
assert(m.handleCommand("get lost.reply", 0, reply) && std::string(reply) == "> off");
for (const char* value : {"no", "yes", "off"}) {
const std::string command = std::string("set lost.reply ") + value;
assert(m.handleCommand(command.c_str(), 0, reply));
assert(std::string(reply) == std::string("> lost.reply ") + value);
}
assert(m.saves == 3);
for (const char* invalid : {"YES", "on", "yes now", "", "1"}) {
const std::string command = std::string("set lost.reply ") + invalid;
assert(m.handleCommand(command.c_str(), 0, reply));
assert(std::string(reply).find("Error:") == 0 && m._prefs.lost_reply == 0 && m.saves == 3);
}
m.save_available = false;
assert(m.handleCommand("set lost.reply yes", 0, reply));
assert(m._prefs.lost_reply == 0 && std::string(reply).find("Error:") == 0);
m.save_available = true;
auto& peer = m.contact(1); peer.flags = 0x10;
for (uint32_t timestamp : {uint32_t(0), uint32_t(1), uint32_t(0xffffffffU)}) {
reply[0] = 0;
m.onCLICommandRecv(peer, &m.incoming, timestamp, "set lost.reply yes", reply);
assert(m._prefs.lost_reply == 0 && m.saves == 4);
reply[0] = 0;
m.onCLICommandRecv(peer, &m.incoming, timestamp, "get lost.reply", reply);
assert(std::string(reply) == "Unknown command");
}
assert(m.handleCommand("set lost.reply yes", 0, reply) && m._prefs.lost_reply == 2);
m.receive(peer, 7, "set lost.reply no"); assert(m._prefs.lost_reply == 2 && m.saves == 5 && m.sends == 0);
}
#else
// The actual gated MyMesh header fragment supplies neither runtime state nor
// a responder method in this build. The callbacks still admit normal text.
static void excludedFeature() {
MyMesh m; auto& peer = m.contact(1); m._prefs.lost_reply = 2;
for (uint32_t poll = 0; poll < 8; ++poll) {
m.clock.now = poll * 60000; m.receive(peer, poll);
}
assert(m.sends == 0 && m.queued == 8 && m.normal_marks == 8);
char reply[160] = {};
for (const char* command : {"get lost.reply", "set lost.reply yes", "set lost.reply no", "set lost.reply off"}) {
assert(!m.handleCommand(command, 0, reply));
assert(m.saves == 0 && m._prefs.lost_reply == 2 && reply[0] == 0);
}
LostSnapshot hidden; m.snapshot(hidden);
assert(hidden.lost_reply == 0 && !(hidden.capabilities & WebConfigServer::CAP_LOST_REPLY));
}
#endif
int main() {
#if MESH_ENABLE_LOST_REPLY
matchingAndOrdinaryInbox(); admissionAndLoops(); retriesCooldownAndFullKeys();
queueReserveUserOwnershipAndAck(); automaticAckReceivePreservesUserExpiry();
localPolicyAndRemoteBoundary();
#else
excludedFeature();
#endif
std::puts("PASS: Companion lost reply production paths");
}
+12 -1
View File
@@ -1,5 +1,6 @@
// Frozen sequential preference serializer from 54e5b0b99.
// Independent of the new storage table; keep its historical field order.
// Independent of the new storage table; historical fields stay unchanged.
// New append-only tails are added explicitly to this scalar reference.
bool DataStore::loadPrefsLegacy(const char *filename,
CompanionNodePrefs& _prefs, double& node_lat,
double& node_lon) {
@@ -13,6 +14,7 @@ bool DataStore::loadPrefsLegacy(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;
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
@@ -81,6 +83,13 @@ bool DataStore::loadPrefsLegacy(const char *filename,
332,
#else
235,
#endif
#ifdef TBEAM_1W
243,
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
333,
#else
236,
#endif
};
const uint32_t prefs_size = file.size();
@@ -229,6 +238,7 @@ bool DataStore::loadPrefsLegacy(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));
// Any bytes left over form only part of a historically appended field.
// Preserve the file and defaults rather than treating that tail as EOF.
@@ -237,6 +247,7 @@ bool DataStore::loadPrefsLegacy(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;
_prefs = loaded_prefs;
node_lat = loaded_lat;
node_lon = loaded_lon;
@@ -138,6 +138,7 @@ bool DataStore::savePrefsLegacy(const CompanionNodePrefs& _prefs, double node_la
success = success && file.write((uint8_t *)&_prefs.bluetooth_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.gps_sync_interval_hours, 2) == 2;
success = success && file.write((uint8_t *)&_prefs.usb_debug_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.lost_reply, 1) == 1;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
success = file.commit(success);
+3
View File
@@ -132,6 +132,9 @@ struct MyMesh : BaseChatMesh {
std::vector<std::pair<uint32_t, uint32_t>> saved;
MyMesh() { contacts[0].id.pub_key[0] = 77; contacts[1].id.pub_key[0] = 77; contacts[1].id.pub_key[31] = 1; }
void markConnectionActive(const ContactInfo&) { ++active; }
// This admission suite keeps the new optional responder disabled; the
// enabled private reply path executes separately in its production suite.
void maybeReplyToLostQuestion(const ContactInfo&, uint32_t, const char*) {}
bool scheduleContactWrite(const ContactInfo& from) { saved.emplace_back(from.sync_since, from.lastmod); return true; }
int getOfflineQueueCapacity() const;
Frame& offlineQueueFrameAt(int);
+37 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Execute Room/Sensor ACL handlers and encrypted packet assembly with bounds."""
from pathlib import Path
import os
import re
import shutil
import subprocess
@@ -15,6 +16,18 @@ ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/client_acl_infrastructure/test_client_acl_infrastructure.cpp"
def room_mail_crypto_arguments(role):
if role == "sensor":
return []
candidates = [Path(os.environ["MESHCORE_CRYPTO_DIR"])] if os.environ.get("MESHCORE_CRYPTO_DIR") else []
candidates += sorted((ROOT / ".pio/libdeps").glob("*/Crypto"))
crypto = next((path for path in candidates if (path / "SHA256.cpp").is_file()), None)
if crypto is None:
raise RuntimeError("Install rweather/Crypto or set MESHCORE_CRYPTO_DIR (no hash mock fallback)")
return ["-DHOST_BUILD", "-I" + str(crypto), str(crypto / "SHA256.cpp"),
str(crypto / "Hash.cpp"), str(crypto / "Crypto.cpp")]
def production_methods(role, transform=None):
sensor = role == "sensor"
path = ROOT / ("examples/simple_sensor/SensorMesh.cpp" if sensor
@@ -28,7 +41,8 @@ def production_methods(role, transform=None):
else " if (payload[0] == REQ_TYPE_GET_STATUS)")
prefix = handler[:begin]
# The new board protocol has its own admission/storage regression suite.
# Keep this legacy ACL/telemetry fixture on its original request families.
# Keep the complete mailbox prefix: its role/send gates also protect this
# authenticated request dispatcher, and use the actual protocol and store.
if not sensor and "if (payload[0] == mesh::ROOM_BOARD_REQUEST_SUBTYPE)" in prefix:
board = extract_braced(prefix, "if (payload[0] == mesh::ROOM_BOARD_REQUEST_SUBTYPE)")
prefix = prefix.replace(board, "", 1)
@@ -74,7 +88,8 @@ class ClientAclInfrastructureTest(unittest.TestCase):
(work / "production.inc").write_text(production_methods(role, transform), encoding="ascii")
binary = work / "acl.exe"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-I" + str(work), "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"), str(FIXTURE), "-o", str(binary)]
"-I" + str(work), "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"),
str(FIXTURE), *room_mail_crypto_arguments(role), "-o", str(binary)]
if role == "sensor": command.insert(1, "-DTEST_SENSOR=1")
if sys.platform.startswith("linux"):
command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
@@ -87,6 +102,8 @@ class ClientAclInfrastructureTest(unittest.TestCase):
run = self.execute(role)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
self.assertIn("Room/Sensor ACL route, input, and permission checks passed", run.stdout)
if role == "room":
self.assertIn("Room mailbox guest, reader, writer and recipient-ban boundaries passed", run.stdout)
decoded = 0
for line in run.stdout.splitlines():
if not line.startswith("LEGACY:"): continue
@@ -116,6 +133,24 @@ class ClientAclInfrastructureTest(unittest.TestCase):
return source.replace(" if (payload_len < 3) return 0;", "", 1)
self.assertNotEqual(self.execute("room", old).returncode, 0)
def test_room_mailbox_inactive_guest_cannot_bypass_session_gate(self):
def old(source):
handler = extract_braced(source, "int MyMesh::handleRequest(")
mailbox = extract_braced(handler, "if (payload[0] == mesh::ROOM_MAIL_REQUEST_SUBTYPE)")
guard = "role == PERM_ACL_GUEST && sender->last_activity != 0"
self.assertIn(guard, mailbox)
return source.replace(mailbox, mailbox.replace(guard, "role == PERM_ACL_GUEST"), 1)
self.assertNotEqual(self.execute("room", old).returncode, 0)
def test_room_mailbox_reader_cannot_bypass_send_permission(self):
def old(source):
handler = extract_braced(source, "int MyMesh::handleRequest(")
mailbox = extract_braced(handler, "if (payload[0] == mesh::ROOM_MAIL_REQUEST_SUBTYPE)")
guard = "role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN"
self.assertIn(guard, mailbox)
return source.replace(mailbox, mailbox.replace(guard, "true"), 1)
self.assertNotEqual(self.execute("room", old).returncode, 0)
def test_old_sensor_truncated_packet_overread_is_rejected(self):
def old(source):
self.assertIn(" if (len < 5) return;", source)
+4 -4
View File
@@ -32,13 +32,13 @@ int main(){
assert(mesh.savePrefs());
const auto on_disk=mesh.store.fs.files["/new_prefs"];
#if defined(TBEAM_1W)
assert(on_disk.size()==242);
assert(on_disk.size()==243);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(on_disk.size()==332);
assert(on_disk.size()==333);
#else
assert(on_disk.size()==235);
assert(on_disk.size()==236);
#endif
const size_t bluetooth_offset=on_disk.size()-4;
const size_t bluetooth_offset=on_disk.size()-5;
assert(on_disk[bluetooth_offset]==1&&on_disk.back()==0);
// Both choices survive a fresh instance reading the actual on-device image.
for(bool enabled : {false,true,false}){
+1 -1
View File
@@ -180,7 +180,7 @@ class CompanionIdentityCliTests(unittest.TestCase):
terminal = method(source, 'void MyMesh::handleTerminalCommand(')
self.assertIn('handleLocalControlCommand(command, local_reply, sizeof(local_reply))', terminal)
self.assertIn('terminalOutput().printf(" -> %s\\r\\n", local_reply);', terminal)
self.assertIn('terminalOutput().print(" get public.key\\r\\n");', terminal)
self.assertIn('" get public.key\\r\\n"', terminal)
shared = method(source, 'bool MyMesh::handleCommand(')
self.assertIn('if (handleLocalControlCommand(command, reply, reply_capacity)) return true;', shared)
self.assertIn('return handleCommand(command, 0, reply);',
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Exercise actual Companion private receive, reply send and local CLI paths."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/companion_lost_reply.cpp"
SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
class CompanionLostReplyTests(unittest.TestCase):
def test_actual_private_callbacks_reply_transport_and_local_config(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++ compiler is required")
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
base = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text()
methods = "#if MESH_ENABLE_LOST_REPLY\n" + extract_braced(
source, "void MyMesh::maybeReplyToLostQuestion(") + "\n#endif\n"
methods += "\n".join(extract_braced(source, signature) for signature in (
"void MyMesh::onMessageRecv(",
"void MyMesh::onCLICommandRecv(", "bool MyMesh::onSignedMessageRecv(",
"void MyMesh::onChannelMessageRecv(", "void MyMesh::clearExpectedAck(",
"void MyMesh::expireExpectedAcks(", "bool MyMesh::processAck(",
))
methods += "\n" + "\n".join(extract_braced(base, signature).replace(
"BaseChatMesh::", "MyMesh::") for signature in (
"mesh::Packet* BaseChatMesh::composeMsgPacket(",
"int BaseChatMesh::sendMessage(",
"int BaseChatMesh::sendMessageDetached(",
"void BaseChatMesh::onAckRecv(",
"bool BaseChatMesh::onContactPathRecv(",
))
direct = extract_braced(source, "bool MyMesh::handleDirectCommand(")
direct_prefix = direct[:direct.index("#ifdef MESH_BUTTON_AUDIO_HIL")]
methods += "\n" + direct_prefix + " return false;\n}\n"
guard = re.search(r"if \(sender_timestamp == 0 && handleDirectCommand\([^\n]+", source)
self.assertIsNotNone(guard)
methods += "\nbool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) {\n"
methods += " const size_t reply_capacity = 160;\n " + guard.group()
methods += '\n return false;\n}\n'
snapshot = extract_braced(source, "void MyMesh::getNodeSnapshot(")
saved = re.search(r"#if MESH_ENABLE_LOST_REPLY\n s\.lost_reply[\s\S]*?#endif", snapshot)
capability = re.search(r"#if MESH_ENABLE_LOST_REPLY\n s\.capabilities \|=[\s\S]*?#endif", snapshot)
self.assertIsNotNone(saved)
self.assertIsNotNone(capability)
methods += "\nvoid MyMesh::snapshot(LostSnapshot& s) {\n" + saved.group() + "\n" + capability.group() + "\n}\n"
include = re.search(r"#if MESH_ENABLE_LOST_REPLY\n#include <helpers/CompanionLostReply.h>\n#endif", header)
state = re.search(r"#if MESH_ENABLE_LOST_REPLY\n mesh::companion::LostReplyLimiter[\s\S]*?#endif", header)
self.assertIsNotNone(include)
self.assertIsNotNone(state)
constants = "\n".join(re.findall(
r"^#define (?:EXPECTED_ACK_[A-Z_]+|PUSH_CODE_SEND_CONFIRMED|RESP_CODE_CHANNEL_MSG_RECV(?:_V3)?|PUSH_CODE_MSG_WAITING|EMERGENCY_CLIENT_REPEAT_[A-Z_]+)\s+.+$",
source, re.MULTILINE))
with tempfile.TemporaryDirectory(prefix="meshcore-lost-reply-") as directory:
work = Path(directory)
(work / "production_constants.inc").write_text(constants, encoding="ascii")
(work / "production_entry.inc").write_text(
extract_braced(header, "struct AckTableEntry") + ";\n", encoding="ascii")
(work / "production_methods.inc").write_text(methods, encoding="ascii")
(work / "production_lost_include.inc").write_text(include.group(), encoding="ascii")
(work / "production_lost_state.inc").write_text(state.group(), encoding="ascii")
variants = ((0, 0, 0, []), (0, 0, 1, []), (1, 0, 0, []), (1, 1, 0, []),
(0, 0, 0, ["-DSTM32_PLATFORM=1", "-DMESH_ENABLE_LOST_REPLY=1"]),
(0, 0, 0, ["-DSTM32_PLATFORM=1"]),
(0, 0, 0, ["-DMESH_ENABLE_LOST_REPLY=0"]))
for number, (one_key, shared, terminal, feature_flags) in enumerate(variants):
with self.subTest(one_key=one_key, shared=shared, terminal=terminal, flags=feature_flags):
binary = work / f"lost-{number}.exe"
compiled = subprocess.run([
compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-unused-variable", *SANITIZERS,
f"-DMESH_ENABLE_ONE_KEY_DM={one_key}",
f"-DONE_KEY_DM_SHARED_OFFLINE_QUEUE={shared}",
*(["-DENABLE_USB_INTERFACE=1"] if terminal else []),
*feature_flags,
"-DCOMPANION_FEATURE_NOTIFICATIONS=0",
f"-I{work}", f"-I{ROOT / 'src'}",
f"-I{ROOT / 'examples/companion_radio'}", str(FIXTURE), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("PASS: Companion lost reply production paths", checked.stdout)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -2,7 +2,9 @@
"""Run real Companion inbox downloads against bounded UART/TCP transports."""
from pathlib import Path
import configparser
import re
import shlex
import subprocess
import tempfile
import unittest
@@ -24,7 +26,9 @@ HARNESS = r'''
#include <vector>
#define DISPLAY_CLASS TestDisplay
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#ifndef OFFLINE_QUEUE_SIZE
#define OFFLINE_QUEUE_SIZE 256
#endif
@CONSTANTS@
@STREAM@
std::deque<WiFiClient> WiFiServer::incoming;
@@ -284,6 +288,33 @@ int main() {
class CompanionOfflineDeliveryTest(unittest.TestCase):
def test_tag_ble_capacity_preserves_contacts_channels_and_full_queue(self):
config = configparser.ConfigParser(interpolation=None)
config.read(ROOT / "variants/rak_wismesh_tag/platformio.ini")
flags = shlex.split(config["env:RAK_WisMesh_Tag_companion_radio_ble"]["build_flags"])
self.assertEqual(flags.count("OFFLINE_QUEUE_SIZE=240"), 1)
self.assertIn("MAX_CONTACTS=350", flags)
self.assertIn("MAX_GROUP_CHANNELS=40", flags)
# Run the production Full overlay without resolving or building any
# PlatformIO environment. It must explicitly replace a BLE override
# if a future Full recipe inherits the BLE base instead of USB.
result = subprocess.run(["bash", "-c", r'''
set -euo pipefail
source build.sh
pio() { echo "unexpected PlatformIO invocation" >&2; return 127; }
PIO_ENV_PLATFORM_BY_NAME[RAK_WisMesh_Tag_companion_radio_full]=NRF52_PLATFORM
PLATFORMIO_BUILD_FLAGS='-DMAX_CONTACTS=350 -DMAX_GROUP_CHANNELS=40'
pio_env_option_contains() { return 1; }
apply_companion_radio_full_profile RAK_WisMesh_Tag_companion_radio_full RAK_WisMesh_Tag_companion_radio_usb
printf '%s\n' "$PLATFORMIO_BUILD_FLAGS" "$PLATFORMIO_BUILD_UNFLAGS"
'''], cwd=ROOT, text=True, capture_output=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("-DMAX_CONTACTS=350", result.stdout)
self.assertIn("-DMAX_GROUP_CHANNELS=40", result.stdout)
self.assertIn("-DOFFLINE_QUEUE_SIZE=256", result.stdout)
self.assertIn("-DOTA_SHARED_COMPANION_QUEUE=1", result.stdout)
self.assertIn("-D OFFLINE_QUEUE_SIZE=240", result.stdout)
def test_real_download_paths_in_every_queue_layout(self):
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
@@ -312,6 +343,7 @@ class CompanionOfflineDeliveryTest(unittest.TestCase):
configurations = {
"circular": [],
"held-dm": ["-DONE_KEY_DM_SHARED_OFFLINE_QUEUE=1"],
"tag-ble": ["-DONE_KEY_DM_SHARED_OFFLINE_QUEUE=1", "-DOFFLINE_QUEUE_SIZE=240"],
"mota": ["-DOTA_SHARED_COMPANION_QUEUE=1"],
"psram": ["-DESP32_PLATFORM=1", "-DBOARD_HAS_PSRAM=1"],
}
@@ -58,6 +58,7 @@ int main(int argc,char** argv){
original.one_key_dm_enabled=1;
original.gps_sync_interval_hours=336;
original.usb_debug_enabled=1;
original.lost_reply=2;
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42;
#endif
@@ -96,6 +97,7 @@ int main(int argc,char** argv){
assert(loaded.one_key_dm_enabled==1);
assert(loaded.gps_sync_interval_hours==336);
assert(loaded.usb_debug_enabled==1);
assert(loaded.lost_reply==2);
#ifdef TBEAM_1W
assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42);
#endif
@@ -162,6 +164,7 @@ int main(int argc,char** argv){
assert(reboot.loadPrefs(loaded,lat,lon));
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
assert(lat==47.1&&lon==-122.2);
assert(loaded.lost_reply==2);
#endif
} else if(scenario==3){
// Copy the real adapter-bearing preferences from dirty storage. This
@@ -200,7 +203,7 @@ int main(int argc,char** argv){
} else if(scenario==7){
// Pre-consent image: all earlier settings survive with default-off consent.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(disk.size()-5);
legacy.fs.files["/new_prefs"].resize(disk.size()-6);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_advert_enabled==0);
@@ -209,7 +212,7 @@ int main(int argc,char** argv){
// The previous complete image loads with the board policy even when the
// receiving prefs object previously held an explicit configured interval.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(disk.size()-3);
legacy.fs.files["/new_prefs"].resize(disk.size()-4);
CompanionNodePrefs loaded;loaded.gps_sync_interval_hours=24;loaded.usb_debug_enabled=1;
double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
@@ -217,12 +220,12 @@ int main(int argc,char** argv){
assert(loaded.usb_debug_enabled==0);
// Half of the two-byte append is never accepted as a complete image.
DataStore torn;torn.fs.files["/new_prefs"]=disk;
torn.fs.files["/new_prefs"].resize(disk.size()-2);
torn.fs.files["/new_prefs"].resize(disk.size()-3);
assert(!torn.loadPrefsInt("/new_prefs",loaded,lat,lon));
// The complete published GPS image is accepted without borrowing either
// GPS byte as the new debug preference.
DataStore gps_image;gps_image.fs.files["/new_prefs"]=disk;
gps_image.fs.files["/new_prefs"].resize(disk.size()-1);
gps_image.fs.files["/new_prefs"].resize(disk.size()-2);
loaded.usb_debug_enabled=1;
assert(gps_image.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0);
+113 -7
View File
@@ -40,6 +40,7 @@ TAIL_FIELDS = (
('flood_retry_group_max_path', 1), ('flood_retry_advert_enabled', 1),
('one_key_dm_enabled', 1), ('bluetooth_enabled', 1),
('gps_sync_interval_hours', 2), ('usb_debug_enabled', 1),
('lost_reply', 1),
)
OPTIONAL_FIELDS = (
('defined(TBEAM_1W)', (('fan_mode', 5), ('fan_lo', 1), ('fan_hi', 1))),
@@ -48,6 +49,7 @@ OPTIONAL_FIELDS = (
)
TESTS = r'''
#include <algorithm>
#include <vector>
static void seed(CompanionNodePrefs& prefs, uint32_t& random) {
@@ -70,11 +72,11 @@ static std::vector<uint8_t> golden(const CompanionNodePrefs& prefs,
};
@GOLDEN@
#if defined(TBEAM_1W)
assert(bytes.size() == 242);
assert(bytes.size() == 243);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(bytes.size() == 332);
assert(bytes.size() == 333);
#else
assert(bytes.size() == 235);
assert(bytes.size() == 236);
#endif
return bytes;
}
@@ -102,19 +104,122 @@ int main() {
}
CompanionNodePrefs original;
assert(original.lost_reply == 0);
strcpy(original.node_name, "durable image");
original.freq = 915.25f; original.ble_pin = 123456;
original.bluetooth_enabled = 1; original.usb_debug_enabled = 1;
original.gps_sync_interval_hours = 336;
original.lost_reply = 2;
DataStore initial;
assert(initial.savePrefs(original, 47.125, -122.25));
const auto before = golden(original, 47.125, -122.25);
assert(initial.fs.files.at("/new_prefs") == before);
// Every complete historical wire boundary stays readable, and an old
// image always disables the new opt-in response even if live RAM was on.
const std::vector<size_t> legacy_sizes = {
84, 85, 90, 91, 92, 93, 140, 141, 142, 143, 144, 155,
156, 157, 158, 159, 191, 193, 200, 207, 214, 215, 226,
#if defined(TBEAM_1W)
// The generic 231-byte compatibility boundary also ends at TBeam's
// complete fan-mode string; preserve that existing accepted image.
231, 233, 237, 238, 239, 241, 242
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
323, 327, 328, 329, 331, 332
#else
230, 231, 232, 234, 235
#endif
};
for (size_t length : legacy_sizes) {
DataStore reboot;
auto historical = before; historical.resize(length);
reboot.fs.files["/new_prefs"] = historical;
CompanionNodePrefs live; live.lost_reply = 2;
live.getCustom()->setByKey("local_tag", "unchanged");
double lat = 1, lon = 2;
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon));
assert(live.lost_reply == 0 && live.ble_pin == original.ble_pin);
assert(live.freq == original.freq && !strcmp(live.node_name, original.node_name));
assert(lat == 47.125 && lon == -122.25);
assert(reboot.fs.files.at("/new_prefs") == historical);
char tag[16] = {};
assert(live.getCustom()->getByKey("local_tag", tag, sizeof(tag)));
assert(!strcmp(tag, "unchanged"));
++comparisons;
}
// The accepted-size table is an exact allowlist. Check every length through
// a full extra byte range, including sizes that could alias legacy values
// if the underlying file size were accidentally narrowed with the table.
std::vector<size_t> accepted_sizes = legacy_sizes;
accepted_sizes.push_back(before.size());
for (size_t length = 0; length <= before.size() + 256; ++length) {
DataStore reboot;
auto candidate = before;
candidate.resize(length);
reboot.fs.files["/new_prefs"] = candidate;
CompanionNodePrefs live;
live.freq = 920.5f;
live.lost_reply = 1;
live.getCustom()->setByKey("length_tag", "unchanged");
double lat = 1, lon = 2;
const bool expected = std::find(accepted_sizes.begin(), accepted_sizes.end(),
length) != accepted_sizes.end();
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon) == expected);
if (expected) {
assert(live.freq == original.freq);
assert(live.lost_reply == (length == before.size() ? 2 : 0));
assert(lat == 47.125 && lon == -122.25);
} else {
assert(live.freq == 920.5f && live.lost_reply == 1);
assert(lat == 1 && lon == 2);
}
char tag[16] = {};
assert(live.getCustom()->getByKey("length_tag", tag, sizeof(tag)));
assert(!strcmp(tag, "unchanged"));
assert(reboot.fs.files.at("/new_prefs") == candidate);
++comparisons;
}
// All 256 byte encodings are preserved by the append-only serializer.
// Only off/no/yes are accepted on reboot; corrupt values normalize off
// without erasing radio settings or changing the committed image.
for (unsigned value = 0; value < 256; ++value) {
CompanionNodePrefs saved;
assert(saved.copyPersistedValuesFrom(original));
saved.lost_reply = static_cast<uint8_t>(value);
DataStore writer;
assert(writer.savePrefs(saved, 47.125, -122.25));
const auto disk = writer.fs.files.at("/new_prefs");
assert(disk.back() == value);
assert(std::equal(disk.begin(), disk.end() - 1, before.begin()));
DataStore reboot; reboot.fs.files["/new_prefs"] = disk;
CompanionNodePrefs live; live.lost_reply = 2;
double lat = 1, lon = 2;
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon));
assert(live.lost_reply == (value <= 2 ? value : 0));
assert(live.ble_pin == original.ble_pin && live.freq == original.freq);
assert(live.gps_sync_interval_hours == 336 && live.usb_debug_enabled == 1);
assert(lat == 47.125 && lon == -122.25);
assert(reboot.fs.files.at("/new_prefs") == disk);
++comparisons;
}
{
DataStore torn; torn.fs.files["/new_prefs"] = before;
torn.fs.fail_read_after = static_cast<int>(before.size() - 1);
CompanionNodePrefs live; live.lost_reply = 1; live.freq = 920.5f;
strcpy(live.node_name, "live unchanged");
double lat = 1, lon = 2;
assert(!torn.loadPrefsInt("/new_prefs", live, lat, lon));
assert(live.lost_reply == 1 && live.freq == 920.5f);
assert(!strcmp(live.node_name, "live unchanged") && lat == 1 && lon == 2);
assert(torn.fs.files.at("/new_prefs") == before);
++comparisons;
}
CompanionNodePrefs changed;
assert(changed.copyPersistedValuesFrom(original));
strcpy(changed.node_name, "replacement image");
changed.freq = 910.5f; changed.usb_debug_enabled = 0;
changed.gps_sync_interval_hours = 24;
changed.lost_reply = 1;
const auto after = golden(changed, 42.25, -121.125);
// Every possible short write and readback, including every tail byte,
@@ -166,6 +271,7 @@ int main() {
assert(!strcmp(loaded.node_name, changed.node_name));
assert(loaded.freq == changed.freq && loaded.ble_pin == changed.ble_pin);
assert(loaded.gps_sync_interval_hours == 24 && loaded.usb_debug_enabled == 0);
assert(loaded.lost_reply == 1);
assert(lat == 42.25 && lon == -121.125);
#if defined(STM32_PLATFORM)
@@ -208,10 +314,10 @@ class CompanionPrefsImageTests(unittest.TestCase):
method(source, signature) for signature in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(')
) + '\n' + save
for suffix, capacity in (('TooSmall', 234), ('TooLarge', 236)):
for suffix, capacity in (('TooSmall', 235), ('TooLarge', 237)):
variant = save.replace('DataStore::savePrefs(', 'DataStore::savePrefs' + suffix + '(', 1)
self.assertIn('uint8_t image[235];', variant)
variant = variant.replace('uint8_t image[235];', f'uint8_t image[{capacity}];', 1)
self.assertIn('uint8_t image[236];', variant)
variant = variant.replace('uint8_t image[236];', f'uint8_t image[{capacity}];', 1)
methods += '\n#if defined(STM32_PLATFORM)\n' + variant + '\n#endif\n'
code = HARNESS.split('int main(')[0].replace('@METHODS@', methods)
code = code.replace(' bool savePrefs(const CompanionNodePrefs&, double, double);',
@@ -219,7 +325,7 @@ class CompanionPrefsImageTests(unittest.TestCase):
' bool savePrefsTooSmall(const CompanionNodePrefs&, double, double);\n'
' bool savePrefsTooLarge(const CompanionNodePrefs&, double, double);')
self.assertEqual(sum(size for _, size in WIRE_FIELDS), 226)
self.assertEqual(sum(size for _, size in TAIL_FIELDS), 9)
self.assertEqual(sum(size for _, size in TAIL_FIELDS), 10)
generated = {}
for seed in (False, True):
rows = [field_code(WIRE_FIELDS, seed)]
+19 -11
View File
@@ -19,6 +19,7 @@ LEGACY_TAIL = r'''
success = success && file.write((uint8_t *)&_prefs.bluetooth_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.gps_sync_interval_hours, 2) == 2;
success = success && file.write((uint8_t *)&_prefs.usb_debug_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.lost_reply, 1) == 1;
'''
TESTS = r'''
@@ -30,6 +31,7 @@ int main() {
original.flood_retry_group_max_path=2; original.flood_retry_advert_enabled=1;
original.one_key_dm_enabled=1; original.bluetooth_enabled=0;
original.gps_sync_interval_hours=336; original.usb_debug_enabled=1;
original.lost_reply=2;
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on"); original.fan_lo=32; original.fan_hi=45;
#endif
@@ -48,24 +50,27 @@ int main() {
prefs.bluetooth_enabled=uint8_t(value+89);
prefs.gps_sync_interval_hours=uint16_t(value*257);
prefs.usb_debug_enabled=uint8_t(value+103);
prefs.lost_reply=uint8_t(value);
DataStore compact, legacy;
assert(compact.savePrefs(prefs,47.1,-122.2));
assert(legacy.savePrefsLegacy(prefs,47.1,-122.2));
const auto& image=compact.fs.files["/new_prefs"];
assert(image==legacy.fs.files["/new_prefs"]);
#if defined(TBEAM_1W)
assert(image.size()==242);
assert(image.size()==243);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(image.size()==332);
assert(image.size()==333);
#else
assert(image.size()==235);
assert(image.size()==236);
#endif
const uint8_t expected[]={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};
assert(!memcmp(image.data()+image.size()-9,expected,6));
assert(!memcmp(image.data()+image.size()-3,&prefs.gps_sync_interval_hours,2));
assert(image.back()==prefs.usb_debug_enabled);
prefs.one_key_dm_enabled,prefs.bluetooth_enabled,0,0,prefs.usb_debug_enabled,
prefs.lost_reply};
assert(!memcmp(image.data()+image.size()-10,expected,6));
assert(!memcmp(image.data()+image.size()-4,&prefs.gps_sync_interval_hours,2));
assert(image[image.size()-2]==prefs.usb_debug_enabled);
assert(image.back()==prefs.lost_reply);
}
DataStore initial; assert(initial.savePrefs(original,47.1,-122.2));
const auto disk=initial.fs.files["/new_prefs"];
@@ -73,6 +78,7 @@ int main() {
strcpy(changed.node_name,"new complete image"); changed.freq=910.5f;
changed.flood_retry_attempts=7; changed.bluetooth_enabled=1;
changed.gps_sync_interval_hours=24; changed.usb_debug_enabled=0;
changed.lost_reply=1;
auto expectOld=[&](DataStore& store) {
assert(store.fs.files["/new_prefs"]==disk);
assert(!store.fs.exists("/new_prefs.tmp"));
@@ -83,13 +89,14 @@ int main() {
assert(loaded.freq==original.freq && loaded.ble_pin==original.ble_pin);
assert(loaded.flood_retry_attempts==4 && loaded.bluetooth_enabled==0);
assert(loaded.gps_sync_interval_hours==336 && loaded.usb_debug_enabled==1);
assert(loaded.lost_reply==2);
assert(lat==47.1 && lon==-122.2);
};
// A short return at each boundary of the single final write must abort the
// transaction, including either byte of the two-byte GPS field.
for (unsigned boundary=0; boundary<9; ++boundary) {
for (unsigned boundary=0; boundary<10; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_write_after=int(disk.size()-9+boundary);
store.fs.fail_write_after=int(disk.size()-10+boundary);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
store.fs.fail_write_after=-1;
assert(store.savePrefs(changed,42.3,-121.2));
@@ -97,9 +104,9 @@ int main() {
}
// Fully written bytes still cannot replace the live image until readback
// verifies every final-tail boundary. LittleFS's retries also must fail.
for (unsigned boundary=0; boundary<9; ++boundary) {
for (unsigned boundary=0; boundary<10; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_read_after=int(disk.size()-9+boundary);
store.fs.fail_read_after=int(disk.size()-10+boundary);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
}
{
@@ -123,6 +130,7 @@ int main() {
assert(!strcmp(loaded.node_name,changed.node_name));
assert(loaded.flood_retry_attempts==7 && loaded.bluetooth_enabled==1);
assert(loaded.gps_sync_interval_hours==24 && loaded.usb_debug_enabled==0);
assert(loaded.lost_reply==1);
assert(lat==42.3 && lon==-121.2);
}
'''
+4 -4
View File
@@ -36,9 +36,9 @@ static size_t adapterOffset(const CompanionNodePrefs& prefs) {
// The value helper does not copy the adapters or their inter-field padding.
// Use the complete object's byte representation for bounded observation.
const uintptr_t first = reinterpret_cast<uintptr_t>(&prefs);
const uintptr_t last = reinterpret_cast<uintptr_t>(&prefs.usb_debug_enabled);
const uintptr_t last = reinterpret_cast<uintptr_t>(&prefs.lost_reply);
assert(last >= first && last - first < sizeof(prefs));
return static_cast<size_t>(last - first) + sizeof(prefs.usb_debug_enabled);
return static_cast<size_t>(last - first) + sizeof(prefs.lost_reply);
}
static std::vector<uint8_t> adapterBytes(const CompanionNodePrefs& prefs) {
@@ -132,7 +132,7 @@ int main() {
4, 32, 4, 8, 8, 4, 1, 1, 1, 1, 4, 1, 1, 1, 1,
4, 1, 1, 1, 1, 4};
assert(expected && READ_CALLS == mandatory);
unsigned optional_fields = 45;
unsigned optional_fields = 46;
#ifdef TBEAM_1W
optional_fields += 3;
#endif
@@ -185,7 +185,7 @@ class CompanionPrefsValuesTests(unittest.TestCase):
r'^\s*(?:float|char|u?int(?:8|16|32)_t)\s+([A-Za-z_][A-Za-z_0-9]*)',
prefix, re.M)
self.assertEqual(declarations[0], 'airtime_factor')
self.assertEqual(declarations[-1], 'usb_debug_enabled')
self.assertEqual(declarations[-1], 'lost_reply')
optional = {
'fan_mode': 'TBEAM_1W', 'fan_lo': 'TBEAM_1W', 'fan_hi': 'TBEAM_1W',
'wifi_ssid': 'defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)',
@@ -122,7 +122,12 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase):
"bool copyPersistedValuesFrom(const CompanionNodePrefs& source)",
)
self.assertIn("reinterpret_cast<uintptr_t>(&airtime_factor)", copy)
self.assertIn("reinterpret_cast<uintptr_t>(&usb_debug_enabled)", copy)
# The appended lost-reply byte is the last persisted value. Both
# snapshots must stop there before either runtime adapter begins.
self.assertIn("reinterpret_cast<uintptr_t>(&lost_reply)", copy)
self.assertIn("reinterpret_cast<uintptr_t>(&values.lost_reply)", copy)
self.assertIn("+ sizeof(lost_reply)", copy)
self.assertIn("end - begin == size - sizeof(values.lost_reply)", copy)
self.assertIn("reinterpret_cast<uintptr_t>(&values.radio)", copy)
self.assertIn("reinterpret_cast<uintptr_t>(&values.custom)", copy)
self.assertIn("size <= radio_begin - begin", copy)
+24 -14
View File
@@ -55,15 +55,17 @@ int main(){
assert(node.savePrefs());
const auto original=node.store.fs.files["/new_prefs"];
#if defined(TBEAM_1W)
assert(original.size()==242);
assert(original.size()==243);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(original.size()==332);
assert(original.size()==333);
#else
assert(original.size()==235);
assert(original.size()==236);
#endif
assert(original.back()==0&&original[original.size()-4]==0);
assert(original[original.size()-3]==1&&original[original.size()-2]==1);
assert(original[original.size()-5]==1&&original[158]==1);
const size_t debug_offset=original.size()-2;
assert(original[debug_offset]==0&&original[debug_offset-3]==0);
assert(original[debug_offset-2]==1&&original[debug_offset-1]==1);
assert(original[debug_offset-4]==1&&original[158]==1);
assert(original.back()==0);
char reply[160]={};
for(const char* malformed : {"set usb.debug","set usb.debug ",
"set usb.debug 1","set usb.debug ON","set usb.debug on ",
@@ -81,8 +83,9 @@ int main(){
assert(!strncmp(reply,"OK - USB debug on (saved)",24));
assert(node._prefs.usb_debug_enabled==1&&mesh::debug_enabled);
const auto enabled_disk=node.store.fs.files["/new_prefs"];
assert(enabled_disk.back()==1);
for(size_t i=0;i+1<original.size();++i)assert(enabled_disk[i]==original[i]);
assert(enabled_disk[debug_offset]==1);
for(size_t i=0;i<original.size();++i)
if(i!=debug_offset)assert(enabled_disk[i]==original[i]);
assert(node.command("get usb.debug",reply,sizeof(reply)));
#if MESH_DEBUG
assert(!strcmp(reply,"usb.debug on"));
@@ -121,7 +124,7 @@ int main(){
}
// Existing complete tails retain their established offsets and always start
// quiet, even if the object was already using verbose runtime diagnostics.
for(unsigned size : {84u,156u,215u,226u,unsigned(original.size()-1)}){
for(unsigned size : {84u,156u,215u,226u,unsigned(debug_offset)}){
DataStore legacy;legacy.fs.files["/new_prefs"]=enabled_disk;
legacy.fs.files["/new_prefs"].resize(size);
CompanionNodePrefs loaded;loaded.usb_debug_enabled=1;
@@ -132,7 +135,7 @@ int main(){
// Normalize the new boolean only after the entire persisted image validates.
for(uint8_t byte : {2,255}){
DataStore corrupt;corrupt.fs.files["/new_prefs"]=enabled_disk;
corrupt.fs.files["/new_prefs"].back()=byte;
corrupt.fs.files["/new_prefs"][debug_offset]=byte;
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(corrupt.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.usb_debug_enabled==0);
@@ -423,6 +426,11 @@ int main(){
const auto& text=node.port.output;
assert(text.find("Commands:\r\n stats-core / stats-radio / stats-radio-diag / stats-packets\r\n")==0);
assert(text.find(" get public.key\r\n")!=std::string::npos);
#if MESH_ENABLE_LOST_REPLY
assert(text.find(" get lost.reply\r\n set lost.reply <off|no|yes>\r\n")!=std::string::npos);
#else
assert(text.find("lost.reply")==std::string::npos);
#endif
assert(text.find(" get usb.debug\r\n set usb.debug <on|off>\r\n")!=std::string::npos);
#if MESH_USB_CONSOLE_COOPERATIVE
assert(text.find(" get usb.watchdog\r\n get usb.watchdog.last\r\n set usb.watchdog <off|on|auto> (saved; default auto)\r\n")!=std::string::npos);
@@ -445,10 +453,12 @@ int main(){
work = Path(directory)
self.prepare(work)
for native in (0, 1):
with self.subTest(native=native):
self.compile_and_run(work, harness,
[flag + '=1' for flag in features] + ['WIFI_SSID="test"',
'MESH_USB_CONSOLE_COOPERATIVE=' + str(native)])
for lost in (0, 1):
with self.subTest(native=native, lost=lost):
self.compile_and_run(work, harness,
[flag + '=1' for flag in features] + ['WIFI_SSID="test"',
'MESH_USB_CONSOLE_COOPERATIVE=' + str(native),
'MESH_ENABLE_LOST_REPLY=' + str(lost)])
if __name__ == '__main__':
+19
View File
@@ -20,6 +20,7 @@ PREAMBLE = r'''
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
#include <helpers/BatteryChargeCLI.h>
#include <helpers/CompanionLostReply.h>
#include "companion_fleet_dispatch.h"
#define ESP32 1
#define WIFI_SSID "test"
@@ -72,6 +73,7 @@ struct Store {
bool formatFileSystem(){++erased;return success;}
};
struct Prefs {
uint8_t lost_reply=0;
float tx_delay_factor=0.5f, direct_tx_delay_factor=0.2f;
uint8_t interference_threshold=0, agc_reset_interval=0;
int8_t tz_offset=0;
@@ -164,6 +166,22 @@ SCENARIOS = r'''
int main() {
MyMesh node;
char reply[160] = {};
assert(node.handleCommand("set lost.reply yes",0,reply));
assert(node._prefs.lost_reply==2 && !strcmp(reply,"> lost.reply yes"));
assert(node.handleCommand("get lost.reply",0,reply) && !strcmp(reply,"> yes"));
const int lost_saves=node.saves;
for(uint32_t stamp:{0u,100u}) {
reply[0]=0;
node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"set lost.reply no",reply);
assert(node._prefs.lost_reply==2 && node.saves==lost_saves);
reply[0]=0;
node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"A7|get lost.reply",reply);
assert(!strstr(reply,"> yes") && node.saves==lost_saves);
}
node.save_ok=false;
assert(node.handleCommand("set lost.reply no",0,reply));
assert(node._prefs.lost_reply==2 && strstr(reply,"could not save"));
node.save_ok=true;
for (uint32_t timestamp : {0u, 100u}) {
node.onCLICommandRecv(ContactInfo{}, nullptr, timestamp,
"fleet send 1 all set radio2 off", reply);
@@ -373,6 +391,7 @@ int main() {
"c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"),
"-I"+str(ROOT / "test/fixtures"),
"-DENABLE_PRIVATE_KEY_EXPORT="+str(export),
"-DMESH_ENABLE_LOST_REPLY=1",
*(["-DWITH_WEBCONFIG=1"] if webconfig else []),
*SANITIZER_FLAGS, "-o", str(binary),
], input=PREAMBLE+implementation+SCENARIOS, text=True, capture_output=True)
+3 -2
View File
@@ -8,7 +8,7 @@ import tempfile
import unittest
from test_replay_reset_integration import extract_braced
from test_client_acl_infrastructure import production_methods
from test_client_acl_infrastructure import production_methods, room_mail_crypto_arguments
ROOT = Path(__file__).resolve().parents[1]
@@ -142,7 +142,8 @@ int main() {
# Actual Mesh::createMultiAck has a pre-existing signed-size comparison.
# Keep other fixture warnings fatal without changing production code.
args = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-Wno-sign-compare",
"-I" + str(work), "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"), str(work / "test.cpp"), "-o", str(binary)]
"-I" + str(work), "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"),
str(work / "test.cpp"), *room_mail_crypto_arguments(role), "-o", str(binary)]
if sensor: args.insert(1, "-DTEST_SENSOR=1")
if sys.platform.startswith("linux"):
args[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Exercise the shipped Lost-status control and actual WebConfig JSON handlers."""
import json
import html
from pathlib import Path
import re
import unittest
from unittest import mock
from test_replay_reset_integration import extract_braced
import test_webconfig_recovery_auth as auth_fixture
import test_webconfig_ui_runtime as browser_fixture
ROOT = Path(__file__).resolve().parents[1]
class LostReplyWebConfigTest(unittest.TestCase):
compile_run = auth_fixture.WebConfigRecoveryAuthTest.compile_run
setup_values = browser_fixture.WebConfigUiRuntimeTest.setup_values
def run_page(self, prelude, virtual_time=1500):
# Snap Chromium has a private /tmp. Put the HTML in the shared
# workspace, as the Room browser fixture does, so it can load it.
temporary_directory = browser_fixture.tempfile.TemporaryDirectory
cache = ROOT / ".pio"
cache.mkdir(exist_ok=True)
def workspace_temporary_directory(*args, **kwargs):
kwargs.setdefault("dir", cache)
return temporary_directory(*args, **kwargs)
with mock.patch.object(browser_fixture.tempfile, "TemporaryDirectory",
workspace_temporary_directory):
return browser_fixture.WebConfigUiRuntimeTest.run_page(self, prelude, virtual_time)
def fixture(self):
server = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
header = (ROOT / "src/helpers/esp32/WebConfigServer.h").read_text()
capability = extract_braced(header, "enum Capability : uint32_t") + ";"
snapshot = extract_braced(header, "struct NodeSnapshot") + ";"
batch_entry = extract_braced(header, "struct BatchEntry") + ";"
allowed = extract_braced(server, "static bool isAllowedSetKey(")
secret = extract_braced(server, "static bool isSecretKey(")
post = extract_braced(server, "void WebConfigServer::handleConfigPost(")
get = extract_braced(server, "void WebConfigServer::handleConfigGet(")
# Execute the actual snapshot-to-radio JSON branch. WiFi/MQTT fields
# after this boundary are unrelated and require peripheral fixtures.
get = get[:get.index(' JsonObject wifi =')] + r'''
}
String out;
serializeJson(doc, out);
req->send(200, "application/json", out);
}
'''
return r'''
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <ArduinoJson.h>
#include "helpers/WebConfigKeys.h"
#include "helpers/WebConfigBatch.h"
using String = std::string;
struct WCLock { explicit WCLock(int) {} };
uint32_t millis() { return 1; }
namespace mesh {
struct Port { template<typename... Args> void printf(const char*, Args...) {} };
Port& usbDebugPort() { static Port port; return port; }
namespace wifi { bool parseEspNowChannel(const char*, uint8_t&) { return false; } }
}
static const char SECRET_SENTINEL[] = "********";
@ALLOWED@
@SECRET@
struct AsyncWebServerRequest {
String input, output;
void* _tempObject = nullptr;
int status = 0;
explicit AsyncWebServerRequest(String body = "") : input(body) { _tempObject = input.data(); }
size_t contentLength() const { return input.size(); }
void send(int code, const char* = nullptr, const String& body = "") { status = code; output = body; }
};
struct WebConfigServer {
@CAPABILITY@
@SNAPSHOT@
@BATCH_ENTRY@
struct Callbacks {
NodeSnapshot node = {};
void getNodeSnapshot(NodeSnapshot& result) { result = node; }
} callbacks;
Callbacks* _cb = &callbacks;
enum { MODE_OFF, MODE_SETUP, MODE_LAN, BATCH_CONFIG, BATCH_CLI };
static constexpr size_t MAX_BODY = 4096;
static constexpr int MAX_BATCH = WebConfigBatch::kMaxBatch;
int _mode = MODE_LAN, _mux = 0, _batch_kind = BATCH_CONFIG;
bool authorized = true, _initial_setup = false, _batch_reboot = false;
bool _batch_reboot_armed = false, _batch_all_ok = true;
bool _standalone_wifi_dirty = false, _setup_wifi_handoff_pending = false;
uint32_t _setup_wifi_handoff_deadline = 0, _diag_until = 0;
char _setup_wifi_handoff_ip[32] = {}, _batch_reqid[17] = {};
int _batch_count = 0, _batch_next = 0;
void* _mqtt_prefs = nullptr;
BatchEntry _batch[MAX_BATCH] = {};
WebConfigBatch::State _batch_state = WebConfigBatch::State::Idle;
static constexpr auto BATCH_PENDING = WebConfigBatch::State::Pending;
static WebConfigBatch::State toSpecState(WebConfigBatch::State state) { return state; }
bool checkAuth(AsyncWebServerRequest*) { return authorized; }
void handleConfigGet(AsyncWebServerRequest*);
void handleConfigPost(AsyncWebServerRequest*);
};
@GET@
@POST@
String writeBody(const char* value) {
return String("{\"reqid\":\"0123456789abcdef\",\"set\":{\"lost.reply\":") + value + "}}";
}
int main() {
const char* modes[] = {"off", "no", "yes"};
for (int value : {0, 1, 2, 255}) {
WebConfigServer server;
server.callbacks.node.lost_reply = uint8_t(value);
server.callbacks.node.capabilities = WebConfigServer::CAP_LOST_REPLY;
AsyncWebServerRequest get;
server.handleConfigGet(&get);
JsonDocument snapshot;
assert(get.status == 200 && deserializeJson(snapshot, get.output) == DeserializationError::Ok);
assert(strcmp(snapshot["radio"]["lost_reply"].as<const char*>(), modes[value <= 2 ? value : 0]) == 0);
assert(snapshot["radio"]["capabilities"].as<uint32_t>() == (1UL << 18));
}
for (const char* mode : modes) {
WebConfigServer server;
server.callbacks.node.capabilities = WebConfigServer::CAP_LOST_REPLY;
AsyncWebServerRequest save(writeBody((String("\"") + mode + "\"").c_str()));
server.handleConfigPost(&save);
assert(save.status == 202 && server._batch_count == 1 && !server._batch_reboot);
assert(strcmp(server._batch[0].key, "lost.reply") == 0);
assert(String(server._batch[0].cmd) == String("set lost.reply ") + mode);
// A correlated retry retains the established batch, including when the
// retry carries an invalid value. Classification precedes new validation.
AsyncWebServerRequest retry(writeBody("\"invalid\""));
server.handleConfigPost(&retry);
assert(retry.status == 202 && String(server._batch[0].cmd) == String("set lost.reply ") + mode);
}
for (const char* invalid : {"null", "true", "2", "\"on\"", "\"YES\"", "\"yes\\n\""}) {
WebConfigServer server;
server.callbacks.node.capabilities = WebConfigServer::CAP_LOST_REPLY;
AsyncWebServerRequest save(writeBody(invalid));
server.handleConfigPost(&save);
assert(save.status == 400 && save.output.find("expected off, no or yes") != String::npos);
assert(server._batch_count == 0 && server._batch_state == WebConfigBatch::State::Idle);
}
WebConfigServer infrastructure;
AsyncWebServerRequest forged(writeBody("\"yes\""));
infrastructure.handleConfigPost(&forged);
assert(forged.status == 400 && forged.output.find("setting unavailable") != String::npos);
assert(infrastructure._batch_count == 0);
infrastructure.authorized = false;
AsyncWebServerRequest unauthorized(writeBody("\"yes\""));
infrastructure.handleConfigPost(&unauthorized);
assert(unauthorized.status == 401 && infrastructure._batch_count == 0);
assert(wcIsAllowedSetKey("lost.reply") && !wcIsAllowedSetKey("lost.reply.extra"));
assert(!wcSetKeyRequiresReboot("lost.reply"));
}
'''.replace("@CAPABILITY@", capability).replace("@SNAPSHOT@", snapshot).replace(
"@BATCH_ENTRY@", batch_entry).replace("@ALLOWED@", allowed).replace(
"@SECRET@", secret).replace("@GET@", get).replace("@POST@", post)
def test_actual_json_strings_and_capability_gated_settings_batch(self):
# The shared compiler helper needs the production helper include path.
self.compile_run(self.fixture().replace('"helpers/', '"' + (ROOT / "src/helpers").as_posix() + '/'))
def test_shipped_control_defaults_off_and_saves_no_yes_off(self):
status, config = self.setup_values()
status.update(mode="lan", capabilities=1 << 18)
config["radio"]["lost_reply"] = "off"
prelude = r'''<script>
(function(){
var status=%s,config=%s,submitted=null,saves=[];
function reply(value,code){return Promise.resolve({ok:true,status:code||200,json:function(){return Promise.resolve(value)}})}
window.fetch=function(path,options){
if(path==="/api/status")return reply(status);
if(path==="/api/config"&&(!options||options.method!=="POST"))return reply(config);
if(path==="/api/config"){
submitted=JSON.parse(options.body);saves.push(submitted);
config.radio.lost_reply=submitted.set["lost.reply"];
return reply({state:"pending",reqid:submitted.reqid,count:1},202);
}
if(path.indexOf("/api/config/result?")===0)return reply({state:"done",reqid:submitted.reqid,results:[{key:"lost.reply",reply:"OK"}]});
return reply({});
};
function mark(key,value){document.body.setAttribute("data-test-"+key,String(value))}
function waitFor(check,next,attempt){
if(check()){next();return}
if((attempt||0)>100){mark("error","save did not complete");return}
setTimeout(function(){waitFor(check,next,(attempt||0)+1)},20);
}
window.addEventListener("load",function(){setTimeout(function(){
var field=document.querySelector('#v-app [data-k="lost.reply"]');
mark("visible",!field.closest('[data-cap]').classList.contains("hide"));
mark("default",field.value);mark("choices",Array.prototype.map.call(field.options,function(o){return o.value}).join(","));
mark("enum",cliEnum("lost.reply").join(","));
var values=["no","yes","off"],index=0;
function saveNext(){
var value=values[index];field.value=value;
field.dispatchEvent(new Event("input",{bubbles:true}));field.dispatchEvent(new Event("change",{bubbles:true}));
appSave();
waitFor(function(){return saves.length===index+1&&st.orig["lost.reply"]===value&&!("lost.reply" in st.dirty)},function(){
mark("saved-"+value,field.value);index++;
if(index<values.length){saveNext();return}
mark("saves",JSON.stringify(saves.map(function(s){return s.set})));mark("reboot",saves.some(function(s){return !!s.reboot}));
mark("result",field.closest('.f').textContent);mark("done",true);
});
}
saveNext();
},300)});
})();
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=3500)
for key, value in (("visible", "true"), ("default", "off"), ("choices", "off,no,yes"),
("enum", "off,no,yes"), ("saved-no", "no"), ("saved-yes", "yes"),
("saved-off", "off"), ("reboot", "false"), ("done", "true")):
self.assertIn('data-test-%s="%s"' % (key, value), dom)
saved = re.search(r'data-test-saves="([^"]*)"', dom)
self.assertIsNotNone(saved)
self.assertEqual(json.loads(html.unescape(saved.group(1))),
[{"lost.reply": "no"}, {"lost.reply": "yes"}, {"lost.reply": "off"}])
self.assertIn("Lost-status auto reply", dom)
self.assertIn("Am I lost?", dom)
def test_other_roles_hide_setting_and_do_not_offer_cli_key(self):
for role in ("Repeater", "Room Server"):
with self.subTest(role=role):
status, config = self.setup_values()
status.update(mode="lan", role=role, capabilities=1)
config["radio"]["lost_reply"] = "off"
prelude = r'''<script>
window.fetch=function(path){var value=path==="/api/status"?%s:%s;return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})};
window.addEventListener("load",function(){setTimeout(function(){
document.body.setAttribute("data-test-hidden",document.getElementById("lost-reply-setting").classList.contains("hide"));
document.body.setAttribute("data-test-no-cli",!cliTable().some(function(row){return row[0]==="get lost.reply"||row[0]==="set lost.reply "}));
},300)});
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-hidden="true"', dom)
self.assertIn('data-test-no-cli="true"', dom)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -96,7 +96,8 @@ build_flags =
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D BLE_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
; Retain 350 contacts/40 channels and the runtime heap reserve for BLE.
-D OFFLINE_QUEUE_SIZE=240
-D PIN_GPS_EN=34
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
+1
View File
@@ -76,6 +76,7 @@ build_flags = ${Tiny_Relay.build_flags}
-Oz
-flto
-fno-inline-small-functions
-fno-tree-dominator-opts ; preserve Companion features and the 32 KiB filesystem
-fipa-pta
-fmerge-all-constants
-fno-ipa-cp-clone
+1
View File
@@ -94,6 +94,7 @@ build_flags = ${lora_e5.build_flags}
-Oz
-flto
-fno-inline-small-functions
-fno-tree-dominator-opts ; preserve Companion features and the 32 KiB filesystem
-fipa-pta
-fmerge-all-constants
-fno-ipa-cp-clone
+5
View File
@@ -365,6 +365,9 @@ body.tab-cli{padding-bottom:0}
<div class="f" data-cap="131072"><label>Bluetooth stealth</label>
<select data-k="bluetooth.stealth"><option value="off">Off</option><option value="on">On</option></select>
<div class="hint">Independent of the address setting. Advertise normally until first pairing, then accept only that bonded peer. An address rotation reopens pairing. Changes require a reboot.</div></div>
<div class="f" data-cap="262144" id="lost-reply-setting"><label>Lost-status auto reply</label>
<select data-k="lost.reply"><option value="off">Off (default)</option><option value="no">Reply No</option><option value="yes">Reply Yes</option></select>
<div class="hint">Reply Yes or No to "Am I lost?" from saved private chat contacts when this device is available. Replies are rate limited. Your choice is saved on this device.</div></div>
<div class="row" data-cap="1">
<div class="f"><label>Latitude</label><input type="number" data-k="lat" step="any"></div>
<div class="f"><label>Longitude</label><input type="number" data-k="lon" step="any"></div>
@@ -810,6 +813,7 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
case"name":return r.name;case"bluetooth.name":return r.bluetooth_name||"";
case"bluetooth.mac":return r.bluetooth_mac||"";
case"bluetooth.stealth":return r.bluetooth_stealth?"on":"off";
case"lost.reply":return r.lost_reply==="yes"||r.lost_reply==="no"?r.lost_reply:"off";
case"lat":return String(r.lat);case"lon":return String(r.lon);
case"tx":return String(r.tx);case"af":return String(r.af);
case"rxdelay":return String(r.rxdelay);case"txdelay":return String(r.txdelay);
@@ -2084,6 +2088,7 @@ var CLI_COMPANION_VERBS=[
["reboot","Restart the node"]
];
var CLI_COMPANION_KEYS=[
["lost.reply","Lost-status auto reply to saved private chat contacts",0,"off|no|yes"],
["display.inbox","Companion inbox behavior",0,"history|pending|unread"],
["display.mode","Display on battery",0,"off|on|button|pairing|button-pairing|automatic"],
["display.timeout","Battery display timeout in seconds (1-3600)",0],