From 36ec9b8bd38bbf4cc8e7fb4dde3e46336385a1e3 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 23:16:27 -0700 Subject: [PATCH] Seed a quiet local emergency-channel notice on Companion joins --- docs/radio_profiles.md | 17 ++ examples/companion_radio/MyMesh.cpp | 56 ++++- examples/companion_radio/MyMesh.h | 6 +- test/fixtures/companion_lost_reply.cpp | 4 +- test/fixtures/companion_meshtext.cpp | 29 ++- test/test_companion_channel_alias.py | 282 ++++++++++++++++++++++++- test/test_companion_meshtext.py | 5 +- 7 files changed, 384 insertions(+), 15 deletions(-) diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index c440fee26..6447194dd 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -416,6 +416,23 @@ Repeater/room/sensor hashtag behavior is unchanged; those nodes can join the canonical MeshCore channel (`#emergency`). Older Companion firmware does not recognize these aliases. +### Local emergency-channel notice + +When a Companion app adds `#emergency` (or `#r2-emergency`), the firmware seeds +its local chat with this notice, using `#EMERGENCY` as the sender: + +```text +#EMERGENCY: This is an emergency channel. Do not use for testing or small talk. +``` + +This is a local onboarding message, not an RF transmission or an emergency +alert. It does not change the node's name. It is added only after the channel +is successfully saved, not on every connection, reboot, key edit, or alias +rename. Deleting and adding the channel again adds a fresh notice. If the +receive queue is full, the notice waits for free space rather than replacing +an actual message. As with other pending received messages, it is RAM-backed +until downloaded by the app; a reboot before download can discard it. + ### Meshtext channel chat `MESH_ENABLE_MESHTEXT_CHAT` enables the text-only Meshtext backend in the diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5c6bd4284..dc8f53814 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2148,9 +2148,51 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe } } +static bool isEmergencyNoticeChannelName(const char* name) { + const auto alias = parseCompanionChannelAlias(name); + return alias.hashtag && alias.kind != CompanionChannelKind::Invalid + && alias.kind != CompanionChannelKind::Meshtext + && strcmp(alias.tail, "emergency") == 0; +} + +void MyMesh::seedEmergencyChannelNotice(uint8_t index, const ChannelDetails& previous) { + if (index >= MAX_GROUP_CHANNELS) return; + const uint8_t bit = 1U << (index % 8); + uint8_t& pending = pending_emergency_notices[index / 8]; + ChannelDetails channel; + if (!getChannel(index, channel) || !isEmergencyNoticeChannelName(channel.name)) { + pending &= ~bit; + return; + } + if (!isEmergencyNoticeChannelName(previous.name)) pending |= bit; + if (!(pending & bit)) return; // Edits and reapplying a saved channel are not joins. + + int capacity = getOfflineQueueCapacity(); +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + capacity -= held_dm_count; +#endif + // A welcome notice must never evict an actual message. Retry after sync + // frees queue space, including space temporarily reserved for held DMs. + if (offline_queue_len >= capacity) return; + if (queueChannelText(index, channel.channel.secret, 0, 0xFF, + getRTCClock()->getCurrentTime(), + "#EMERGENCY: This is an emergency channel. Do not use for testing or small talk.", + true, false)) pending &= ~bit; +} + +void MyMesh::servicePendingChannelNotices() { + for (unsigned index = 0; index < MAX_GROUP_CHANNELS; ++index) { + if (!(pending_emergency_notices[index / 8] & (1U << (index % 8)))) continue; + ChannelDetails current{}; + getChannel(index, current); + seedEmergencyChannelNotice(index, current); + return; // At most one local notice per loop, never a radio send. + } +} + bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], float snr, uint8_t path_len, uint32_t timestamp, const char* text, - bool is_emergency_channel) { + bool is_emergency_channel, bool notify) { if (!text || channel_idx >= MAX_GROUP_CHANNELS) return false; int i = 0; if (app_target_ver >= 3) { @@ -2180,23 +2222,26 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo if (!queued) return false; #if COMPANION_FEATURE_NOTIFICATIONS - const bool notification_managed = _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis()); + const bool notification_managed = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis()); if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen()); #else const bool notification_managed = false; #endif + // Older screen UIs can vibrate inside newMsg(), independently of notify(). + // Treat local notices as managed/screen-suppressed while retaining history. + if (!notify && _ui) _ui->setMessageNotificationOverride(true, true); if (_serial && _serial->isConnected()) { uint8_t frame[1]; frame[0] = PUSH_CODE_MSG_WAITING; _serial->writeFrame(frame, 1); } else { #ifdef DISPLAY_CLASS - if (_ui && !notification_managed) _ui->notify(UIEventType::channelMessage); + if (notify && _ui && !notification_managed) _ui->notify(UIEventType::channelMessage); #endif } #if COMPANION_FEATURE_TEXT_TERMINAL - if (hasTerminalOutput() + if (notify && hasTerminalOutput() && _terminal_display.shouldShowChannel(is_emergency_channel)) { ChannelDetails details; const char* channel_name = getChannel(channel_idx, details) ? details.name : "Unknown"; @@ -6915,6 +6960,8 @@ void MyMesh::handleCmdFrame(size_t len) { refreshMeshtextProfile(); #endif writeOKFrame(); + // Local history seed only after the durable save and its acknowledgement. + seedEmergencyChannelNotice(channel_idx, previous); } else { // AtomicFileWriter preserves the last complete channels file. Keep the // live table consistent with it when a full or failing filesystem cannot @@ -11381,6 +11428,7 @@ void MyMesh::loop() { #endif } servicePendingRadioParamApply(); + if (!mesh::morseTimingActive(_radio)) servicePendingChannelNotices(); #if MESH_ENABLE_ONE_KEY_DM if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs(); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index d28e4838f..005ac4458 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -442,7 +442,9 @@ protected: #endif bool queueChannelText(uint8_t index, const uint8_t secret[32], float snr, uint8_t path_len, uint32_t timestamp, const char* text, - bool is_emergency_channel); + bool is_emergency_channel, bool notify = true); + void seedEmergencyChannelNotice(uint8_t index, const ChannelDetails& previous); + void servicePendingChannelNotices(); int findChannelIdxForPacket(const mesh::GroupChannel& channel, const mesh::Packet* packet); bool allowFloodRetry(const mesh::Packet* packet) const override; uint8_t getFloodRetryMaxPathLength(const mesh::Packet* packet) const override; @@ -916,6 +918,8 @@ private: void initializeOfflineQueue(); int offline_queue_len; int offline_queue_head; + // Local onboarding only: RAM-backed like the receive queue, not RF traffic. + uint8_t pending_emergency_notices[(MAX_GROUP_CHANNELS + 7) / 8] = {}; #if defined(OTA_SHARED_COMPANION_QUEUE) static_assert(OFFLINE_QUEUE_SIZE == 256, "Shared mOTA queue requires 256 normal slots"); mesh::BorrowableFrameBuffer offline_queue; diff --git a/test/fixtures/companion_lost_reply.cpp b/test/fixtures/companion_lost_reply.cpp index 5487a9c29..f1a05b1cf 100644 --- a/test/fixtures/companion_lost_reply.cpp +++ b/test/fixtures/companion_lost_reply.cpp @@ -89,7 +89,7 @@ struct BaseSerialInterface { 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 UI { void setMessageNotificationOverride(bool, bool = false) {} }; struct Terminal { void print(const char*) {} template void printf(const char*, T...) {} @@ -231,7 +231,7 @@ public: 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 queueChannelText(uint8_t, const uint8_t[32], float, uint8_t, uint32_t, const char*, bool); + bool queueChannelText(uint8_t, const uint8_t[32], float, uint8_t, uint32_t, const char*, bool, bool = true); 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&); diff --git a/test/fixtures/companion_meshtext.cpp b/test/fixtures/companion_meshtext.cpp index c3fec8ecf..d1f1f3ba8 100644 --- a/test/fixtures/companion_meshtext.cpp +++ b/test/fixtures/companion_meshtext.cpp @@ -69,7 +69,14 @@ struct Serial { bool isConnected() { return connected; } void writeFrame(const uint8_t* bytes,int size) { assert(size>=0); frames.emplace_back(bytes,bytes+size); } }; -struct UI { void setMessageNotificationOverride(bool,bool=false) {} }; +struct UI { + bool managed=false,screen=false; + unsigned quiet_overrides=0; + void setMessageNotificationOverride(bool value,bool suppress_screen=false) { + managed=value;screen=value && suppress_screen; + if(managed && screen) ++quiet_overrides; + } +}; struct Notifications { uint8_t key[32]={}; unsigned calls=0; bool message(const void*,const uint8_t* secret,bool,uint32_t) { ++calls; memcpy(key,secret,32); return false; } @@ -116,6 +123,8 @@ public: unsigned foreign_admissions=0,meshcore_sends=0; std::vector> foreign,offline; size_t offline_capacity=4; + int offline_queue_len=0; + uint8_t pending_emergency_notices[(MAX_GROUP_CHANNELS+7)/8]={}; Serial serial; Serial* _serial=&serial; UI ui; UI* _ui=&ui; Notifications _notifications; uint8_t out_frame[MAX_FRAME_SIZE]={}; uint8_t app_target_ver=3; int error=-1; @@ -145,7 +154,10 @@ public: bool onForeignRadioFrame(const uint8_t*,size_t,uint8_t,uint32_t,float,float); void onForeignRadioSendComplete(bool); bool handleMeshtextCommand(const char*,char*,size_t); - bool queueChannelText(uint8_t,const uint8_t[32],float,uint8_t,uint32_t,const char*,bool); + bool queueChannelText(uint8_t,const uint8_t[32],float,uint8_t,uint32_t,const char*,bool,bool=true); + void seedEmergencyChannelNotice(uint8_t,const ChannelDetails&); + void servicePendingChannelNotices(); + int getOfflineQueueCapacity() const { return offline_capacity; } bool hasForeignRadioFrame() const { return foreign_pending; } bool queueForeignRadioFrame(const uint8_t* bytes,size_t size,uint8_t profile,uint32_t generation) { if(!admit_foreign || foreign_pending) return false; @@ -173,7 +185,7 @@ public: Random* getRNG() { return &random; } bool addToOfflineQueue(const uint8_t* bytes,int size) { assert(size>=0 && size<=MAX_FRAME_SIZE); if(offline.size()>=offline_capacity) return false; - offline.emplace_back(bytes,bytes+size); return true; + offline.emplace_back(bytes,bytes+size); offline_queue_len=offline.size(); return true; } bool saveChannels() { return save_result; } bool savePrefs() { return save_result; } @@ -396,6 +408,17 @@ static void receive() { assert(node.offline.back()[1]==128 && node.offline.back()[5]==255); assert(node.queueChannelText(0,ordinary_key,std::numeric_limits::quiet_NaN(),0,124,"nan",false)); assert(node.offline.back()[1]==0); + node.offline_capacity=8; + const unsigned alerts=node._notifications.calls,quiet_overrides=node.ui.quiet_overrides; + const size_t queued=node.offline.size(),pushes=node.serial.frames.size(); + assert(node.queueChannelText(0,ordinary_key,0,255,125,"#EMERGENCY: local notice",true,false)); + assert(node.offline.size()==queued+1 && node.serial.frames.size()==pushes+1); + assert(node.serial.frames.back()==std::vector{PUSH_CODE_MSG_WAITING}); + assert(node._notifications.calls==alerts && node.ui.quiet_overrides==quiet_overrides+1); + assert(!node.ui.managed && !node.ui.screen); // Quiet suppression is scoped to this message. + assert(node.queueChannelText(0,ordinary_key,0,0,126,"ordinary message",false)); + assert(node._notifications.calls==alerts+1 && node.ui.quiet_overrides==quiet_overrides+1); + assert(!node.ui.managed && !node.ui.screen); uint8_t empty_secret[32]={}; assert(!node.queueChannelText(MAX_GROUP_CHANNELS,empty_secret,0,0,0,"x",false)); assert(!node.queueChannelText(0,empty_secret,0,0,0,nullptr,false)); puts("PASS receive"); diff --git a/test/test_companion_channel_alias.py b/test/test_companion_channel_alias.py index c6b16c977..d16b9ef9c 100644 --- a/test/test_companion_channel_alias.py +++ b/test/test_companion_channel_alias.py @@ -16,6 +16,7 @@ ROOT = Path(__file__).resolve().parents[1] HASH_NAMES = ( "#emergency", "#r2-emergency", "#mt-emergency", "#custom", "#r2-custom", "#mt-custom", + "#EMERGENCY", "#Emergency", "emergency", "Emergency", "#emergency-test", ) HARNESS = r''' @@ -378,6 +379,9 @@ struct StrHelper { }; struct Serial { uint8_t frame[128] = {}; int length = 0; + bool connected = true; + unsigned pushes = 0; + bool isConnected() const { return connected; } void writeFrame(const uint8_t* data, int size) { assert(size >= 0 && size <= static_cast(sizeof(frame))); memcpy(frame, data, size); length = size; @@ -391,9 +395,211 @@ COMMAND_MEMBERS = r''' bool saveChannels() { ++save_calls; return save_result; } void writeErrFrame(uint8_t code) { error = code; } void writeOKFrame() { ok = true; } + void seedEmergencyChannelNotice(uint8_t, const ChannelDetails&) {} void command(uint8_t*, int); ''' +NOTICE_STUBS = r''' +#include +#include +#include +#include +#include +// Expected failures in the negative controls must not open Windows CRT dialogs. +#undef assert +#define assert(condition) do { if(!(condition)) { \ + std::fprintf(stderr,"notice fixture assertion: %s\n",#condition);std::exit(91); \ +} } while(false) +using std::isfinite; +#define COMPANION_FEATURE_NOTIFICATIONS 1 +#define COMPANION_FEATURE_TEXT_TERMINAL 1 +#define DISPLAY_CLASS NoticeDisplay +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define OFFLINE_QUEUE_SIZE 4 +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE @HELD@ +#define MESH_ENABLE_MESHTEXT_CHAT 1 +static bool checking_ack_order=false,ack_written=false; +struct NoticeCommandScope { + NoticeCommandScope(){checking_ack_order=true;ack_written=false;} + ~NoticeCommandScope(){checking_ack_order=false;} +}; +uint32_t millis(){return 123;} +enum class UIEventType { channelMessage }; +struct NoticeUI { + unsigned notifications=0,messages=0; + bool override_active=false,override_screen=false,last_quiet=false; + void syncMessageQueue(int,int=-1){assert(!checking_ack_order || ack_written);} + void setMessageNotificationOverride(bool active,bool screen=false){override_active=active;override_screen=screen;} + void notify(UIEventType){++notifications;} + void newMsg(uint8_t,const char*,const char*,int,int,const char*,int){ + ++messages;last_quiet=override_active && override_screen; + } +}; +struct NoticeNotifications { + unsigned calls=0; + bool message(void*,const uint8_t*,bool,uint32_t){++calls;return false;} + bool overridesScreen()const{return false;} +}; +struct NoticeClock {uint32_t epoch=1792012345;uint32_t getCurrentTime()const{return epoch;}}; +struct NoticeTerminal {unsigned echoes=0;void printf(const char*,...){++echoes;}}; +struct NoticeTerminalDisplay {bool shouldShowChannel(bool)const{return true;}}; +''' + +NOTICE_MEMBERS = r''' + NoticeUI ui; NoticeUI* _ui=&ui; + NoticeNotifications _notifications; + NoticeClock clock; NoticeClock* getRTCClock(){return &clock;} + NoticeTerminal terminal;NoticeTerminalDisplay _terminal_display; + bool hasTerminalOutput()const{return true;}NoticeTerminal& terminalOutput(){return terminal;} + uint8_t app_target_ver=3,out_frame[MAX_FRAME_SIZE]={}; + uint8_t pending_emergency_notices[(MAX_GROUP_CHANNELS+7)/8]={}; + unsigned held_dm_count=0; + struct Frame {uint8_t len=0,buf[MAX_FRAME_SIZE]={};bool isChannelMsg()const;}; + Frame offline_queue[OFFLINE_QUEUE_SIZE]; + int offline_queue_len=0,offline_queue_head=0; + int getOfflineQueueCapacity()const; + Frame& offlineQueueFrameAt(int); + bool addToOfflineQueue(const uint8_t*,int); + void popOfflineQueue(); + int peekOfflineQueue(uint8_t*); + int getFromOfflineQueue(uint8_t*); + void seedEmergencyChannelNotice(uint8_t,const ChannelDetails&); + void servicePendingChannelNotices(); + bool queueChannelText(uint8_t,const uint8_t[32],float,uint8_t,uint32_t,const char*,bool,bool=true); + bool _mt_delivery_config_dirty=false,_mt_profile_dirty=false,valid_transport=true; + struct Seen {void clear(){}} _mt_seen; + bool isMeshtextChannel(const ChannelDetails& value)const{ + return parseCompanionChannelAlias(value.name).kind==CompanionChannelKind::Meshtext; + } + bool validateMeshtextChannelChange(int,const ChannelDetails&){return valid_transport;} + void refreshMeshtextProfile(){} +''' + +NOTICE_CASES = r''' +static const char notice[]="#EMERGENCY: This is an emergency channel. Do not use for testing or small talk."; +static bool pending(const MyMesh& node,unsigned index){return node.pending_emergency_notices[index/8]&(1U<<(index%8));} +static unsigned effectiveCapacity(const MyMesh& node){ + return node.getOfflineQueueCapacity() +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + -node.held_dm_count +#endif + ; +} +static void verifyNotice(MyMesh& node,unsigned index,int queue_position=-1){ + if(queue_position<0) queue_position=node.offline_queue_len-1; + const auto& frame=node.offlineQueueFrameAt(queue_position); + const unsigned base=node.app_target_ver>=3 ? 4:1; + const unsigned text_at=base+7; + assert(frame.buf[0]==(node.app_target_ver>=3 ? RESP_CODE_CHANNEL_MSG_RECV_V3:RESP_CODE_CHANNEL_MSG_RECV)); + if(base==4) assert(frame.buf[1]==0 && frame.buf[2]==0 && frame.buf[3]==0); + assert(frame.buf[base]==index && frame.buf[base+1]==0xff && frame.buf[base+2]==TXT_TYPE_PLAIN); + uint32_t timestamp=0;memcpy(×tamp,frame.buf+base+3,sizeof(timestamp)); + assert(timestamp==node.clock.epoch && frame.len==text_at+strlen(notice)); + assert(!memcmp(frame.buf+text_at,notice,strlen(notice))); + assert(node.ui.last_quiet && !node.ui.override_active && !node.ui.override_screen); + assert(node.ui.notifications==0 && node._notifications.calls==0); + // An ASCII-origin mOTA owner can leave the terminal output registered. + // A quiet local seed must not inject ASCII into that binary folder stream. + assert(node.terminal.echoes==0); + assert(!pending(node,index)); +} +static void fillRealMessages(MyMesh& node){ + for(unsigned i=0;i0 && node.offline_queue_len==0); + node.servicePendingChannelNotices();assert(node.offline_queue_len==0); + } + for(const char* name:{"#custom","#EMERGENCY","#Emergency","emergency","Emergency","#emergency-test","#mt-emergency"}){ + MyMesh node;applySet(node,0,channel(name)); + assert(node.ok && node.offline_queue_len==0 && !pending(node,0)); + node.servicePendingChannelNotices();assert(node.offline_queue_len==0); + } + { + // The 32-byte SET remains Meshtext-only; it must not seed a MeshCore notice. + MyMesh node;auto mt=channel("#mt-emergency"); + const auto alias=channel("#r2-emergency");memcpy(mt.channel.secret,alias.channel.secret,16); + for(unsigned i=16;i<32;++i) mt.channel.secret[i]=uint8_t(i+1); + applySet(node,0,mt,32);assert(node.ok && node.offline_queue_len==0 && !pending(node,0)); + } + { + MyMesh node;auto original=channel("#custom");assert(node.setChannel(0,original)); + node.save_result=false;applySet(node,0,channel("#emergency")); + assert(node.error==ERR_CODE_FILE_IO_ERROR && !node.ok && node.offline_queue_len==0 && !pending(node,0)); + expectRaw(node,0,original);node.save_result=true; + applySet(node,MAX_GROUP_CHANNELS,channel("#emergency")); + assert(node.error==ERR_CODE_NOT_FOUND && node.offline_queue_len==0); + auto invalid=original;strcpy(invalid.name,"#r2-mt-emergency");applySet(node,0,invalid); + assert(node.error==ERR_CODE_ILLEGAL_ARG && node.offline_queue_len==0); + node.valid_transport=false;applySet(node,0,channel("#emergency")); + assert(node.error==ERR_CODE_BAD_STATE && node.offline_queue_len==0 && !pending(node,0)); + } + for(bool cancel:{false,true}){ + MyMesh node;node.held_dm_count=ONE_KEY_DM_SHARED_OFFLINE_QUEUE ? 1:0; + fillRealMessages(node);const unsigned full=node.offline_queue_len; + applySet(node,16,channel("#emergency")); + assert(node.ok && pending(node,16) && node.offline_queue_len==int(full)); + node.servicePendingChannelNotices();node.servicePendingChannelNotices(); + // Even a queue of evictable channel messages must remain untouched. + for(unsigned i=0;i= 2)", + "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32)", + "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16)", + )) + macros = ( + "CMD_GET_CHANNEL", "CMD_SET_CHANNEL", "RESP_CODE_CHANNEL_INFO", + "RESP_CODE_CHANNEL_MSG_RECV", "RESP_CODE_CHANNEL_MSG_RECV_V3", "RESP_CODE_CHANNEL_DATA_RECV", + "PUSH_CODE_MSG_WAITING", "ERR_CODE_UNSUPPORTED_CMD", "ERR_CODE_NOT_FOUND", + "ERR_CODE_FILE_IO_ERROR", "ERR_CODE_ILLEGAL_ARG", "ERR_CODE_BAD_STATE", + ) + constants = "\n".join(re.search(r"^#define\s+" + name + r"\s+[^\r\n]+", companion, + flags=re.MULTILINE).group(0) for name in macros) + text_helpers = (ROOT / "src/helpers/TxtDataHelpers.h").read_text(encoding="utf-8") + constants += "\n" + re.search(r"^#define\s+TXT_TYPE_PLAIN\s+[^\r\n]+", text_helpers, + flags=re.MULTILINE).group(0) + save_getter = body(header, "bool getChannelForSave(uint8_t").replace("override", "") + members = COMMAND_MEMBERS.replace( + " void seedEmergencyChannelNotice(uint8_t, const ChannelDetails&) {}\n", "") + members = members.replace("void writeOKFrame() { ok = true; }", + "void writeOKFrame() { ok = true; ack_written = true; }") + fixture = RUNTIME_HARNESS[:RUNTIME_HARNESS.index("\nint main()")] + fixture = fixture.replace("#define MAX_GROUP_CHANNELS 5", "#define MAX_GROUP_CHANNELS 17") + fixture = fixture.replace("class MyMesh :", COMMAND_STUBS + "\nclass MyMesh :") + fixture = fixture.replace("@SAVE_GETTER@", save_getter + "\n" + members + NOTICE_MEMBERS) + command_helpers = COMMAND_HARNESS[:COMMAND_HARNESS.index("\nint main()")] + command_helpers = command_helpers.replace("uint8_t out_frame[128] = {};", + "NoticeCommandScope ack_scope; uint8_t out_frame[128] = {};") + shared = HARNESS[:HARNESS.index("static void assertAlias(")] + vectors, key_vectors = oracle_vectors() + program = (shared + constants + "\n" + NOTICE_STUBS + fixture + command_helpers + NOTICE_CASES) + program = program.replace("@VECTORS@", vectors).replace("@KEY_VECTORS@", key_vectors) + program = program.replace("@METHODS@", methods).replace("@COMMAND_BRANCHES@", branches) + for held in (0, 1): + with self.subTest(shared_held_dm_queue=held): + self.run_native_program(program.replace("@HELD@", str(held)), "notice", + extra_flags=("-Wno-unused-parameter",)) + # Prove the executable fixture catches the two dangerous regressions: + # treating a local seed like a real audible message, or letting the + # general queue policy evict real traffic to make room for the seed. + self.assertIn("true, false)) pending", program) + self.run_native_program(program.replace("@HELD@", "0").replace( + "true, false)) pending", "true, true)) pending", 1), "notice", + extra_flags=("-Wno-unused-parameter",), expect_failure="node.ui.last_quiet") + self.assertIn("if (offline_queue_len >= capacity) return;", program) + self.run_native_program(program.replace("@HELD@", "1").replace( + "if (offline_queue_len >= capacity) return;", + "if (offline_queue_len > capacity) return;", 1), "notice", + extra_flags=("-Wno-unused-parameter",), expect_failure="pending(node,16)") + + def run_native_program(self, source_text, expected, extra_flags=(), expect_failure=None): compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") with tempfile.TemporaryDirectory(prefix="meshcore-channel-runtime-") as directory: work = Path(directory) @@ -642,14 +912,18 @@ class CompanionChannelAliasTest(unittest.TestCase): sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) built = subprocess.run( - [compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", *sanitizers, + [compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", *extra_flags, *sanitizers, "-I" + str(ROOT / "src"), str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60, ) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) - self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) - self.assertIn("PASS " + expected, checked.stdout) + if expect_failure: + self.assertEqual(checked.returncode, 91, checked.stdout + checked.stderr) + self.assertIn(expect_failure, checked.stderr) + else: + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("PASS " + expected, checked.stdout) if __name__ == "__main__": diff --git a/test/test_companion_meshtext.py b/test/test_companion_meshtext.py index d82a1a2b1..b54aa7819 100644 --- a/test/test_companion_meshtext.py +++ b/test/test_companion_meshtext.py @@ -56,7 +56,10 @@ class CompanionMeshtextTest(unittest.TestCase): mt = (ROOT / "examples/companion_radio/MeshtextCompanion.cpp").read_text(encoding="utf-8") mt = mt[mt.index("namespace {"):].rsplit("#endif", 1)[0] signatures = ("bool MyMesh::isAliasRadio2Defined(", "bool MyMesh::resolveChannelAlias(", - "bool MyMesh::getChannel(", "bool MyMesh::setChannel(", "bool MyMesh::queueChannelText(", + "bool MyMesh::getChannel(", "bool MyMesh::setChannel(", + "static bool isEmergencyNoticeChannelName(", + "void MyMesh::seedEmergencyChannelNotice(", + "void MyMesh::servicePendingChannelNotices(", "bool MyMesh::queueChannelText(", "bool MyMesh::saveAdvertName(") methods = "\n".join(body(source, signature) for signature in signatures) utils_source = (ROOT / "src/Utils.cpp").read_text(encoding="utf-8")