Add Companion lost-status replies and repair room mailbox test adapters
Save an opt-in off/no/yes answer to private Am I lost questions from known chat contacts. Keep reply traffic and ACK state bounded, preserve pending user delivery, and expose the setting through local CLI and ESP32 WebConfig. Retain compact STM32 application/filesystem boundaries, qualify the Tag BLE queue allowance, and exercise saved-image compatibility and browser controls. Fix stale room mailbox protocol dependencies in the ACL/route test fixtures. Validation: ten Companion builds, sanitized production-path regressions, preference fault/compatibility tests, browser tests, and 86 capacity/build recipe checks passed. No live radio was flashed for this feature.
This commit is contained in:
1 parent
2c1c2c8e99
commit
8031e60b55
46 files changed
+3663
-2020
No files matched your search
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}){
|
||||
|
||||
@@ -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);',
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user