Files
MeshCore-Solo/examples/companion_radio/MyMesh.h
T
JakubandClaude Opus 5 1d5dbe2f9a feat(companion): contact expiry + prune, and scope-list follow-up fixes
Settings > Contacts gains "Expire" (Off/7d/30d/90d) and a "Prune now" action
that counts first and asks before removing anything. A contact with no
advert/update within the threshold is eligible; favourites are always kept.
Thresholds and their labels live in one NodePrefs table so the age shown and
the age enforced can't drift. SCHEMA_SENTINEL -> 0xC0DE002C (sizeof unchanged
at 2824, confirmed on native and a real WioTrackerL1 build).

Also fixes four bugs in the scope list from 89c02eea:

- removeScope() saved /scopes1 but never savePrefs(), so the ch_scope_idx[]
  and repeat_extra_scope_mask fix-ups it makes were lost on reboot, leaving
  shifted entries against unshifted indices.
- CMD_SET_DEFAULT_FLOOD_SCOPE wrote the legacy fields directly instead of
  going through setPrimaryScope(), so the app's default-scope setting had
  nothing reading it once sends resolved through the list.
- The reverse direction was stale too: an on-device "Set as default" never
  refreshed default_scope_name/key, so CMD_GET_DEFAULT_FLOOD_SCOPE reported
  a scope the device had stopped using. New syncLegacyDefaultScope().
- Upgrading from the old single Scope field set the default (so DMs kept it)
  but left every channel unscoped, since "*" means unscoped, not "inherit".
  loadScopeList() now reports when it migrated and begin() seeds the channels
  that already exist, leaving empty slots alone.

"Set default" -> "Set as default", and setting it now says what it governs
("Default: DMs + relay") rather than leaving the [default] marker to imply
more than it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:55:46 +02:00

678 lines
34 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <Arduino.h>
#include <Mesh.h>
#include "AbstractUITask.h"
#include <helpers/ui/DisplayDriver.h>
// Forward declaration for UITask
class UITask;
/*------------ Frame Protocol --------------*/
#define FIRMWARE_VER_CODE 13
// Fallback only -- every real build (local or CI) goes through build.sh, which
// always injects its own FIRMWARE_BUILD_DATE (today's date at build time).
// __DATE__ is the compiler's own "Mmm dd yyyy" build-date macro, so a
// `pio run` invoked directly (bypassing build.sh -- e.g. an IDE's Build
// button) still shows the date it was actually compiled, instead of a
// hardcoded string that would otherwise go stale and never change again.
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE __DATE__
#endif
// Fallback only -- every real build (local or CI) goes through build.sh, which
// always injects FIRMWARE_VERSION itself (the pushed tag name for a release,
// "dev-<commit>" otherwise; see build-solo-firmwares.yml). This default only
// shows up for a `pio run` invoked directly, bypassing build.sh entirely --
// a plain "dev build" stamped with its compile date instead of a specific
// version number that would otherwise be hardcoded here and go stale (and
// misleadingly claim to BE that version) the moment development moves on.
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "dev-" __DATE__
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include <InternalFileSystem.h>
#elif defined(RP2040_PLATFORM)
#include <LittleFS.h>
#elif defined(ESP32)
#include <SPIFFS.h>
#endif
#include "DataStore.h"
#include "NodePrefs.h"
#include "ScopeList.h"
#include <RTClib.h>
#include <helpers/ArduinoHelpers.h>
#include <helpers/BaseSerialInterface.h>
#include <helpers/IdentityStore.h>
#include <helpers/SimpleMeshTables.h>
#include <helpers/StaticPoolPacketManager.h>
#include <target.h>
/* ---------------------------------- CONFIGURATION ------------------------------------- */
// LORA_FREQ/BW/SF/CR fallbacks now live in NodePrefs.h (DataStore.cpp needs them too).
#ifndef LORA_TX_POWER
#define LORA_TX_POWER 20
#endif
#ifndef MAX_LORA_TX_POWER
#define MAX_LORA_TX_POWER LORA_TX_POWER
#endif
#ifndef MAX_CONTACTS
#define MAX_CONTACTS 100
#endif
#ifndef OFFLINE_QUEUE_SIZE
#define OFFLINE_QUEUE_SIZE 16
#endif
#ifndef BLE_NAME_PREFIX
#define BLE_NAME_PREFIX "MeshCore-"
#endif
#include <helpers/BaseChatMesh.h>
#include <helpers/TransportKeyStore.h>
/* -------------------------------------------------------------------------------------- */
#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS
#define REQ_TYPE_KEEP_ALIVE 0x02
#define REQ_TYPE_GET_TELEMETRY_DATA 0x03
struct AdvertPath {
uint8_t pubkey_prefix[7];
uint8_t path_len;
char name[32];
uint32_t recv_timestamp;
uint8_t path[MAX_PATH_SIZE];
};
struct DiscoverResult {
char name[32]; // contact name if known, "" if unknown (use type label)
uint8_t type; // ADV_TYPE_REPEATER / ADV_TYPE_SENSOR / ADV_TYPE_ROOM
bool is_known; // true = in contacts[], false = new unknown node
int8_t rssi; // RSSI of the response as received by us (dBm)
int8_t snr_x4; // SNR of the response as received by us (dB × 4)
int8_t remote_snr_x4; // SNR at which responder heard our request (dB × 4)
uint8_t pub_key[PUB_KEY_SIZE];
uint32_t timestamp;
};
#define EXPECTED_ACK_TABLE_SIZE 8
class MyMesh : public BaseChatMesh, public DataStoreHost {
public:
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
void begin(bool has_display);
void startInterface(BaseSerialInterface &serial);
const char *getNodeName();
NodePrefs *getNodePrefs();
uint32_t getBLEPin();
void loop();
void handleCmdFrame(size_t len);
bool advert();
#ifdef SIM_PLATFORM
// Phase 3 sim-only test hook: same as advert() but FLOOD-routed (like the
// real phone-app CMD_SEND_SELF_ADVERT command's flood=1 branch) instead of
// zero-hop, so a JS test harness can make an instance's identity actually
// propagate through an intermediate repeater instance -- advert() alone
// (zero-hop) never leaves the immediate ether link. Not reachable from any
// real hardware build (no phone app exists in the sim to send the real
// CMD_SEND_SELF_ADVERT command over), so this is additive/dead code
// everywhere else, not a behavior change.
bool advertFlood();
#endif
void sendNodeDiscoverReq();
void enterCLIRescue();
int getRecentlyHeard(AdvertPath dest[], int max_num);
int getDiscoverResults(DiscoverResult dest[], int max_count);
// On-device contact management — lets Nearby Nodes add a discovered node or
// delete a contact without the phone app. Mirrors the CMD_ADD/REMOVE paths.
bool addDiscoveredContact(const uint8_t* pub_key, const char* name, uint8_t type);
bool deleteContactByKey(const uint8_t* pub_key);
// Settings > Contacts > "Expire" / "Prune now" -- a contact with no
// advert/update (ContactInfo::lastmod) within NodePrefs::contactExpiryDays(
// _prefs.contact_expiry_idx) days is eligible for removal; index 0 = Off.
// Favourites are always exempt. Both take the threshold from that one
// NodePrefs table, the same one the Settings row labels itself from.
uint32_t staleContactThresholdSecs() const; // 0 when expiry is Off
// How many contacts pruneStaleContacts() would remove right now, without
// removing anything -- backs the confirm dialog. 0 whenever expiry is Off.
// Not const: BaseChatMesh::getContactByIdx() isn't either.
int countStaleContacts();
// Removes every non-favourite contact whose lastmod is older than the
// configured threshold. Returns the number actually removed. The contacts
// file is written lazily, like every other contact edit (see
// dirty_contacts_expiry / flushDirtyContacts()).
int pruneStaleContacts();
// Ping/Trace functionality
#define PING_RESULT_MAX 4
typedef void (*PingCallback)(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms);
struct PingResult {
uint32_t tag;
uint32_t auth_code;
int16_t snr_out_x4; // SNR out (to first hop) × 4
int16_t snr_back_x4; // SNR back (from last hop to us) × 4
uint32_t rtt_ms; // Round-trip time in milliseconds
bool received;
unsigned long sent_ms;
};
uint32_t sendPing(const uint8_t* dest_pubkey, uint8_t hash_width = 1);
void setPingCallback(PingCallback cb, void* arg);
void clearPingResult(uint32_t tag);
PingResult* getPingResult(uint32_t tag);
PingCallback getPingCallback() const { return _ping_callback; }
AbstractUITask* getUITask() const { return _ui; }
protected:
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
bool getCADEnabled() const override;
int calcRxDelay(float score, uint32_t air_time) const override;
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
uint8_t getExtraAckTransmitCount() const override;
bool filterRecvFloodPacket(mesh::Packet* packet) override;
bool allowPacketForward(const mesh::Packet* packet) override;
bool isRepeatLooped(const mesh::Packet* packet) const;
// Overhear suppression only makes sense while repeating; gated behind its own
// opt-in pref (Tools > Repeater > Suppress dup).
bool wantsOverhearSuppress() const override { return _prefs.client_repeat && _prefs.repeat_suppress_dup; }
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
bool isAutoAddEnabled() const override;
bool shouldAutoAddContactType(uint8_t type) const override;
bool shouldOverwriteWhenFull() const override;
uint8_t getAutoAddMaxHops() const override;
void onContactsFull() override;
void onContactOverwrite(const uint8_t* pub_key) override;
bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override;
void onDiscoveredAdvert(bool was_flood) override;
void onContactPathUpdated(const ContactInfo &contact) override;
ContactInfo* processAck(const uint8_t *data) override;
void queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *extra, int extra_len, const char *text);
void onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text) override;
void onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text) override;
void onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *sender_prefix, const char *text) override;
void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
const char *text) override;
void onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type,
const uint8_t *data, size_t data_len) override;
uint8_t onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) override;
void onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) override;
void onControlDataRecv(mesh::Packet *packet) override;
void onRawDataRecv(mesh::Packet *packet) override;
void onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override;
uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override;
uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override;
void onSendTimeout() override;
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; // APC: ACK SNR sample
// APC internals (see MyMesh.cpp): one reverse-link SNR sample, a lost-confirmation
// ramp-up, and tracking an originated flood so its echo (or absence) can be scored.
// The heard-echo sampling itself lives in filterRecvFloodPacket() (declared below).
void apcSampleSnr(float snr);
void apcOnFailure();
void apcTrackFloodSend(const mesh::Packet* pkt);
void trackRelaySend(const mesh::Packet* pkt); // arm the UI relayed-into-mesh tracker
public:
// Seq of the most recently tracked channel send — the UI records it on the
// outgoing history entry so a heard echo (onChannelRelayed) can match it back.
uint32_t lastChannelRelaySeq() const { return _last_relay_seq; }
// Mirror a channel post this device just originated (sendGroupMessage
// already succeeded) into the on-device history AND arm its "Relayed by"
// tracker in one call -- every caller (CMD_SEND_CHANNEL_TXT_MSG, the three
// Remote Bot reply/command/locfix paths) needs both, always in this order,
// and a caller that adds the history entry without arming the tracker
// right after (an easy line to forget) silently never shows repeater
// confirmations for that message. sendGroupMessage's sendFloodScoped(
// GroupChannel&, ...) already calls trackRelaySend() unconditionally, so
// lastChannelRelaySeq() is already the seq for the send just made.
int mirrorOwnChannelMsg(uint8_t channel_idx, const char* text, int text_len = -1, uint32_t timestamp = 0) {
if (!_ui) return -1;
int pos = _ui->addOwnChannelMsg(channel_idx, text, text_len, timestamp);
if (pos >= 0) _ui->armChannelRelay(pos, lastChannelRelaySeq());
return pos;
}
private:
// DataStoreHost methods
bool onContactLoaded(const ContactInfo& contact) override { return addContact(contact); }
bool getContactForSave(uint32_t idx, ContactInfo& contact) override { return getContactByIdx(idx, contact); }
bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); }
bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); }
void clearPendingReqs() {
pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = ui_pending_login = 0;
}
public:
// On-device UI login to a room/repeater contact — no phone app required.
// The room server's ACL grants permission per-identity (self_id), not per
// command source, so this reuses the same sendLogin() the BLE CMD_SEND_LOGIN
// path uses; the async result lands in onContactResponse() and is pushed to
// the UI via AbstractUITask::onRoomLoginResult().
bool sendRoomLogin(const ContactInfo& contact, const char* password, uint32_t& est_timeout) {
if (sendLogin(contact, password, est_timeout) == MSG_SEND_FAILED) return false;
clearPendingReqs();
memcpy(&ui_pending_login, contact.id.pub_key, 4); // match this in onContactResponse()
return true;
}
// Called by a screen that gave up waiting on its own sendRoomLogin() (Cancel
// or a timeout) so a reply that arrives after doesn't get misrouted:
// UITask::onRoomLoginResult() dispatches by whichever screen is *currently*
// showing, not by who actually sent the request, so a late reply landing
// after the requester navigated away is delivered to whatever screen the
// user is on instead -- which then persists its own unrelated login state
// (e.g. a different screen's _login_pw) as if it were that reply's answer.
// Pubkey-guarded so this is a no-op if a newer request (this screen retrying,
// or a different screen entirely) has since overwritten ui_pending_login.
void cancelUiPendingLogin(const uint8_t* pub_key) {
if (ui_pending_login && memcmp(&ui_pending_login, pub_key, 4) == 0) ui_pending_login = 0;
}
// On-device UI logout: drops the local keep-alive tracking (mirrors the app's
// CMD_LOGOUT) and forgets the saved password, so the next room open prompts
// for credentials again instead of silently re-using them. No packet is sent
// to the server -- the room ACL has no session state to tear down, this is
// purely local "forget this login" bookkeeping.
void logoutRoom(const uint8_t* pub_key) {
stopConnection(pub_key);
forgetRoomPassword(pub_key);
}
// On-device-saved room/repeater login passwords, persisted to flash (own
// small file, independent of /contacts3) so a room that's already been
// logged into doesn't need its password retyped after reboot.
bool saveRoomPassword(const uint8_t* pub_key, const char* password);
bool getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len);
void forgetRoomPassword(const uint8_t* pub_key);
// On-device channel add/edit/delete (Messages > Channels). Shares the exact
// setChannel + saveChannels + onChannelRemoved-cleanup sequence the
// CMD_SET_CHANNEL BLE handler already performs, so both paths stay in sync.
bool setChannelLocal(uint8_t idx, const ChannelDetails& ch);
// On-device favourite toggle (Messages > Contacts/Rooms > Fav). Writes the
// same ContactInfo::flags bit 0 the app's starred-contact flag uses, so the
// "fav" list filters no longer depend on the app having starred anything.
bool setContactFavourite(const uint8_t* pub_key, bool fav);
// On-device "remote admin" (Tools > Admin): send a CLI command to a node
// you're logged into with admin permission (see ClientACL::isAdmin()). The
// reply is a text frame (TXT_TYPE_CLI_DATA) delivered via onCommandDataRecv();
// this just tracks which contact's reply AdminScreen is currently waiting on.
bool sendAdminCommand(const ContactInfo& contact, const char* cmd_text, uint32_t& est_timeout) {
if (sendCommandData(contact, rtc_clock.getCurrentTime(), 0, cmd_text, est_timeout) == MSG_SEND_FAILED)
return false;
memcpy(&ui_pending_admin_reply, contact.id.pub_key, 4);
return true;
}
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void saveRTCTime() { _store->saveRTCTime(); }
// Contact updates (new adverts, path/lastmod changes) are lazily debounced
// (see dirty_contacts_expiry) to avoid wearing flash on every packet --
// under regular mesh traffic the timer keeps getting re-armed, so it can
// stay pending for the device's whole uptime. CMD_REBOOT already flushes
// this before rebooting; UITask::shutdown() (low-battery auto-shutdown,
// long-press power-off) needs the same flush or a whole session's worth
// of learned contacts can be lost.
void flushDirtyContacts() { if (dirty_contacts_expiry) { saveContacts(); dirty_contacts_expiry = 0; } }
DataStore* getDataStore() const { return _store; }
void applyApc(); // (re)initialise Adaptive Power Control from prefs
// Adaptive Power Control is suppressed while repeating: a repeater wants full,
// consistent TX power for relay reach, and its feedback sources (own ACKs /
// own flood echoes) don't fire on forwarded traffic anyway. applyApc() then
// pins power to the ceiling.
bool apcActive() const { return _prefs.tx_apc && !_prefs.client_repeat; }
// True when the optional repeater radio profile is a valid LoRa config for
// this radio (freq bounds come from the chip's own validated range).
bool repeaterProfileValid() const {
float lo, hi; radio_driver.getFreqBounds(lo, hi);
return isValidRepeaterProfile(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr, lo, hi);
}
// Load the radio with the correct params for the current mode: the repeater
// profile when relaying with a valid dedicated profile, otherwise the
// companion's own params. Single source of truth, used at boot and whenever
// the repeater toggle / network / profile changes.
void applyRepeaterRadio();
// Sets this device's own default scope (Settings > Radio > Scope, and the
// app's CMD_SET_DEFAULT_FLOOD_SCOPE) from a single typed region name.
// Keeps default_scope_name/default_scope_key in sync (legacy fields, inert
// once /scopes1 exists -- see ScopeList.h) *and* finds-or-creates a
// matching entry in the shared scope list, marking it default -- so a
// scope set remotely by the app shows up as a real, named entry on the
// device's own list too, not just in the two legacy fields. Empty name
// clears the default back to list index 0 ("*"). Calls
// rebuildRepeatScopes() and persists the list.
void setPrimaryScope(const char* name);
// Mirrors the list's current default entry back into the legacy
// NodePrefs::default_scope_name/key pair. Those two fields are the only
// shape CMD_GET_DEFAULT_FLOOD_SCOPE can report, so any on-device change to
// which entry is default (or a rename/delete that moves it) has to refresh
// them -- otherwise the app keeps showing, and re-sending, a scope the
// device stopped using. Callers persist prefs themselves.
void syncLegacyDefaultScope();
// Rebuilds repeat_scopes[]/repeat_scope_count from the scope list's current
// default entry (slot 0) plus repeat_extra_scope_mask (Tools > Repeater >
// Extra scopes -- a toggle over the same list). Call after loading prefs +
// the scope list at boot and whenever either scope setting is edited.
void rebuildRepeatScopes();
// The shared named-scope list (Settings > Radio > Scope). Read-only outside
// MyMesh -- edits go through setPrimaryScope()/addScope()/setChannelScope()/
// setDefaultScope() so repeat_scopes[]/persistence stay in sync.
const ScopeList& scopeList() const { return _scope_list; }
// Adds a new named entry (see ScopeList::add()), persists the list, and
// returns its list index (0 if the name's empty or the list's full).
uint8_t addScope(const char* name);
// Renames list index idx (1..count; a no-op for 0/"*" or an empty name),
// re-deriving its key (a scope's key is purely a hash of its name) and
// persisting the list.
void renameScope(uint8_t idx, const char* name);
// Removes list index idx (1..count; a no-op for 0/"*"), persists the list,
// fixes up repeat_extra_scope_mask, and clears any channel's ch_scope_idx
// that pointed at it (shift-and-clamp, matching ScopeList::remove()).
void removeScope(uint8_t idx);
// Marks list index idx as the default (used for DMs and any channel/
// repeater slot without its own pick). Persists the list and rebuilds
// repeat_scopes[].
void setDefaultScope(uint8_t idx);
// Sets channel_idx's own scope-list pick (0 = "*"). Persists prefs.
void setChannelScope(uint8_t channel_idx, uint8_t idx);
bool isAckPending(uint32_t expected_ack) const {
if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
if (expected_ack_table[i].ack != 0 && expected_ack_table[i].ack == expected_ack) return true;
return false;
}
#if ENV_INCLUDE_GPS == 1
void applyGpsPrefs() {
sensors.setSettingValue("gps", _prefs.gps_enabled ? "1" : "0");
// gps_interval doubles as the GPS duty-cycle sleep window in seconds
// (0 = disabled, GPS stays continuous) -- see EnvironmentSensorManager::
// gpsDutyCycleLoop(). Max: 24 hours = 86400 seconds (5 digits + null).
char interval_str[12];
sprintf(interval_str, "%u", _prefs.gps_interval);
sensors.setSettingValue("gps_interval", interval_str);
}
#endif
// To check if there is pending work
bool hasPendingWork() const;
// Number of auto-replies sent since boot (DM + channel + room). Shown on BotScreen.
uint16_t botReplyCount() const { return _bot_reply_count; }
// Whether a "!gps fix" request is acquiring/averaging right now -- used by
// UITask's GPS duty-cycle "is anything live using GPS right now" check, so
// the scheduler doesn't nap GPS out from under an in-flight bot request.
bool isGpsFixPending() const { return _loc_fix.active; }
private:
void tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t hops);
void tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t hops);
void tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops);
bool tryBotCommand(const ContactInfo& from, const char* text, uint8_t hops); // DM commands
bool tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t hops); // channel commands
bool tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops); // room commands
bool botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text
int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name, bool actions_allowed); // scan "!word"s → combined reply, returns count
// !gps fix -- single-shot "wait for a stabilised GPS fix, then push a follow-up
// message" action. botCommandReply() only sets _locfix_requested (it doesn't know
// the destination); the tryBot*Command() wrappers call startLocFix() with the
// destination they each already have, but only once the immediate ack actually
// sent (so a throttled/suppressed ack never starts a fix nobody will hear about).
void tickLocFix(); // ticked every loop() while _loc_fix.active
void startLocFix(uint8_t dest_type, const uint8_t* pub_key, uint8_t channel_idx);
void sendLocFixResult(const char* msg);
static bool isLocFixReady(LocationProvider* loc); // HDOP if the provider has it, else satellite count
bool botTriggerMatches(const char* trigger, const char* body, bool allow_wildcard) const;
bool botInQuietHours() const; // true when auto-replies should stay silent
bool botDmAllowed(const uint8_t* pubkey); // per-contact DM throttle: ok to reply?
void botDmRecord(const uint8_t* pubkey); // remember we just replied to this contact
// DM allow-list gate (bot_dm_scope): true unless scope is favourites-only
// and `from` isn't starred. Shared by the DM trigger-reply and command paths.
bool botDmSenderAllowed(const ContactInfo& from) const;
// Resolves a room post's signed author prefix to a display name for {name};
// falls back to a generic label when the poster isn't a known contact.
void botRoomSenderName(const uint8_t* sender_prefix, char* out, int out_len);
// Splits a channel message's leading "SenderName: " text convention off its
// body, for the {name} placeholder and to match triggers/commands against
// the body only. `*msg_out` points into `text` (no copy); `sender_name`
// defaults to "someone" if there's no ": " separator.
void botChannelSenderSplit(const char* text, char* sender_name, int sender_name_len, const char** msg_out);
void writeOKFrame();
void writeErrFrame(uint8_t err_code);
void writeDisabledFrame();
void writeContactRespFrame(uint8_t code, const ContactInfo &contact);
void updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len);
void addToOfflineQueue(const uint8_t frame[], int len);
int getFromOfflineQueue(uint8_t frame[]);
int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override {
return _store->getBlobByKey(key, key_len, dest_buf);
}
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override {
return _store->putBlobByKey(key, key_len, src_buf, len);
}
void checkCLIRescueCmd();
void checkSerialInterface();
bool isValidClientRepeatFreq(uint32_t f) const;
#ifdef ENABLE_SCREENSHOT
void handleScreenshotRequest();
void sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize);
#endif
// helpers, short-cuts
void saveChannels() { _store->saveChannels(this); }
void saveContacts();
DataStore* _store;
NodePrefs _prefs;
uint32_t pending_login;
uint32_t ui_pending_login; // like pending_login, but triggered by on-device UI instead of BLE/USB app
char pending_login_pw[16]; // password of the in-flight app/USB login, persisted on success for ADV_TYPE_ROOM (see saveRoomPassword)
uint32_t ui_pending_admin_reply; // pub_key prefix of the contact AdminScreen's sendAdminCommand() is awaiting a CLI reply from
uint32_t pending_status;
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
uint32_t pending_req; // pending _BINARY_REQ
BaseSerialInterface *_serial;
AbstractUITask* _ui;
ContactsIterator _iter;
uint32_t _iter_filter_since;
uint32_t _most_recent_lastmod;
uint32_t _active_ble_pin;
bool _iter_started;
bool _cli_rescue;
int8_t _apc_cur_dbm; // APC current TX power (≤ tx_power_dbm ceiling) when tx_apc on
float _apc_margin_ewma; // APC smoothed reverse-link SNR margin above the SF demod floor
uint8_t _apc_fail_count; // APC consecutive lost-confirmation count (graduated ramp-up)
uint8_t _apc_flood_hash[MAX_HASH_SIZE]; // hash of the channel/flood send awaiting a repeater echo
uint16_t _apc_flood_len; // its payload length — cheap pre-filter before hashing
uint32_t _apc_flood_deadline; // echo-wait deadline for that send
bool _apc_flood_pending; // a tracked flood send is awaiting its echo
// UI "relayed into mesh" tracker for channel sends — independent of APC. A small
// ring so a quick burst of channel sends are each tracked (not just the latest).
// Hashing on receive only runs while at least one slot is pending, so the hot
// flood-recv path is untouched otherwise.
static const int RELAY_RING = 4;
struct RelaySlot {
uint8_t hash[MAX_HASH_SIZE];
uint16_t len;
uint32_t deadline;
uint32_t seq;
bool pending;
};
RelaySlot _relay[RELAY_RING];
int _relay_head; // next ring slot to overwrite
int _relay_active; // number of slots currently pending (cheap gate)
uint32_t _relay_seq; // monotonic id counter for tracked sends
uint32_t _last_relay_seq; // seq of the most recent tracked send (for the UI to record)
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80];
uint8_t app_target_ver;
uint8_t *sign_data;
uint32_t sign_data_len;
unsigned long dirty_contacts_expiry;
unsigned long _bot_last_ch_reply_ms;
unsigned long _bot_last_room_reply_ms;
unsigned long _next_auto_advert_ms;
// Per-contact DM reply throttle: a small ring of the most recent recipients so
// one chatty contact can't be spammed while a different sender is still served.
struct BotReplyLog { uint8_t key[4]; unsigned long t_ms; bool used; };
static const int BOT_DM_LOG_SIZE = 8;
BotReplyLog _bot_dm_log[BOT_DM_LOG_SIZE];
uint16_t _bot_reply_count; // total auto-replies sent since boot
// !gps fix state -- one global slot (one physical GPS): botCommandReply()
// rejects a second request outright while one is active, so this never
// needs to be an array. See tickLocFix()/startLocFix() in MyMeshBot.h.
// Readiness: HDOP (tenths, lower=better) when the provider exposes it --
// 20 == HDOP 2.0, the usual "good fix" cutoff -- else satellite count as a
// cruder fallback (see LocationProvider::getHDOP()'s -1 = "not available").
static const int LOCFIX_MAX_HDOP = 20;
static const int LOCFIX_MIN_SATS = 8; // readiness threshold (HDOP-less fallback)
static const uint32_t LOCFIX_AVERAGE_MS = 10000; // once ready, keep averaging this long
static const uint32_t LOCFIX_TIMEOUT_MS = 90000; // hard stop covering both phases
enum { LOCFIX_DEST_CONTACT = 0, LOCFIX_DEST_CHANNEL = 1 }; // CONTACT covers DM and room alike (both reply via sendMessage)
struct PendingLocFix {
bool active;
bool gps_was_on; // restore to this when done, not unconditionally "off"
uint32_t deadline_ms;
uint32_t averaging_until_ms; // 0 while still acquiring; set once the sat threshold is first met
double sum_lat, sum_lon;
int sample_count;
uint8_t dest_type;
uint8_t pub_key[PUB_KEY_SIZE]; // dest_type == LOCFIX_DEST_CONTACT
uint8_t channel_idx; // dest_type == LOCFIX_DEST_CHANNEL
};
PendingLocFix _loc_fix;
bool _locfix_requested; // transient: set by botCommandReply() when "!gps fix" was
// seen this scan, cleared by the tryBot*Command() wrapper
uint32_t _locfix_requested_timeout_ms; // "!gps fix [seconds]" override, see startLocFix()
// Deferred bot actions (!gps on|off, !buzz, !advert, !gpio1..4 on|off) --
// botCommandReply() only records what was requested; the actual hardware/
// radio side effect happens in applyPendingBotActions(), called by the
// tryBot*Command() wrappers only once quiet-hours/cooldown/per-contact
// throttle have passed and the ack actually sent. Otherwise those gates
// would only suppress the reply text while the action fired unconditionally
// on every matching message (e.g. !buzz still buzzing during quiet hours,
// or an unthrottled !advert flooding the mesh). Mirrors the _locfix_requested
// pattern above; resetPendingBotActions() is the throttled/aborted-path
// twin of applyPendingBotActions(), used wherever _locfix_requested used to
// be cleared alone.
bool _bot_gps_action_pending;
bool _bot_gps_action_on;
int _bot_buzz_action_secs; // 0 = no !buzz requested this scan
bool _bot_advert_action_pending;
int8_t _bot_gpio_action[4]; // per pin: -1 none requested, 0 off, 1 on
void applyPendingBotActions();
void resetPendingBotActions();
TransportKey send_scope;
// The shared named-scope list backing Settings > Radio > Scope, the
// channel context menu's Scope: row, and Tools > Repeater > Extra scopes.
// Loaded once at boot (see begin()) via DataStore::loadScopeList(), kept in
// sync with /scopes1 by every mutator above.
ScopeList _scope_list;
// Runtime-only (not persisted) cache of scopes accepted by repeat_scope_only:
// slot 0 is the scope list's current default entry (if not "*"), the rest
// are derived from repeat_extra_scope_mask's set bits. Rebuilt by
// rebuildRepeatScopes() (see the public section below) whenever either
// scope setting changes.
static const uint8_t MAX_REPEAT_SCOPES = 4;
TransportKey repeat_scopes[MAX_REPEAT_SCOPES];
uint8_t repeat_scope_count;
uint8_t cmd_frame[MAX_FRAME_SIZE + 1];
uint8_t out_frame[MAX_FRAME_SIZE + 1];
CayenneLPP telemetry;
struct Frame {
uint8_t len;
uint8_t buf[MAX_FRAME_SIZE];
bool isChannelMsg() const;
};
int offline_queue_len;
Frame offline_queue[OFFLINE_QUEUE_SIZE];
struct AckTableEntry {
unsigned long msg_sent;
uint32_t ack;
ContactInfo* contact;
};
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
int next_ack_idx;
#define ADVERT_PATH_TABLE_SIZE 16
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
#define DISCOVER_RESULTS_MAX 16
DiscoverResult _discover_results[DISCOVER_RESULTS_MAX];
int _discover_count;
uint32_t _pending_node_discover_tag;
unsigned long _pending_node_discover_until;
// Dedup for NODE_DISCOVER_RESP copies heard more than once: the responder's
// zero-hop direct copy and a re-flooded copy relayed by another repeater
// carry different packet hashes, so the mesh duplicate filter passes both.
// Keyed by (tag, responder pubkey prefix) with a short expiry — covers both
// the standalone on-device scan and responses forwarded to the app (which
// would otherwise list the same repeater twice).
#define DISCOVER_SEEN_MAX 16
struct DiscoverSeen { uint32_t tag; uint8_t pk[6]; unsigned long until; };
DiscoverSeen _disc_seen[DISCOVER_SEEN_MAX];
uint8_t _disc_seen_head;
bool isDupDiscoverResp(uint32_t tag, const uint8_t* pub_key); // records when new
// ── Ping/Trace state ──────────────────────────────────────────────────────
PingResult _ping_results[PING_RESULT_MAX];
PingCallback _ping_callback;
void* _ping_callback_arg;
};
extern MyMesh the_mesh;