Seed a quiet local emergency-channel notice on Companion joins
This commit is contained in:
1 parent
4b465f9966
commit
36ec9b8bd3
7 files changed
+384
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Frame, OFFLINE_QUEUE_SIZE, 128, mesh::ota::OtaContext> offline_queue;
|
||||
|
||||
+2
-2
@@ -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<typename... T> 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&);
|
||||
|
||||
Vendored
+26
-3
@@ -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<std::vector<uint8_t>> 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<float>::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<uint8_t>{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");
|
||||
|
||||
@@ -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<int>(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 <cmath>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <helpers/CompanionFrameLimits.h>
|
||||
// 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;i<effectiveCapacity(node);++i){
|
||||
uint8_t frame[]={RESP_CODE_CHANNEL_MSG_RECV,uint8_t(40+i)};
|
||||
assert(node.addToOfflineQueue(frame,sizeof(frame)));
|
||||
}
|
||||
}
|
||||
int main(){
|
||||
for(uint8_t app:{uint8_t(1),uint8_t(3)}) for(bool connected:{false,true})
|
||||
for(const char* name:{"#emergency","#r2-emergency"}){
|
||||
MyMesh node;node.app_target_ver=app;node.serial.connected=connected;
|
||||
ChannelDetails empty{};expectGet(node,9,empty);
|
||||
auto emergency=channel(name);
|
||||
applySet(node,9,emergency);
|
||||
assert(node.ok && node.offline_queue_len==1 && node.ui.messages==1);
|
||||
verifyNotice(node,9);
|
||||
assert(node.serial.length==(connected ? 1:0));
|
||||
if(connected) assert(node.serial.frame[0]==PUSH_CODE_MSG_WAITING);
|
||||
// A label/key/routing edit and app replay are not new joins.
|
||||
applySet(node,9,emergency);assert(node.offline_queue_len==1);
|
||||
auto renamed=channel(!strcmp(name,"#emergency") ? "#r2-emergency":"#emergency");
|
||||
applySet(node,9,renamed);assert(node.offline_queue_len==1);
|
||||
for(unsigned i=0;i<16;++i) renamed.channel.secret[i]=uint8_t(i+1);
|
||||
applySet(node,9,renamed);assert(node.offline_queue_len==1);
|
||||
renamed.channel.tx_radio=mesh::RADIO_TX_PRIMARY;assert(node.setChannel(9,renamed));
|
||||
applySet(node,9,renamed);node.servicePendingChannelNotices();
|
||||
assert(node.offline_queue_len==1 && node.ui.messages==1);
|
||||
// Later app download sees exactly one ordinary channel-text envelope.
|
||||
uint8_t frame[MAX_FRAME_SIZE]={};
|
||||
assert(node.getFromOfflineQueue(frame)>0 && 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<full;++i){const auto& f=node.offlineQueueFrameAt(i);assert(f.len==2 && f.buf[1]==40+i);}
|
||||
if(cancel){ChannelDetails deleted{};applySet(node,16,deleted);assert(node.ok && !pending(node,16));}
|
||||
node.popOfflineQueue();node.servicePendingChannelNotices();
|
||||
if(cancel) assert(node.offline_queue_len==int(full-1) && node.ui.messages==0);
|
||||
else {assert(node.offline_queue_len==int(full));verifyNotice(node,16);}
|
||||
const int length=node.offline_queue_len;
|
||||
node.servicePendingChannelNotices();assert(node.offline_queue_len==length);
|
||||
}
|
||||
{
|
||||
MyMesh node;fillRealMessages(node);
|
||||
applySet(node,8,channel("#emergency"));applySet(node,16,channel("#r2-emergency"));
|
||||
assert(pending(node,8) && pending(node,16));node.popOfflineQueue();node.popOfflineQueue();
|
||||
node.servicePendingChannelNotices();assert(!pending(node,8) && pending(node,16));verifyNotice(node,8);
|
||||
node.servicePendingChannelNotices();assert(!pending(node,16));verifyNotice(node,16);
|
||||
const int length=node.offline_queue_len;node.servicePendingChannelNotices();assert(node.offline_queue_len==length);
|
||||
}
|
||||
{
|
||||
// Loading/reapplying saved channels during startup is not an app join.
|
||||
MyMesh reboot;assert(reboot.setChannel(9,channel("#emergency")));
|
||||
reboot.servicePendingChannelNotices();assert(reboot.offline_queue_len==0 && !pending(reboot,9));
|
||||
ChannelDetails deleted{};applySet(reboot,9,deleted);applySet(reboot,9,channel("#emergency"));
|
||||
assert(reboot.ok && reboot.offline_queue_len==1);verifyNotice(reboot,9);
|
||||
}
|
||||
{
|
||||
// Optional suppression must not mute normal incoming channel messages.
|
||||
MyMesh node;node.serial.connected=false;auto ordinary=channel("#emergency");
|
||||
assert(node.setChannel(0,ordinary));
|
||||
assert(node.queueChannelText(0,ordinary.channel.secret,0,0,123,"Alice: real traffic",true));
|
||||
assert(node._notifications.calls==1 && node.ui.notifications==1 && !node.ui.last_quiet);
|
||||
assert(node.terminal.echoes==1);
|
||||
}
|
||||
puts("PASS notice persisted join, legacy/V3 local history, quiet delivery, retry and cancellation");
|
||||
}
|
||||
'''
|
||||
|
||||
COMMAND_HARNESS = r'''
|
||||
void MyMesh::command(uint8_t* cmd_frame, int len) {
|
||||
uint8_t out_frame[128] = {};
|
||||
@@ -632,7 +838,71 @@ class CompanionChannelAliasTest(unittest.TestCase):
|
||||
"@METHODS@", methods).replace("@COMMAND_BRANCHES@", branches)
|
||||
self.run_native_program(source_text, "command")
|
||||
|
||||
def run_native_program(self, source_text, expected):
|
||||
def test_persisted_emergency_join_seeds_quiet_local_history_and_defers_without_eviction(self):
|
||||
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8")
|
||||
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text(encoding="utf-8")
|
||||
signatures = (
|
||||
"bool MyMesh::isAliasRadio2Defined(", "bool MyMesh::resolveChannelAlias(",
|
||||
"bool MyMesh::getChannel(", "bool MyMesh::setChannel(",
|
||||
"static bool isEmergencyNoticeChannelName(",
|
||||
"void MyMesh::seedEmergencyChannelNotice(", "void MyMesh::servicePendingChannelNotices(",
|
||||
"bool MyMesh::queueChannelText(", "bool MyMesh::Frame::isChannelMsg() const",
|
||||
"int MyMesh::getOfflineQueueCapacity() const", "MyMesh::Frame& MyMesh::offlineQueueFrameAt(",
|
||||
"bool MyMesh::addToOfflineQueue(", "void MyMesh::popOfflineQueue(",
|
||||
"int MyMesh::peekOfflineQueue(", "int MyMesh::getFromOfflineQueue(",
|
||||
)
|
||||
methods = "\n".join(body(companion, signature) for signature in signatures)
|
||||
branches = " else ".join(body(companion, signature) for signature in (
|
||||
"if (cmd_frame[0] == CMD_GET_CHANNEL && len >= 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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user