Files
MeshCore-Solo/examples/companion_radio/MyMesh.h
Jakub 0a61b7ef15 fix(bot,ui): defer bot actions past throttle, fix CardKB lock bypass, add !gps fix timeout arg
- Bot Actions (!buzz/!gps/!advert/!gpio1-4) ran their side effect
  immediately during botScanCommands(), before quiet-hours/cooldown/
  per-contact throttle were checked -- those gates only suppressed the
  reply text, not the actual buzz/GPS toggle/advert/pin write. botCommandReply()
  now only records what was requested; applyPendingBotActions() runs the
  deferred effects once a wrapper's throttle checks pass and the ack sent,
  mirroring the existing _locfix_requested pattern. resetPendingBotActions()
  clears everything on every throttled/aborted path.

- CardKB's Fn+<letter> accent-popup shortcut bypassed the locked-input gate
  (it called into KeyboardWidget directly instead of through the
  enqueueKey()/dequeue path every other key uses, so it wasn't discarded
  while _locked). Now checks _locked itself.

- Since a locked device now correctly ignores CardKB entirely, Fn+Esc
  (single press) is added as CardKB's own lock/unlock gesture -- otherwise
  a CardKB-only setup had no way to unlock. Esc rather than the adjacent
  Fn+Backspace, to avoid an accidental press.

- botScanCommands() now parses up to two arguments per command instead of
  one. Used by "!gps fix [seconds]" to override the default 90s timeout
  (clamped 15-300s) for a poor sky view where 90s isn't always enough to
  reach isLocFixReady()'s HDOP/satellite bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 23:51:31 +02:00

540 lines
26 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
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "6 Jun 2026"
#endif
// Versioning: vX.Y = upstream base, solo.N = fork revision
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.16-solo.0"
#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 <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();
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);
// 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;
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; }
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 "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(); }
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();
bool isAckPending(uint32_t expected_ack) const {
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
if (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");
if (_prefs.gps_interval > 0) {
char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null)
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; }
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;
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;