mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-09-26 13:48:01 +00:00
1475 lines
73 KiB
C++
1475 lines
73 KiB
C++
#pragma once
|
||
|
||
#include <Arduino.h>
|
||
#include <Mesh.h>
|
||
#include <helpers/AdvertDataHelpers.h>
|
||
#include <helpers/MeshTouchTxTrace.h>
|
||
#include "AbstractUITask.h"
|
||
|
||
// Node-discovery control packet (standard MeshCore — matches examples/simple_repeater
|
||
// + simple_sensor): a zero-hop CTL_TYPE_NODE_DISCOVER_REQ that neighbouring repeaters
|
||
// answer DIRECTLY with a NODE_DISCOVER_RESP. Used by the touch signal probe.
|
||
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 // upper nibble = type; low bit = prefix_only
|
||
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
|
||
|
||
// Operational wire-debug. The companion link is a BINARY protocol over Serial (USB-CDC); writing raw
|
||
// text to Serial during a session interleaves into that stream and corrupts it — the MeshCore app
|
||
// then throws "Bad state: Streamsink is bound to a stream" and the connect / room login / repeater
|
||
// admin all fail (GH #25, #54, #23). Compiled out in release; build -DCOMPANION_WIRE_DEBUG=1 to get
|
||
// these traces back (only safe over a non-companion debug console).
|
||
#ifndef COMPANION_WIRE_DEBUG
|
||
#define COMPANION_WIRE_DEBUG 0
|
||
#endif
|
||
#if COMPANION_WIRE_DEBUG
|
||
#define WIRE_DBG(...) Serial.printf(__VA_ARGS__)
|
||
#else
|
||
#define WIRE_DBG(...) do {} while (0)
|
||
#endif
|
||
|
||
/*------------ Frame Protocol --------------*/
|
||
// Keep the fork's companion-protocol lineage (27 — a superset of upstream's 13)
|
||
// so the existing meshcomod web/phone client still matches. 1.16's new companion
|
||
// commands (raw packet, un-scoped flood, anon req) are added as handlers below;
|
||
// advertising them by bumping this is a coordinated client change for later.
|
||
#define FIRMWARE_VER_CODE 27
|
||
|
||
#ifndef FIRMWARE_BUILD_DATE
|
||
#define FIRMWARE_BUILD_DATE "7 Jun 2026"
|
||
#endif
|
||
|
||
#ifndef FIRMWARE_VERSION
|
||
#define FIRMWARE_VERSION "v1.17.4-touch"
|
||
#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"
|
||
|
||
/* Synthetic local command contact: appears in contact list, messages to it are intercepted and never sent over mesh. */
|
||
#define MESHCOMOD_NAME "Meshcomod"
|
||
|
||
#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 ------------------------------------- */
|
||
|
||
#ifndef LORA_FREQ
|
||
#define LORA_FREQ 915.0
|
||
#endif
|
||
#ifndef LORA_BW
|
||
#define LORA_BW 250
|
||
#endif
|
||
#ifndef LORA_SF
|
||
#define LORA_SF 10
|
||
#endif
|
||
#ifndef LORA_CR
|
||
#define LORA_CR 5
|
||
#endif
|
||
#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 HISTORY_RING_SIZE
|
||
#define HISTORY_RING_SIZE 128
|
||
#endif
|
||
#ifndef MAX_HISTORY_CLIENTS
|
||
#define MAX_HISTORY_CLIENTS 8
|
||
#endif
|
||
#ifndef MAX_CLIENT_ID_LEN
|
||
#define MAX_CLIENT_ID_LEN 31
|
||
#endif
|
||
|
||
#ifndef BLE_NAME_PREFIX
|
||
#define BLE_NAME_PREFIX "MeshCore-"
|
||
#endif
|
||
|
||
#include <helpers/BaseChatMesh.h>
|
||
#include <helpers/TransportKeyStore.h>
|
||
#include "RegionRegistry.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;
|
||
uint32_t recv_seq;
|
||
uint8_t type;
|
||
bool used;
|
||
uint8_t path[MAX_PATH_SIZE];
|
||
};
|
||
|
||
struct RecentlyHeardName {
|
||
uint8_t pubkey_prefix[7];
|
||
char name[32];
|
||
uint32_t recv_seq;
|
||
uint8_t type;
|
||
};
|
||
|
||
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);
|
||
|
||
// Keep WadaMesh's queued text packets intact around the pinned core's
|
||
// sendMessage/sendCommandData implementations. That core currently drops
|
||
// every queued TXT before enqueueing a new one; these wrappers restore FIFO
|
||
// behavior and let MyMesh replace only the same logical private message.
|
||
int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt,
|
||
const char* text, uint32_t& expected_ack, uint32_t& est_timeout,
|
||
uint32_t* out_packet_hash4 = nullptr, TxtTxDebugInfo* out_dbg = nullptr);
|
||
int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt,
|
||
const char* text, uint32_t& est_timeout,
|
||
uint32_t* out_packet_hash4 = nullptr, TxtTxDebugInfo* out_dbg = nullptr);
|
||
|
||
const char *getNodeName();
|
||
NodePrefs *getNodePrefs();
|
||
uint32_t getBLEPin();
|
||
bool setBLEPin(uint32_t pin); // user-chosen 6-digit pairing code (persisted; applies next boot)
|
||
uint32_t getOrphanedBlobs() const { return _orphaned_blobs; } // see PENDING_DEL_MAX (#222)
|
||
DataStore* getStore() { return _store; } // for the About diagnostics
|
||
|
||
// Live device info accessors (used by the touch Settings → Device modal to
|
||
// mirror the web client's "Device (live)" panel — public key prefix, channel
|
||
// count, etc.). self_id and num_channels live in `protected:` of the base
|
||
// classes, so we expose narrow getters here.
|
||
const uint8_t *getSelfPubKey() const { return self_id.pub_key; }
|
||
/** Export this node's 64-byte private key (settings backup). */
|
||
size_t uiExportPrivKey(uint8_t dest[64]) { return self_id.writeTo(dest, 64); }
|
||
/** Import a 64-byte private key from a settings backup: validate, persist,
|
||
* swap the active identity, reload contacts (invalidate shared secrets).
|
||
* Returns false on an invalid key or storage failure. */
|
||
bool uiImportPrivKey(const uint8_t src[64]) {
|
||
if (!mesh::LocalIdentity::validatePrivateKey(src)) return false;
|
||
mesh::LocalIdentity identity;
|
||
identity.readFrom(src, 64);
|
||
if (!_store->saveMainIdentity(identity)) return false;
|
||
self_id = identity;
|
||
resetContacts();
|
||
_store->loadContacts(this);
|
||
return true;
|
||
}
|
||
/** Add/merge a contact from a settings backup (name + pubkey + flags + GPS).
|
||
* Returns false if the slot table is full. Mirrors how addContact works. */
|
||
bool uiAddContactFromBackup(const uint8_t pub_key[32], const char* name, uint8_t type,
|
||
uint8_t flags, int32_t lat, int32_t lon,
|
||
uint32_t last_advert, uint32_t lastmod) {
|
||
ContactInfo c{};
|
||
memcpy(c.id.pub_key, pub_key, 32);
|
||
c.type = type; c.flags = flags;
|
||
c.gps_lat = lat; c.gps_lon = lon;
|
||
c.last_advert_timestamp = last_advert; c.lastmod = lastmod;
|
||
c.out_path_len = OUT_PATH_UNKNOWN;
|
||
strncpy(c.name, name ? name : "", sizeof(c.name) - 1);
|
||
return addContact(c);
|
||
}
|
||
// Channel count by iterating slots — `num_channels` is private in the base.
|
||
// A channel slot is in-use when its name is non-empty (matches the lookup
|
||
// pattern used elsewhere in the firmware).
|
||
int getNumChannels() {
|
||
#ifdef MAX_GROUP_CHANNELS
|
||
int n = 0;
|
||
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
||
ChannelDetails cd{};
|
||
if (getChannel(i, cd) && cd.name[0] != '\0') ++n;
|
||
}
|
||
return n;
|
||
#else
|
||
return 0;
|
||
#endif
|
||
}
|
||
|
||
void loop();
|
||
void handleCmdFrame(size_t len);
|
||
|
||
// ---- Settings backup (MeshCore-app-compatible JSON) ----
|
||
/** Write the full backup JSON (name, public/private key, radio + position
|
||
* settings, channels, contacts) to `out`. Matches the stock MeshCore app
|
||
* export shape so the file opens in the app / web client. */
|
||
void uiExportBackup(Print& out, double node_lat, double node_lon);
|
||
/** Apply a parsed backup. `sections` bit flags choose what to apply:
|
||
* bit0=identity(name+key) bit1=radio bit2=position bit3=channels bit4=contacts.
|
||
* `replace_*` clears existing channels/contacts first. Returns false on a
|
||
* malformed document. Fills counts (channels/contacts applied) if non-null. */
|
||
bool uiImportBackup(Stream& in, uint8_t sections,
|
||
bool replace_channels, bool replace_contacts,
|
||
int* out_channels, int* out_contacts);
|
||
/** Factory reset: wipe ALL persistent data — formats the internal filesystem
|
||
* (identity, contacts, channels, saved messages, exported backups) and erases
|
||
* NVS (Wi-Fi credentials + every setting). The caller MUST reboot immediately
|
||
* after; a fresh identity is generated on the next boot. Returns false if the
|
||
* format/erase failed. */
|
||
bool uiFactoryReset() { return _store->formatFileSystem(); }
|
||
bool advert();
|
||
/** Send advert with explicit routing: true = flood (multi-hop), false = zero-hop.
|
||
* `advert()` (zero-hop) is kept for backward compatibility. */
|
||
bool sendAdvert(bool flood);
|
||
void enterCLIRescue();
|
||
|
||
int getRecentlyHeard(RecentlyHeardName dest[], int max_num);
|
||
bool uiIsMeshcomodRecipient(const uint8_t* pub_key_prefix_6) const {
|
||
return isMeshcomodRecipient(pub_key_prefix_6);
|
||
}
|
||
|
||
// On-device terminal: register an output sink (nullptr = off) and run a local
|
||
// CLI command. Replies flow through pushMeshcomodReply -> the sink.
|
||
static void setTerminalSink(void (*cb)(const char* line));
|
||
void runLocalCli(const char* cmd);
|
||
|
||
#if defined(ESP32) && (defined(WIFI_SSID) || defined(MULTI_TRANSPORT_COMPANION))
|
||
/** HTTP OTA: one UTF-8 line as PUSH_CODE_BINARY_RESPONSE (ESP32Board → meshcoreRepeaterTcpOtaEmitLine). */
|
||
void pushCompanionOtaProgressLine(const char* line);
|
||
#endif
|
||
|
||
protected:
|
||
float getAirtimeBudgetFactor() const override;
|
||
int getInterferenceThreshold() const override;
|
||
int calcRxDelay(float score, uint32_t air_time) const override;
|
||
uint8_t getExtraAckTransmitCount() const override;
|
||
uint8_t getAutoAddMaxHops() const override;
|
||
bool filterRecvFloodPacket(mesh::Packet* packet) override;
|
||
bool allowPacketForward(const mesh::Packet* packet) override;
|
||
|
||
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
|
||
// Meshcomod: synthetic local command contact (no RF)
|
||
static const uint8_t MESHCOMOD_PUB_KEY_PREFIX[6];
|
||
void getMeshcomodContact(ContactInfo& dest);
|
||
bool isMeshcomodRecipient(const uint8_t* pub_key_prefix_6) const;
|
||
bool handleMeshcomodCommand(const char* text, int text_len);
|
||
void pushMeshcomodReply(const char* text, bool immediate_current = false);
|
||
|
||
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;
|
||
void logTx(mesh::Packet* packet, int len) override;
|
||
void logTxFail(mesh::Packet* packet, int len) override;
|
||
bool isAutoAddEnabled() const override;
|
||
bool shouldAutoAddContactType(uint8_t type) const override;
|
||
bool shouldOverwriteWhenFull() const override;
|
||
void onContactsFull() override;
|
||
void onContactOverwrite(const uint8_t* pub_key) override;
|
||
// Blob deletes queued by onContactOverwrite and drained from loop() — never
|
||
// from packet handling, see #222 (SPIFFS GC stalls both cores).
|
||
// 8 was too shallow: a drop here does not just skip work, it ORPHANS the blob file
|
||
// permanently (nothing else ever deletes it), and orphans accumulate on the one
|
||
// resource that drives GC cost — how full the volume is. 32 costs 1 KB of RAM and
|
||
// needs a 16-second eviction burst to overflow.
|
||
static const uint8_t PENDING_DEL_MAX = 32;
|
||
uint8_t _pending_del[PENDING_DEL_MAX][PUB_KEY_SIZE];
|
||
uint8_t _pending_del_n = 0;
|
||
uint32_t _next_pending_del_at = 0;
|
||
uint16_t _orphaned_blobs = 0; // queue overflows — each one leaks a blob file for good
|
||
void drainPendingBlobDeletes();
|
||
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 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;
|
||
// An UNCONFIGURED channel slot is all zeroes — including its secret, its hash
|
||
// and its name. Both core lookups treat such a slot as a real channel, which
|
||
// is what produced the "#unknown" threads (#260): a device that transmits on
|
||
// an unconfigured slot encrypts with an all-zero key, and every receiver with
|
||
// a spare slot decrypts it successfully against that same all-zero key, then
|
||
// resolves the name to "" and files the message under a nameless thread.
|
||
// Treat "has a non-zero secret" as the definition of a real channel.
|
||
static bool channelSlotConfigured(const ChannelDetails& cd) {
|
||
for (size_t i = 0; i < sizeof(cd.channel.secret); i++) if (cd.channel.secret[i]) return true;
|
||
return false;
|
||
}
|
||
/** findChannelIdx() that ignores unconfigured slots. -1 = not one of ours. */
|
||
int findConfiguredChannelIdx(const mesh::GroupChannel& ch) const;
|
||
/** Decrypt candidates for a received group packet, unconfigured slots excluded.
|
||
* Also stops empty slots (hash byte 0x00) from filling the 4-entry candidate
|
||
* array and starving out a REAL channel whose hash byte happens to be 0x00 —
|
||
* that one silently dropped the message instead of misfiling it. */
|
||
int searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel dest[], int max_matches) override;
|
||
void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
|
||
const char *text) 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;
|
||
|
||
// 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 = 0;
|
||
}
|
||
|
||
public:
|
||
/** Which kind of touch-UI request is currently in flight, so the response
|
||
* matcher in onContactResponse routes to the right callback. */
|
||
enum class UiReqKind : uint8_t { None = 0, Status = 1, Telemetry = 2 };
|
||
|
||
/** Fire a REQ_TYPE_GET_STATUS from the touch UI side. Result is delivered
|
||
* via AbstractUITask::onPingReply when the reply arrives.
|
||
* Returns the MSG_SEND_* result code. */
|
||
int sendStatusPingForUI(ContactInfo& recipient) {
|
||
uint32_t tag = 0, est = 0;
|
||
/* Clear any stale serial-side status pending so the legacy match doesn't
|
||
* eat our reply before the UI hook sees it. */
|
||
pending_status = 0;
|
||
int r = sendRequest(recipient, REQ_TYPE_GET_STATUS, tag, est);
|
||
if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) {
|
||
memcpy(&_ui_pending_status, recipient.id.pub_key, 4);
|
||
_ui_pending_kind = UiReqKind::Status;
|
||
_ui_pending_tag = tag; // request tag, reflected by the repeater
|
||
}
|
||
return r;
|
||
}
|
||
|
||
/** Does a blank-password LOGIN mean anything to this contact?
|
||
*
|
||
* Only to a node that keeps an ACL: repeaters, room servers and sensors.
|
||
* A plain chat contact is another companion, which has no LOGIN handler at
|
||
* all. Chaining one in front of a request there either wastes a packet (the
|
||
* fire-and-forget helpers below) or, in the deferred path, arms a wait for a
|
||
* LOGIN-OK that can never arrive -- so the request is never sent and the UI
|
||
* reports a failure.
|
||
*
|
||
* That is why telemetry and position for a chat contact worked from the
|
||
* phone app, which sends the request straight out, and never worked from the
|
||
* device (#293). The map went stale with it: a contact's position is
|
||
* refreshed from the CayenneLPP GPS field in a telemetry reply, and there
|
||
* was no reply. */
|
||
static bool contactNeedsGuestLogin(const ContactInfo& c) {
|
||
return c.type == ADV_TYPE_REPEATER || c.type == ADV_TYPE_ROOM ||
|
||
c.type == ADV_TYPE_SENSOR;
|
||
}
|
||
|
||
/** Same as sendStatusPingForUI, but sends an unauthenticated guest LOGIN
|
||
* first. Repeaters need the sender to be in their ACL before they will
|
||
* decrypt a PAYLOAD_TYPE_REQ; the ACL is only populated by handleLoginReq.
|
||
* An empty-password sendLogin matches repeaters whose guest_password is
|
||
* blank (the typical out-of-box default) and adds us as a guest. The
|
||
* dispatcher serializes outbound TX, so the LOGIN ANON_REQ reaches the
|
||
* destination before the STATUS REQ that follows.
|
||
* No-op if the LOGIN packet pool is empty; falls through to send the
|
||
* STATUS REQ anyway so a repeater that already knows us still replies. */
|
||
int sendStatusPingWithGuestLoginForUI(ContactInfo& recipient) {
|
||
if (contactNeedsGuestLogin(recipient)) {
|
||
uint32_t login_est = 0;
|
||
sendLogin(recipient, "", login_est);
|
||
}
|
||
return sendStatusPingForUI(recipient);
|
||
}
|
||
|
||
/** Same chained-login flavour for telemetry — repeaters and sensors also
|
||
* require ACL membership for REQ_TYPE_GET_TELEMETRY_DATA. */
|
||
int sendTelemetryRequestWithGuestLoginForUI(ContactInfo& recipient) {
|
||
if (contactNeedsGuestLogin(recipient)) {
|
||
uint32_t login_est = 0;
|
||
sendLogin(recipient, "", login_est);
|
||
}
|
||
return sendTelemetryRequestForUI(recipient);
|
||
}
|
||
|
||
/** Interactive logins must rediscover the route. MeshCore 1.16 repeaters
|
||
* answer a direct login by flooding the response without refreshing their
|
||
* return path; a flooded login returns PATH + LOGIN_OK and refreshes both
|
||
* sides before the deferred request or first admin command is sent. */
|
||
void uiResetPathForLogin(ContactInfo& recipient) {
|
||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) return;
|
||
uiResetContactPath(recipient.id.pub_key);
|
||
recipient.out_path_len = OUT_PATH_UNKNOWN;
|
||
memset(recipient.out_path, 0, sizeof(recipient.out_path));
|
||
}
|
||
|
||
/** Touch-UI manual STATUS/TELEMETRY request that DEFERS the REQ until the
|
||
* guest LOGIN is acknowledged. The chained helpers above fire LOGIN and REQ
|
||
* back-to-back, but a repeater drops a PAYLOAD_TYPE_REQ from a sender it
|
||
* hasn't ACL'd yet — and on first contact the ACL entry isn't committed by
|
||
* the time the REQ is processed, so the first request usually gets no reply
|
||
* (the user had to tap twice). This sends ONLY the blank-password LOGIN now
|
||
* and arms _ui_login_then; onContactResponse issues the REQ once the
|
||
* LOGIN-OK lands, by which point we're in the ACL and a direct out_path has
|
||
* been learned, so the REQ decrypts on the first try.
|
||
* Returns the LOGIN's MSG_SEND_* result — the UI shows "requesting…" on a
|
||
* successful send and arms its own reply deadline. Manual paths only;
|
||
* auto-poll keeps the immediate chained send above (a single arm slot can't
|
||
* serve its multi-node loop, and a missed poll just retries next interval). */
|
||
int uiSendRequestAfterGuestLogin(ContactInfo& recipient, UiReqKind kind) {
|
||
// Nothing to defer behind on a companion: send the request itself, now.
|
||
if (!contactNeedsGuestLogin(recipient)) {
|
||
cancelUIDeferredLogin();
|
||
return (kind == UiReqKind::Telemetry) ? sendTelemetryRequestForUI(recipient)
|
||
: sendStatusPingForUI(recipient);
|
||
}
|
||
uiResetPathForLogin(recipient);
|
||
uint32_t login_est = 0;
|
||
int r = sendLogin(recipient, "", login_est);
|
||
if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) {
|
||
memcpy(&_ui_login_then, recipient.id.pub_key, 4);
|
||
_ui_login_then_kind = kind;
|
||
} else {
|
||
cancelUIDeferredLogin();
|
||
}
|
||
return r;
|
||
}
|
||
|
||
/** Admin login for the touch UI repeater admin console.
|
||
* Sends a sendLogin with the given password (empty = guest), records
|
||
* pending_login so onContactResponse's existing login branch can route
|
||
* the response. The same branch now also fires
|
||
* AbstractUITask::onAdminLoginResult so the UI can flip from "logging
|
||
* in…" to "logged in" (or "failed"). */
|
||
int uiSendAdminLogin(ContactInfo& recipient, const char* password) {
|
||
uiResetPathForLogin(recipient);
|
||
uint32_t est = 0;
|
||
int r = sendLogin(recipient, password ? password : "", est);
|
||
if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) {
|
||
memcpy(&pending_login, recipient.id.pub_key, 4);
|
||
}
|
||
// Diagnostic (room-server login trace): which contact/type we sent the login
|
||
// to and the send result. Pair with the "[ROOM] login resp" line in
|
||
// onContactResponse to see exactly where a room login breaks.
|
||
WIRE_DBG("[ROOM] login send '%s' type=%u pwlen=%u sync_since=%lu -> r=%d\n",
|
||
recipient.name, (unsigned)recipient.type,
|
||
(unsigned)(password ? strlen(password) : 0),
|
||
(unsigned long)recipient.sync_since, r);
|
||
return r;
|
||
}
|
||
|
||
/** Is a keep-alive session to this server currently armed and alive?
|
||
* (Room chat UI: the core frees the slot 2.5x the interval after the last
|
||
* ACK/activity, so false = the session needs a re-login.) */
|
||
bool uiHasConnectionTo(const uint8_t pub_key[32]) { return hasConnectionTo(pub_key); }
|
||
|
||
/** Re-login to a room server WITHOUT a password. For a client already in the
|
||
* room's ACL a blank login replies LOGIN_OK with the stored permissions and
|
||
* skips the login replay guard (simple_room_server onAnonDataRecv, blank-
|
||
* password branch) — and when it arrives flooded the server also resets its
|
||
* stale out_path to us. The blank login itself resets NOTHING else server-side
|
||
* (not push_failures, not last_activity); the actual heal is the chain it
|
||
* starts: LOGIN_OK -> onContactResponse arms the room keep-alive -> the first
|
||
* REQ_TYPE_KEEP_ALIVE refreshes the server's last_activity and clears its
|
||
* push-abandon counter, so pushes resume (issue #89: self-heal + the chat
|
||
* sheet's "Log in again"). NOTE it cannot recover a server that REBOOTED
|
||
* (non-admin ACL entries aren't persisted there) — that needs a passworded
|
||
* Join, which the UI escalates to when this gets no LOGIN_OK (#267). */
|
||
int uiRoomRelogin(const uint8_t pub_key[32]) {
|
||
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||
if (!c || c->type != ADV_TYPE_ROOM) return MSG_SEND_FAILED;
|
||
return uiSendAdminLogin(*c, "");
|
||
}
|
||
|
||
/** Contact-table INDEX for a public key, or -1. lookupContactByPubKey returns
|
||
* the record; the touch UI's room-join path needs the index instead, because
|
||
* that is what openMeshContactDm() takes (#267). */
|
||
int uiContactIdxByPubKey(const uint8_t pub_key[32]) {
|
||
const uint32_t n = getNumContacts();
|
||
for (uint32_t i = 0; i < n; ++i) {
|
||
ContactInfo c;
|
||
if (getContactByIdx(i, c) && memcmp(c.id.pub_key, pub_key, PUB_KEY_SIZE) == 0) return (int)i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/** Send a CLI command line to a previously-logged-in repeater / room
|
||
* server. The reply lands in AbstractUITask::onAdminCommandReply via
|
||
* queueMessage when the server returns a TXT_TYPE_CLI_DATA frame.
|
||
* No-op if recipient isn't in our contacts; returns MSG_SEND_FAILED. */
|
||
int uiSendAdminCommand(ContactInfo& recipient, const char* text) {
|
||
if (!text || !text[0]) return MSG_SEND_FAILED;
|
||
uint32_t est = 0;
|
||
uint32_t ts = getRTCClock()->getCurrentTimeUnique();
|
||
return sendCommandData(recipient, ts, 0, text, est);
|
||
}
|
||
/** Cancel UI ping pending state (used by timeout). */
|
||
void cancelUIPingPending() {
|
||
_ui_pending_status = 0;
|
||
_ui_pending_kind = UiReqKind::None;
|
||
_ui_pending_tag = 0;
|
||
}
|
||
/** Disarm a deferred guest-login-then-request (see uiSendRequestAfterGuestLogin)
|
||
* so a late LOGIN-OK can't fire a REQ after the UI gave up. Kept separate from
|
||
* cancelUIPingPending() so a ping timeout never disarms a telemetry request. */
|
||
void cancelUIDeferredLogin() {
|
||
_ui_login_then = 0;
|
||
_ui_login_then_kind = UiReqKind::None;
|
||
}
|
||
/** True if a UI ping is still waiting on a reply. */
|
||
bool hasUIPingPending() const { return _ui_pending_status != 0; }
|
||
|
||
/** Register an expected ACK hash that came out of a touch-UI sendMessage
|
||
* call, so MyMesh::processAck can match the inbound ACK and dispatch
|
||
* onMessageAcked back to the UI. The companion-serial CMD_SEND_TXT_MSG
|
||
* handler already does this for app-originated messages; the touch UI
|
||
* reaches sendMessage directly and skipped this until now. */
|
||
void uiRegisterExpectedAck(uint32_t expected_ack, const uint8_t pub_key[32]);
|
||
|
||
// ---- "Repeats heard" for sent floods + route of the last received flood ----
|
||
// When we originate a flood TXT, repeaters re-broadcast it and our own radio
|
||
// hears the echoes (same payload, longer path). We fingerprint the payload at
|
||
// send time (sendFloodScoped) and match echoes in logRxRaw, counting repeats
|
||
// per recent send. The touch UI stamps its outgoing bubble with the
|
||
// fingerprint (uiLastSentFp) and later reads the count (uiRepeatsForFp).
|
||
// _last_rx_path holds the path hashes of the just-received flood so the UI's
|
||
// newMsg* handler (called synchronously next) can stash the inbound route.
|
||
static const int UI_ECHO_SLOTS = 12;
|
||
uint32_t _echo_fp[UI_ECHO_SLOTS] = {0};
|
||
uint8_t _echo_rep[UI_ECHO_SLOTS] = {0};
|
||
static const int ECHO_MAX_HOPS = 3; // repeaters remembered per echoed send
|
||
uint8_t _echo_hop[UI_ECHO_SLOTS][ECHO_MAX_HOPS][4] = {}; // last-hop hash of each distinct echo
|
||
uint8_t _echo_hop_sz[UI_ECHO_SLOTS] = {0}; // hash size used for those hops
|
||
uint8_t _echo_hop_n[UI_ECHO_SLOTS] = {0}; // count, 0..ECHO_MAX_HOPS
|
||
uint8_t _echo_idx = 0;
|
||
uint32_t _last_sent_fp = 0;
|
||
uint8_t _last_rx_path[32] = {0};
|
||
uint8_t _last_rx_path_n = 0;
|
||
uint16_t _last_rx_scope = 0; // transport_codes[0] of the last RX flood ("scope")
|
||
bool _last_rx_scope_home = false; // that code verified against OUR region key (#259)
|
||
// Which REGISTERED region that code verified against (#271): a stable slot,
|
||
// REGION_SLOT_AMBIGUOUS when several matched, or REGION_SLOT_NONE. Widens the
|
||
// one-key _last_rx_scope_home check above to every region the user tracks.
|
||
uint8_t _last_rx_scope_slot = REGION_SLOT_NONE;
|
||
RegionRegistry _region_reg;
|
||
bool _last_rx_has_scope = false; // false if the packet carried no transport codes
|
||
uint32_t _last_sender_ts = 0; // embedded send-time of the last inbound msg (UI bubble ts; 0 = use now)
|
||
volatile bool _echo_dirty = false; // a repeat was counted -> UI should refresh
|
||
|
||
// ---- Live signal strength (top-bar icon) ----
|
||
// Updated on EVERY received packet in logRxRaw(): the SNR (×4) + millis() at RX.
|
||
// The UI maps it to bars and dims them when nothing's been heard for a while.
|
||
int8_t _ui_sig_snr_q4 = -128; // SNR_dB * 4 of the last RX (-128 = nothing yet)
|
||
int8_t _ui_sig_rssi = 0; // RSSI dBm of the last RX
|
||
uint32_t _ui_sig_ms = 0; // millis() at that RX (0 = nothing heard yet)
|
||
int8_t uiSignalSnrQ4() const { return _ui_sig_snr_q4; }
|
||
int8_t uiSignalRssi() const { return _ui_sig_rssi; }
|
||
uint32_t uiSignalMs() const { return _ui_sig_ms; }
|
||
|
||
// ---- Discover scan (the Discover app: active node-discovery, ALL node types) ----
|
||
// uiStartDiscoverScan() broadcasts a zero-hop NODE_DISCOVER_REQ with a type filter + a fresh
|
||
// random tag; EVERY neighbour that answers with a NODE_DISCOVER_RESP is upserted here (keyed by
|
||
// pubkey prefix) from onControlDataRecv. Unlike the single-scalar signal probe, every responder
|
||
// is kept, with BOTH link directions (our RX of them + their RX of us, from RESP payload[1]).
|
||
struct DiscoverHit {
|
||
uint8_t pubkey[32]; // responder identity (full key — the REQ sets prefix_only=0)
|
||
uint8_t node_type; // ADV_TYPE_* (RESP payload[0] low nibble): repeater/chat/room/sensor
|
||
int8_t our_snr_q4; // our RX SNR*4 of their reply (forward link)
|
||
int8_t our_rssi; // our RX RSSI dBm of their reply
|
||
int8_t their_snr_q4; // their RX SNR*4 of our request (reverse link — RESP payload[1])
|
||
uint8_t path_len; // hops the reply travelled (0 = heard directly, i.e. in RF range)
|
||
uint32_t first_ms; // millis() first heard this session
|
||
uint32_t last_ms; // millis() last heard
|
||
uint16_t heard; // reply count
|
||
};
|
||
static const int DISCOVER_MAX = 64;
|
||
DiscoverHit _discover[DISCOVER_MAX];
|
||
uint8_t _discover_cnt = 0;
|
||
uint32_t _discover_tag = 0; // active scan tag (matches RESPs; 0 = no scan yet)
|
||
uint32_t _discover_scan_ms = 0; // millis() the current scan sweep was fired
|
||
uint8_t discoverCount() const { return _discover_cnt; }
|
||
bool discoverGet(uint8_t i, DiscoverHit& out) const {
|
||
if (i >= _discover_cnt) return false; out = _discover[i]; return true;
|
||
}
|
||
void discoverClear() { _discover_cnt = 0; }
|
||
uint32_t discoverScanMs() const { return _discover_scan_ms; }
|
||
void discoverUpsert(const uint8_t* pk, uint8_t pklen, uint8_t node_type,
|
||
int8_t our_snr_q4, int8_t our_rssi, int8_t their_snr_q4, uint8_t path_len);
|
||
|
||
// ---- Recent-RX ring (RF Monitor app) ----
|
||
// One record per received frame, captured in logRxRaw(): payload type / route
|
||
// / hop count / length + signal, so the Monitor page can show a live "what am
|
||
// I hearing" feed without a core hook. Newest-first via uiRxLogGet (i=0 = most
|
||
// recent). Board-independent — populated on every board, not just touch.
|
||
struct UiRxRec {
|
||
uint32_t ms; // millis() at RX
|
||
int8_t rssi; // dBm
|
||
int8_t snr_q4; // SNR_dB * 4
|
||
uint8_t ptype; // payload type (raw[0]>>2)&0x0F
|
||
uint8_t route; // route type raw[0]&0x03
|
||
uint8_t hops; // path length carried (0 = heard direct from origin)
|
||
uint8_t len; // frame length (clamped to 255)
|
||
// Who sent it, as far as the frame actually says. MeshCore only carries a
|
||
// full identity on an ADVERT (the payload opens with the 32-byte public
|
||
// key); the addressed types carry one-byte destination/source hashes, and
|
||
// the rest carry nothing at all. Recorded honestly rather than guessed, so
|
||
// a caller can tell "node X was here" from "something was here".
|
||
// org_kind 0 = nothing identifying in this frame
|
||
// 1 = org[0..3] is the first 4 bytes of the origin's public key
|
||
// 2 = org[0] is the destination hash, org[1] the source hash
|
||
uint8_t org_kind;
|
||
uint8_t org[4];
|
||
};
|
||
static const int UI_RXLOG_MAX = 16;
|
||
UiRxRec _ui_rxlog[UI_RXLOG_MAX];
|
||
uint8_t _ui_rxlog_head = 0; // next write slot
|
||
uint8_t _ui_rxlog_cnt = 0; // valid entries (<= UI_RXLOG_MAX)
|
||
uint8_t uiRxLogCount() const { return _ui_rxlog_cnt; }
|
||
bool uiRxLogGet(uint8_t i, UiRxRec& out) const {
|
||
if (i >= _ui_rxlog_cnt) return false;
|
||
uint8_t idx = (uint8_t)((_ui_rxlog_head + UI_RXLOG_MAX - 1 - i) % UI_RXLOG_MAX);
|
||
out = _ui_rxlog[idx];
|
||
return true;
|
||
}
|
||
// Record a reception into the ring (called from logRxRaw).
|
||
void uiRxLogPush(uint32_t ms, int8_t rssi, int8_t snr_q4,
|
||
uint8_t ptype, uint8_t route, uint8_t hops, uint8_t len,
|
||
uint8_t org_kind = 0, const uint8_t* org = nullptr) {
|
||
UiRxRec& r = _ui_rxlog[_ui_rxlog_head];
|
||
r.ms = ms; r.rssi = rssi; r.snr_q4 = snr_q4;
|
||
r.ptype = ptype; r.route = route; r.hops = hops; r.len = len;
|
||
r.org_kind = org ? org_kind : 0;
|
||
if (r.org_kind) memcpy(r.org, org, 4); else memset(r.org, 0, 4);
|
||
_ui_rxlog_head = (uint8_t)((_ui_rxlog_head + 1) % UI_RXLOG_MAX);
|
||
if (_ui_rxlog_cnt < UI_RXLOG_MAX) _ui_rxlog_cnt++;
|
||
}
|
||
|
||
/** Fingerprint of the most-recently originated flood TXT payload (0 if none). */
|
||
uint32_t uiLastSentFp() const { return _last_sent_fp; }
|
||
|
||
/** Echoes (repeater re-broadcasts) heard of the flood TXT with this payload
|
||
* fingerprint. 0 if unknown / evicted from the ring. */
|
||
uint8_t uiRepeatsForFp(uint32_t fp) const {
|
||
if (fp == 0) return 0;
|
||
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) return _echo_rep[i];
|
||
return 0;
|
||
}
|
||
|
||
/** How many distinct repeaters we captured echoing this sent fingerprint. */
|
||
uint8_t uiRepeatHopCount(uint32_t fp) const {
|
||
if (fp == 0) return 0;
|
||
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) return _echo_hop_n[i];
|
||
return 0;
|
||
}
|
||
/** Copy the idx-th captured repeater hash for `fp` into out[] (needs >= 4 bytes);
|
||
* returns the hash size (0 if none). Bounded, read-only. */
|
||
uint8_t uiRepeatHop(uint32_t fp, uint8_t idx, uint8_t* out, uint8_t out_sz) const {
|
||
if (fp == 0 || !out) return 0;
|
||
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) {
|
||
if (idx >= _echo_hop_n[i]) return 0;
|
||
uint8_t sz = _echo_hop_sz[i]; if (sz > out_sz) sz = out_sz; if (sz > 4) sz = 4;
|
||
memcpy(out, _echo_hop[i][idx], sz);
|
||
return sz;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/** Path (repeater hashes) of the most-recently received flood; copies up to
|
||
* `max` bytes into buf, returns the count. Read synchronously from the
|
||
* newMsg* handler that follows reception. */
|
||
uint8_t lastRxPath(uint8_t* buf, uint8_t max) const {
|
||
uint8_t n = _last_rx_path_n < max ? _last_rx_path_n : max;
|
||
if (buf && n) memcpy(buf, _last_rx_path, n);
|
||
return n;
|
||
}
|
||
/** Scope (transport_codes[0]) of the last received flood; *has = false when
|
||
* the packet carried no transport codes. */
|
||
uint16_t lastRxScope(bool* has) const { if (has) *has = _last_rx_has_scope; return _last_rx_scope; }
|
||
/** Consume the embedded send-time stashed right before the last UI notify (room
|
||
* history replay carries old send-times; without this the UI stamps "now").
|
||
* Returns 0 when nothing was stashed -> the caller keeps the delivery time. */
|
||
uint32_t uiConsumeLastSenderTs() { uint32_t t = _last_sender_ts; _last_sender_ts = 0; return t; }
|
||
/** Capture route + scope of a just-received flood for the Info popup. Call
|
||
* synchronously right before the newMsg* notification. */
|
||
void uiStashRxMeta(mesh::Packet* pkt) {
|
||
_last_rx_path_n = 0;
|
||
if (pkt && pkt->isRouteFlood()) {
|
||
int nb = (int)pkt->getPathHashCount() * (int)pkt->getPathHashSize();
|
||
if (nb > (int)sizeof(_last_rx_path)) nb = (int)sizeof(_last_rx_path);
|
||
if (nb > 0) { memcpy(_last_rx_path, pkt->path, nb); _last_rx_path_n = (uint8_t)nb; }
|
||
}
|
||
const uint8_t rt = pkt ? pkt->getRouteType() : 0xFF;
|
||
_last_rx_has_scope = (rt == ROUTE_TYPE_TRANSPORT_FLOOD || rt == ROUTE_TYPE_TRANSPORT_DIRECT);
|
||
// #157: some senders carry the region in transport_codes[1] (reply-region hint) with
|
||
// codes[0] zero -- the Info popup then showed "Scope 0000" for a genuinely scoped message.
|
||
// Show whichever code is set; [0] (the scope proper) wins when both are.
|
||
_last_rx_scope = 0;
|
||
_last_rx_scope_home = false;
|
||
_last_rx_scope_slot = REGION_SLOT_NONE;
|
||
if (_last_rx_has_scope && pkt) {
|
||
_last_rx_scope = pkt->transport_codes[0] ? pkt->transport_codes[0] : pkt->transport_codes[1];
|
||
// The code is HMAC(region key, payload) truncated to 16 bits — per-PACKET,
|
||
// not a region id, which is why the same sender in the same region shows a
|
||
// different value on every message (#259). It can still be VERIFIED: recompute
|
||
// it with our own region key and see if it matches. That answers the only
|
||
// question a reader actually has ("was this scoped to my region?") and must
|
||
// happen here, while the packet is still in hand.
|
||
TransportKey home;
|
||
memcpy(&home.key, _prefs.default_scope_key, sizeof(home.key));
|
||
if (!home.isNull() && pkt->transport_codes[0])
|
||
_last_rx_scope_home = (home.calcTransportCode(pkt) == pkt->transport_codes[0]);
|
||
// #271: same verification, widened to every region the user has registered,
|
||
// so the Info popup can name WHICH one instead of only "mine / not mine".
|
||
// Only codes[0] is the forwarding scope; codes[1] is a reply-region hint and
|
||
// is reported separately, so match strictly against [0] and never against
|
||
// the [1] fallback that _last_rx_scope may be holding.
|
||
_last_rx_scope_slot = pkt->transport_codes[0]
|
||
? _region_reg.matchPacket(pkt, pkt->transport_codes[0])
|
||
: REGION_SLOT_NONE;
|
||
}
|
||
}
|
||
/** Region registry backing the scope naming above (#271). */
|
||
RegionRegistry& regionRegistry() { return _region_reg; }
|
||
/** Stable slot of the region the last RX scope verified against, or
|
||
* REGION_SLOT_AMBIGUOUS / REGION_SLOT_NONE. */
|
||
uint8_t lastRxScopeSlot() const { return _last_rx_scope_slot; }
|
||
/** True when the last RX flood's scope verified against our own region key. */
|
||
bool lastRxScopeIsHome() const { return _last_rx_scope_home; }
|
||
|
||
/** Track a freshly-sent flood TXT fingerprint (called from sendFloodScoped). */
|
||
void uiTrackSentFp(uint32_t fp) {
|
||
if (fp == 0) return;
|
||
_last_sent_fp = fp;
|
||
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) { _echo_rep[i] = 0; _echo_hop_n[i] = 0; return; }
|
||
_echo_fp[_echo_idx] = fp; _echo_rep[_echo_idx] = 0; _echo_hop_n[_echo_idx] = 0;
|
||
_echo_idx = (uint8_t)((_echo_idx + 1) % UI_ECHO_SLOTS);
|
||
}
|
||
|
||
/** Count one echo of fingerprint `fp` (called from logRxRaw on a match). `hop`
|
||
* (optional) is the re-flooding repeater's hash — the echo's last path hop —
|
||
* recorded deduped + bounded so the sent-message Info can name the repeaters. */
|
||
/** Returns true when `fp` matched one of OUR recent sends (i.e. this RX is an
|
||
* echo of our own flood) — logRxRaw uses that to let the frame through to BLE
|
||
* for the app's "Repeats heard" (issue #94). */
|
||
bool uiCountEcho(uint32_t fp, const uint8_t* hop = nullptr, uint8_t hop_sz = 0) {
|
||
for (int i = 0; i < UI_ECHO_SLOTS; i++)
|
||
if (_echo_fp[i] == fp) {
|
||
if (_echo_rep[i] < 255) _echo_rep[i]++;
|
||
_echo_dirty = true;
|
||
if (hop && hop_sz > 0 && hop_sz <= 4 && _echo_hop_n[i] < ECHO_MAX_HOPS) {
|
||
bool seen = false;
|
||
for (uint8_t k = 0; k < _echo_hop_n[i]; k++)
|
||
if (_echo_hop_sz[i] == hop_sz && memcmp(_echo_hop[i][k], hop, hop_sz) == 0) { seen = true; break; }
|
||
if (!seen) {
|
||
memcpy(_echo_hop[i][_echo_hop_n[i]], hop, hop_sz);
|
||
_echo_hop_sz[i] = hop_sz;
|
||
_echo_hop_n[i]++;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
/** True once if a repeat was counted since the last call — the UI uses this
|
||
* to refresh the chat so the bubble's repeat tag updates live. */
|
||
bool takeEchoDirty() { bool d = _echo_dirty; _echo_dirty = false; return d; }
|
||
|
||
/** UI: resolve a path-hop hash prefix to its (repeater) contact name. Bounded
|
||
* + null-safe; writes out[] NUL-terminated and returns true ONLY on a named
|
||
* match (otherwise the caller shows the bare hash). Read-only contact lookup,
|
||
* so it can't corrupt anything even if called mid-RX. */
|
||
bool uiHopName(const uint8_t* hash, int prefix_len, char* out, size_t out_sz) {
|
||
if (!out || out_sz == 0) return false;
|
||
out[0] = '\0';
|
||
if (!hash || prefix_len <= 0) return false;
|
||
ContactInfo* c = lookupContactByPubKey(hash, prefix_len);
|
||
if (!c || c->name[0] == '\0') return false;
|
||
strncpy(out, c->name, out_sz - 1);
|
||
out[out_sz - 1] = '\0';
|
||
return true;
|
||
}
|
||
|
||
/** Resolve a path-hop hash prefix to the repeater's advertised position.
|
||
* Returns true + fills lat/lon (degrees) when the contact is known AND has a
|
||
* non-zero GPS position; false otherwise. Read-only — safe from the UI. */
|
||
bool uiHopPos(const uint8_t* hash, int prefix_len, double* out_lat, double* out_lon) {
|
||
if (!hash || prefix_len <= 0) return false;
|
||
ContactInfo* c = lookupContactByPubKey(hash, prefix_len);
|
||
if (!c) return false;
|
||
if (c->gps_lat == 0 && c->gps_lon == 0) return false;
|
||
if (out_lat) *out_lat = (double)c->gps_lat / 1.0e6;
|
||
if (out_lon) *out_lon = (double)c->gps_lon / 1.0e6;
|
||
return true;
|
||
}
|
||
|
||
/** Send a 0-hop trace ping to a single neighbor (typically a repeater).
|
||
* Returns the trace tag we chose (non-zero on success) so the UI can
|
||
* match the onTracePingResult callback. The trace path is just the
|
||
* neighbor's hash; when they retransmit, our radio picks it up and the
|
||
* dispatcher fires onTraceRecv at us with the SNRs in both directions.
|
||
* Returns 0 on failure. */
|
||
uint32_t uiSendTracePing(const uint8_t pub_key[32]) {
|
||
if (!pub_key) return 0;
|
||
uint8_t hash_sz = (uint8_t)(_prefs.path_hash_mode + 1);
|
||
if (hash_sz == 0 || hash_sz > 4) hash_sz = 1;
|
||
uint8_t flags = (uint8_t)(_prefs.path_hash_mode & 0x03);
|
||
uint32_t tag = 0;
|
||
getRNG()->random((uint8_t*)&tag, sizeof(tag));
|
||
if (tag == 0) tag = 1;
|
||
// Stash so onTraceRecv can route the right callback to the UI.
|
||
_ui_trace_ping_tag = tag;
|
||
mesh::Packet* pkt = createTrace(tag, 0, flags);
|
||
if (!pkt) { _ui_trace_ping_tag = 0; return 0; }
|
||
sendDirect(pkt, pub_key, hash_sz);
|
||
return tag;
|
||
}
|
||
|
||
/** Send a FULL-ROUTE trace toward `c` along its entire known path, so every
|
||
* repeater on the way appends its RX-SNR (vs uiSendTracePing, which only
|
||
* probes the immediate neighbour). The result arrives via the same
|
||
* onTracePingResult callback, but with one SNR reading per hop. Falls back
|
||
* to a single-hop trace when the path to `c` is unknown (flood-routed
|
||
* contact — there's no fixed path to walk). Returns the tag, 0 on failure. */
|
||
uint32_t uiSendTraceRoute(const ContactInfo& c) {
|
||
uint8_t flags = (uint8_t)(_prefs.path_hash_mode & 0x03);
|
||
uint32_t tag = 0;
|
||
getRNG()->random((uint8_t*)&tag, sizeof(tag));
|
||
if (tag == 0) tag = 1;
|
||
_ui_trace_ping_tag = tag;
|
||
mesh::Packet* pkt = createTrace(tag, 0, flags);
|
||
if (!pkt) { _ui_trace_ping_tag = 0; return 0; }
|
||
if (c.out_path_len != OUT_PATH_UNKNOWN && c.out_path_len > 0 &&
|
||
c.out_path_len <= MAX_PATH_SIZE) { // clamp: a corrupt out_path_len (>64)
|
||
sendDirect(pkt, c.out_path, c.out_path_len); // would overrun sendDirect's payload memcpy -> reboot
|
||
} else {
|
||
uint8_t hash_sz = (uint8_t)(_prefs.path_hash_mode + 1);
|
||
if (hash_sz == 0 || hash_sz > 4) hash_sz = 1;
|
||
sendDirect(pkt, c.id.pub_key, hash_sz); // unknown path → single hop
|
||
}
|
||
return tag;
|
||
}
|
||
|
||
/** SIGNAL PROBE (the standard MeshCore node-discovery): broadcast a zero-hop
|
||
* NODE_DISCOVER_REQ control packet asking repeaters to answer. Each neighbouring
|
||
* repeater replies DIRECTLY with a NODE_DISCOVER_RESP — this is the exact packet the
|
||
* Ultra / KiekR GUIs use, and what repeater firmware actually answers. (A TRACE or a
|
||
* bare zero-hop advert gets no reply: repeaters don't retransmit an unpathed trace,
|
||
* so the old trace-probe read nothing — thanks to Tarmo for decoding the real packet.)
|
||
* onControlDataRecv matches _ui_sig_probe_tag on the reply and captures its SNR/RSSI
|
||
* into _ui_sig_*. Never floods (zero-hop, not forwarded). Returns the tag. */
|
||
uint32_t uiSendSignalProbe() {
|
||
uint8_t data[10];
|
||
data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // 0x80; low bit (prefix_only) stays 0
|
||
data[1] = (uint8_t)(1 << ADV_TYPE_REPEATER); // filter: ask repeaters to answer
|
||
getRNG()->random(&data[2], 4); // random tag, to match the responses to
|
||
memcpy(&_ui_sig_probe_tag, &data[2], 4);
|
||
if (_ui_sig_probe_tag == 0) { _ui_sig_probe_tag = 1; memcpy(&data[2], &_ui_sig_probe_tag, 4); }
|
||
uint32_t since = 0; // 0 = answer regardless of freshness
|
||
memcpy(&data[6], &since, 4);
|
||
mesh::Packet* pkt = createControlData(data, sizeof(data));
|
||
if (!pkt) { _ui_sig_probe_tag = 0; return 0; }
|
||
sendZeroHop(pkt); // repeaters reply directly; never floods
|
||
return _ui_sig_probe_tag;
|
||
}
|
||
|
||
/** DISCOVER SCAN (Discover app): like uiSendSignalProbe, but asks ALL node types and KEEPS
|
||
* every responder (see _discover[] + discoverUpsert, populated in onControlDataRecv). Broadcasts
|
||
* one zero-hop NODE_DISCOVER_REQ; neighbours reply DIRECTLY (never floods). type_filter = OR of
|
||
* (1<<ADV_TYPE_*); pass 0 for "all types". Returns the scan tag (0 = failed). The caller should
|
||
* airtime-gate repeated sweeps (Dispatcher::getRemainingTxBudget). */
|
||
uint32_t uiStartDiscoverScan(uint8_t type_filter = 0) {
|
||
uint8_t data[10];
|
||
data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // 0x80; low bit prefix_only=0 -> full 32-byte pubkeys
|
||
data[1] = type_filter ? type_filter
|
||
: (uint8_t)((1 << ADV_TYPE_CHAT) | (1 << ADV_TYPE_REPEATER) |
|
||
(1 << ADV_TYPE_ROOM) | (1 << ADV_TYPE_SENSOR)); // all types
|
||
getRNG()->random(&data[2], 4); // fresh random tag to match this sweep's replies
|
||
memcpy(&_discover_tag, &data[2], 4);
|
||
if (_discover_tag == 0) { _discover_tag = 1; memcpy(&data[2], &_discover_tag, 4); }
|
||
uint32_t since = 0; // 0 = answer regardless of freshness
|
||
memcpy(&data[6], &since, 4);
|
||
mesh::Packet* pkt = createControlData(data, sizeof(data));
|
||
if (!pkt) { _discover_tag = 0; return 0; }
|
||
sendZeroHop(pkt);
|
||
_discover_scan_ms = millis();
|
||
return _discover_tag;
|
||
}
|
||
|
||
/** Request CayenneLPP telemetry from a remote contact. Reply is delivered
|
||
* via AbstractUITask::onTelemetryReply with the raw LPP payload after the
|
||
* 4-byte timestamp header. Falls back to onPingReply if the UI didn't
|
||
* override the telemetry hook.
|
||
* Returns the MSG_SEND_* result code. */
|
||
int sendTelemetryRequestForUI(ContactInfo& recipient) {
|
||
uint32_t tag = 0, est = 0;
|
||
pending_telemetry = 0;
|
||
int r = sendRequest(recipient, REQ_TYPE_GET_TELEMETRY_DATA, tag, est);
|
||
if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) {
|
||
memcpy(&_ui_pending_status, recipient.id.pub_key, 4);
|
||
_ui_pending_kind = UiReqKind::Telemetry;
|
||
_ui_pending_tag = tag; // request tag, reflected by the repeater
|
||
}
|
||
return r;
|
||
}
|
||
|
||
private:
|
||
|
||
public:
|
||
bool savePrefs() { return _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
|
||
|
||
// Set the default flood scope (region) used to tag outgoing flood packets, so
|
||
// repeaters on region-scoped networks ("flood only for their region") will
|
||
// re-broadcast them. A public "#hashtag" region's scope key is SHA256("#name");
|
||
// a blank/NULL name clears it (unscoped, the default). Persists immediately.
|
||
void setDefaultFloodScope(const char* region_name);
|
||
|
||
// Opt-in: when true, direct/login/admin UNICAST floods are also tagged with the default
|
||
// region scope, so a region-scoped repeater that is your ONLY path will re-flood them.
|
||
// Default false keeps them unscoped (cross-region login/DMs work). (Issue #64.)
|
||
void setScopeDirectFloods(bool on) { scope_direct_floods = on; }
|
||
|
||
// Per-channel flood-scope override. pushChannelScope() derives a transient scope
|
||
// from "#region" (SHA256) for the NEXT channel send and returns true (the caller
|
||
// must popChannelScope() right after that send to restore); a blank/NULL name is
|
||
// a no-op returning false. Lets a channel override the default flood scope.
|
||
bool pushChannelScope(const char* region_name);
|
||
void popChannelScope();
|
||
|
||
// Re-apply the persisted radio settings (freq/bw/sf/cr, TX power, RX-boost) to
|
||
// the live radio — the same calls begin() makes at boot. Lets the UI change
|
||
// region / radio params and have them take effect immediately, no reboot.
|
||
void applyRadioFromPrefs();
|
||
|
||
/** Find the first unused channel slot, or -1 if the table is full. A slot
|
||
* is considered free when its name is empty (matches getNumChannels). */
|
||
int findFirstEmptyChannelSlot() {
|
||
#ifdef MAX_GROUP_CHANNELS
|
||
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
||
ChannelDetails cd{};
|
||
if (!getChannel(i, cd) || cd.name[0] == '\0') return i;
|
||
}
|
||
#endif
|
||
return -1;
|
||
}
|
||
|
||
/** UI-side equivalent of the CMD_SET_CHANNEL serial handler in handleCmdFrame.
|
||
* Writes channel `idx` with `name` (up to 31 chars) and a 16-byte secret,
|
||
* persists it to NVS and pokes the UI to refresh its thread list.
|
||
* Returns false if `idx` is out of range or setChannel rejects the slot. */
|
||
bool uiAddOrUpdateChannel(int idx, const char* name, const uint8_t secret16[16]) {
|
||
if (idx < 0) return false;
|
||
ChannelDetails channel{};
|
||
StrHelper::strncpy(channel.name, name, sizeof(channel.name));
|
||
memset(channel.channel.secret, 0, sizeof(channel.channel.secret));
|
||
memcpy(channel.channel.secret, secret16, 16);
|
||
if (!setChannel(idx, channel)) return false;
|
||
saveChannels();
|
||
if (_ui) _ui->onThreadsChanged();
|
||
return true;
|
||
}
|
||
|
||
/** Wipe the cached return path for a contact so the next outgoing message
|
||
* re-floods instead of routing through a stale hop list. Returns false
|
||
* when the contact isn't in the table any more. */
|
||
bool uiResetContactPath(const uint8_t pub_key[32]) {
|
||
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||
if (!slot) return false;
|
||
slot->out_path_len = OUT_PATH_UNKNOWN;
|
||
memset(slot->out_path, 0, sizeof(slot->out_path));
|
||
saveContacts();
|
||
return true;
|
||
}
|
||
|
||
/** Update a contact's stored GPS position (microdegrees, *1e6) — e.g. from a
|
||
* telemetry reply that carried a CayenneLPP GPS field. Lets contacts that
|
||
* don't flood position adverts (but do answer telemetry) appear on the map
|
||
* (issue #27). Returns false if the contact isn't in the table. */
|
||
bool uiSetContactGps(const uint8_t pub_key[32], int32_t lat_e6, int32_t lon_e6) {
|
||
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||
if (!slot) return false;
|
||
slot->gps_lat = lat_e6;
|
||
slot->gps_lon = lon_e6;
|
||
slot->lastmod = getRTCClock()->getCurrentTime();
|
||
saveContacts();
|
||
return true;
|
||
}
|
||
|
||
/** Manually add a chat-type peer to contacts[] from a raw 32-byte pubkey
|
||
* + display name. Used by the Contacts tab "+" → "Add by pubkey" flow
|
||
* when we want to talk to someone whose advert we haven't received yet.
|
||
* Returns false if the pubkey is already a contact, the name is empty,
|
||
* or the contact table is full. */
|
||
bool uiAddManualContact(const uint8_t pub_key[32], const char* name) {
|
||
if (!name || !name[0]) return false;
|
||
if (lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != nullptr) return false;
|
||
ContactInfo ci{};
|
||
memcpy(ci.id.pub_key, pub_key, PUB_KEY_SIZE);
|
||
ci.type = ADV_TYPE_CHAT;
|
||
ci.out_path_len = OUT_PATH_UNKNOWN;
|
||
ci.last_advert_timestamp = 0; // unknown — we never heard them
|
||
ci.lastmod = getRTCClock()->getCurrentTime();
|
||
StrHelper::strncpy(ci.name, name, sizeof(ci.name));
|
||
if (!addContact(ci)) return false;
|
||
saveContacts();
|
||
if (_ui) _ui->onThreadsChanged();
|
||
return true;
|
||
}
|
||
|
||
/** Add a node found by the Discover app (active NODE_DISCOVER sweep) to contacts, from its full
|
||
* 32-byte pubkey + node TYPE + a display name. Like uiAddManualContact but preserves the type
|
||
* (repeater/room/sensor/chat) so the contact lands with the right role/icon — the discovery
|
||
* response carries the type + full pubkey but no advert, so name is a placeholder until one
|
||
* arrives. Returns false if it's already a contact, the name is empty, or the table is full. */
|
||
bool uiAddDiscoveredContact(const uint8_t pub_key[32], uint8_t type, const char* name) {
|
||
if (!name || !name[0]) return false;
|
||
if (lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != nullptr) return false;
|
||
ContactInfo ci{};
|
||
memcpy(ci.id.pub_key, pub_key, PUB_KEY_SIZE);
|
||
ci.type = type;
|
||
ci.out_path_len = OUT_PATH_UNKNOWN;
|
||
ci.last_advert_timestamp = 0; // unknown — we only heard a discovery reply
|
||
ci.lastmod = getRTCClock()->getCurrentTime();
|
||
StrHelper::strncpy(ci.name, name, sizeof(ci.name));
|
||
if (!addContact(ci)) return false;
|
||
saveContacts();
|
||
if (_ui) _ui->onThreadsChanged();
|
||
return true;
|
||
}
|
||
|
||
/** Persist the in-RAM contact table to flash (/contacts3). Public wrapper so
|
||
* UI paths that insert via the base addContact() — e.g. the Discovered-list
|
||
* "Add to contacts" button — can persist; otherwise that contact is RAM-only
|
||
* and lost on reboot. */
|
||
bool uiPersistContacts() { saveContacts(); return true; }
|
||
|
||
/** Mirror the device-side favorite star into the firmware contact table's
|
||
* flags bit 0 — the bit the core's overwrite-oldest eviction skips and the
|
||
* phone app reads/writes. Without this a device-starred contact was still
|
||
* evictable when the table filled (#178): the star lived only in
|
||
* TouchPrefs, invisible to the core. Returns false if the contact is gone;
|
||
* a no-op flag state is success without a save. */
|
||
// Per-contact telemetry permissions (#266: share position with chosen contacts
|
||
// instead of broadcasting it in every advert).
|
||
//
|
||
// MeshCore packs these into contact.flags ABOVE the favourite bit — the request
|
||
// handler does `contact.flags >> 1` before masking with TELEM_PERM_* — so the bit
|
||
// for a given TELEM_PERM_x lives at (x << 1) here. They only take effect for a
|
||
// category whose telemetry_mode_* is TELEM_MODE_ALLOW_FLAGS; ALLOW_ALL ignores
|
||
// them and answers everyone, DENY answers nobody.
|
||
static uint8_t contactTelemBit(uint8_t telem_perm) { return (uint8_t)(telem_perm << 1); }
|
||
|
||
bool uiGetContactTelemetryPerm(const uint8_t pub_key[32], uint8_t telem_perm) {
|
||
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||
return slot && (slot->flags & contactTelemBit(telem_perm)) != 0;
|
||
}
|
||
bool uiSetContactTelemetryPerm(const uint8_t pub_key[32], uint8_t telem_perm, bool on) {
|
||
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||
if (!slot) return false;
|
||
const uint8_t bit = contactTelemBit(telem_perm);
|
||
const uint8_t nf = on ? (uint8_t)(slot->flags | bit) : (uint8_t)(slot->flags & ~bit);
|
||
if (nf == slot->flags) return true;
|
||
slot->flags = nf;
|
||
saveContacts();
|
||
return true;
|
||
}
|
||
|
||
bool uiSetContactFavorite(const uint8_t pub_key[32], bool fav) {
|
||
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||
if (!slot) return false;
|
||
const uint8_t nf = fav ? (uint8_t)(slot->flags | 0x01) : (uint8_t)(slot->flags & ~0x01);
|
||
if (nf == slot->flags) return true;
|
||
slot->flags = nf;
|
||
saveContacts();
|
||
return true;
|
||
}
|
||
// Flush a pending (possibly coalesced) contacts write before a deliberate
|
||
// shutdown/reboot, so the on-device power paths don't lose the last refresh
|
||
// window on card-less devices (see MyMesh::loop). No-op when nothing is pending.
|
||
void flushContactsIfDirty() { if (dirty_contacts_expiry) { saveContacts(); dirty_contacts_expiry = 0; } }
|
||
/** Flush the companion sync history (message ring + per-client cursors) to
|
||
* storage synchronously. Call on every deliberate reboot/power-off path, next
|
||
* to persistHistoryNow(): the periodic writes are coalesced (see
|
||
* serviceSyncHistory), so a reset inside the window would otherwise drop the
|
||
* newest messages from the app-sync replay. No-op when nothing is pending. */
|
||
void persistSyncHistoryNow();
|
||
|
||
/** Remove a contact from a device-UI action and PERSIST it (mirrors the
|
||
* companion app's CMD_REMOVE_CONTACT). The base removeContact() only drops it
|
||
* from RAM, so without rewriting /contacts3 the contact reappears on the next
|
||
* reboot. Also deletes its stored blob and pings the chats list. */
|
||
bool uiRemoveContact(const ContactInfo& c) {
|
||
ContactInfo* slot = lookupContactByPubKey(c.id.pub_key, PUB_KEY_SIZE);
|
||
if (!slot || !removeContact(*slot)) return false;
|
||
_store->deleteBlobByKey(c.id.pub_key, PUB_KEY_SIZE);
|
||
saveContacts();
|
||
if (_ui) _ui->onThreadsChanged();
|
||
return true;
|
||
}
|
||
|
||
/** Clear channel slot `idx` (zero name + secret), persist, and ping the UI
|
||
* so the chats list drops the entry. Returns false if the index is out of
|
||
* range. Used by the long-press → Delete action on the chats list. */
|
||
bool uiDeleteChannel(int idx) {
|
||
#ifdef MAX_GROUP_CHANNELS
|
||
if (idx < 0 || idx >= MAX_GROUP_CHANNELS) return false;
|
||
ChannelDetails empty{};
|
||
if (!setChannel(idx, empty)) return false;
|
||
saveChannels();
|
||
if (_ui) _ui->onThreadsChanged();
|
||
return true;
|
||
#else
|
||
(void)idx;
|
||
return false;
|
||
#endif
|
||
}
|
||
|
||
/** Idempotently re-add the default "Public" channel (PSK matches
|
||
* MyMesh.cpp's PUBLIC_GROUP_PSK). Returns true if the channel is present
|
||
* after the call, false if no slot was available. */
|
||
bool uiJoinPublicChannel() {
|
||
#ifdef MAX_GROUP_CHANNELS
|
||
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
||
ChannelDetails cd{};
|
||
if (getChannel(i, cd) && cd.name[0] != '\0' &&
|
||
strncasecmp(cd.name, "Public", 6) == 0) return true;
|
||
}
|
||
#endif
|
||
if (addChannel("Public", "izOH6cXN6mrJ5e26oRXNcg==") == nullptr) return false;
|
||
saveChannels();
|
||
if (_ui) _ui->onThreadsChanged();
|
||
return true;
|
||
}
|
||
|
||
// To check if there is pending work
|
||
bool hasPendingWork() const;
|
||
|
||
/** Return the delay until the next companion retry/final-echo deadline.
|
||
* Future retries are wake deadlines, not work that should hold the CPU
|
||
* awake. Returns false when no retry train is active. */
|
||
bool getNextCompanionRetryWakeDelay(uint32_t& delay_millis) const;
|
||
/** Radio & Mesh toggle: auto-retry sends until the mesh echoes/ACKs them (default ON).
|
||
* Live-settable; OFF stops NEW retry trains, an in-flight train finishes its schedule. */
|
||
void setCompanionRetryEnabled(bool on) { _companion_retry_enabled = on; }
|
||
|
||
// Number of companion clients currently connected on any transport.
|
||
// Used by the idle light-sleep gate (TouchSleep) to confirm no one is
|
||
// actively talking to us before the node parks in light sleep.
|
||
int getProtoNumClients() const { return proto_num_clients; }
|
||
|
||
// True if the radio is currently mid-receive of a packet (preamble→RxDone).
|
||
// _radio is the protected Dispatcher::Radio pointer; isReceiving() returns false
|
||
// by default and is overridden by RadioLibWrapper with a real preamble-detect check.
|
||
bool isRadioReceiving() const { return _radio && _radio->isReceiving(); }
|
||
|
||
private:
|
||
bool _companion_retry_enabled = false; // opt-in; UITask::begin() applies the persisted toggle
|
||
static const uint8_t COMPANION_TEXT_QUEUE_CAPACITY = 16;
|
||
uint8_t companionDetachQueuedText(mesh::Packet* packets[], uint8_t priorities[],
|
||
uint32_t scheduled_for[]);
|
||
bool companionRestoreQueuedText(mesh::Packet* packets[], const uint8_t priorities[],
|
||
const uint32_t scheduled_for[], uint8_t count);
|
||
mesh::Packet* companionDetachQueuedTextByHash4(uint32_t packet_hash4,
|
||
uint8_t retry_key[MAX_HASH_SIZE]);
|
||
void companionRetryCancelKey(const uint8_t retry_key[MAX_HASH_SIZE]);
|
||
|
||
void companionRetryObserveRaw(const uint8_t raw[], int len);
|
||
void companionRetryStart(const mesh::Packet* packet, const uint8_t retry_key[MAX_HASH_SIZE]);
|
||
void companionRetryService();
|
||
void companionRetryCancelSlot(int slot_idx);
|
||
void companionRetryResetSlot(int slot_idx);
|
||
bool companionRetryQueueClone(int slot_idx, const mesh::Packet* packet,
|
||
uint8_t attempt_idx);
|
||
uint32_t companionRetryDelay(const mesh::Packet* packet, bool direct, uint8_t attempt_idx);
|
||
|
||
void writeOKFrame();
|
||
void writeErrFrame(uint8_t err_code);
|
||
void writeDisabledFrame();
|
||
/** Returns bytes written, or 0 on failure. Caller can retry on 0. */
|
||
size_t writeContactRespFrame(uint8_t code, const ContactInfo &contact, bool to_all = false);
|
||
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[]);
|
||
/** Appends frame to ring; returns assigned seq, or 0 if rejected. */
|
||
uint32_t addToHistoryRing(const uint8_t frame[], int len);
|
||
/** Get next history frame for client. If do_advance is false, does not advance last_delivered_seq (call commitHistoryForClient after successful write). */
|
||
int getNextFromHistoryForClient(const char* client_id, uint8_t frame[], uint32_t* out_seq = nullptr, bool do_advance = true);
|
||
void commitHistoryForClient(const char* client_id, uint32_t seq);
|
||
/** After a successful V3 live broadcast, bump sync watermarks for clients that understand V3 on the wire (target_ver >= 3 or unknown 0xFF).
|
||
* Legacy apps (CMD_DEVICE_QUERY second byte < 3) are skipped so CMD_SYNC_NEXT_MESSAGE can still deliver adapted 7/8 they never got from live 16/17. */
|
||
void advanceHistoryClientsAfterV3Broadcast(uint32_t seq);
|
||
void setClientTargetVer(const char* client_id, uint8_t target_ver);
|
||
uint8_t getClientTargetVer(const char* client_id) const;
|
||
void setClientAppName(const char* client_id, const char* app_name);
|
||
bool shouldAdvanceClientAfterV3Broadcast(const char* client_id) const;
|
||
int adaptHistoryFrameForClient(const char* client_id, const uint8_t src[], int src_len, uint8_t dest[]) const;
|
||
void sendSyncSinceDelta(uint32_t T);
|
||
int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override {
|
||
return _store->getBlobByKey(key, key_len, dest_buf);
|
||
}
|
||
// The core calls this on EVERY received advert (BaseChatMesh::onAdvertRecv, outside the
|
||
// auto-add block, so for known contacts too) and each call is a full create+truncate+write
|
||
// of a small file. On a card-less board that file lives on SPIFFS, so ~200 known nodes
|
||
// re-advertising means a flash write every few seconds forever — churn that keeps the
|
||
// partition permanently GC-prone and feeds the multi-second "mesh" loop stalls.
|
||
//
|
||
// The blob is the raw advert packet and its ONLY consumer is Share/export contact
|
||
// (BaseChatMesh::shareContact via getBlobByKey). Its content for a given node is
|
||
// effectively static — name, type, location. So persist it ONCE PER KEY PER BOOT and skip
|
||
// the redundant rewrites: after the first advert from each node the packet path does no
|
||
// flash I/O at all. Reads are unaffected because getBlobByKey above still reads the file we
|
||
// already wrote, so there is no cache to keep coherent. A node that RENAMES itself keeps its
|
||
// old shared blob until the next reboot, which is a fair price for removing the churn.
|
||
// Cost: kBlobSeenSlots * 4 bytes of DRAM. Move to PSRAM if static DRAM ever gets tight.
|
||
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override {
|
||
if (key_len >= 4) {
|
||
uint32_t pfx;
|
||
memcpy(&pfx, key, 4);
|
||
if (pfx == 0) pfx = 1; // 0 is the empty-slot marker
|
||
const uint16_t slot = (uint16_t)((pfx ^ (pfx >> 13) ^ (pfx >> 23)) & (kBlobSeenSlots - 1));
|
||
if (_blob_seen[slot] == pfx) return true; // already on flash this boot — skip the write
|
||
_blob_seen[slot] = pfx; // collision just costs one extra write later
|
||
}
|
||
return _store->putBlobByKey(key, key_len, src_buf, len);
|
||
}
|
||
|
||
void checkCLIRescueCmd();
|
||
void checkSerialInterface();
|
||
bool isValidClientRepeatFreq(uint32_t f) const;
|
||
|
||
// helpers, short-cuts
|
||
void saveChannels() { _store->saveChannels(this); }
|
||
void saveContacts();
|
||
|
||
DataStore* _store;
|
||
NodePrefs _prefs;
|
||
uint32_t pending_login;
|
||
uint32_t pending_status;
|
||
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
|
||
uint32_t pending_req; // pending _BINARY_REQ
|
||
/** UI-side ping tracker: holds first 4 bytes of recipient pub_key when the
|
||
* touch UI fired a status request. Independent from `pending_status` so it
|
||
* doesn't collide with the companion-serial workflow. */
|
||
uint32_t _ui_pending_status;
|
||
/** Which UI-side request is in flight against `_ui_pending_status` —
|
||
* STATUS replies should route to onPingReply, TELEMETRY replies to
|
||
* onTelemetryReply (CayenneLPP-aware). */
|
||
UiReqKind _ui_pending_kind = UiReqKind::None;
|
||
/** Reflected-tag match for the UI request. STATUS/TELEMETRY responses
|
||
* echo our request's 4-byte timestamp at data[0..3], but a chained
|
||
* guest LOGIN response carries the *repeater's* own clock there — so
|
||
* matching by pubkey alone misroutes the login OK as the REQ reply.
|
||
* Compare tag in onContactResponse to keep the two streams separate. */
|
||
uint32_t _ui_pending_tag = 0;
|
||
/** Deferred guest-login-then-request arm: holds the first 4 bytes of the
|
||
* recipient pub_key (0 = none) plus which REQ to send, set by
|
||
* uiSendRequestAfterGuestLogin(). onContactResponse fires the REQ when the
|
||
* matching LOGIN-OK arrives, removing the ACL-commit race on the first try. */
|
||
uint32_t _ui_login_then = 0;
|
||
UiReqKind _ui_login_then_kind = UiReqKind::None;
|
||
BaseSerialInterface *_serial;
|
||
AbstractUITask* _ui;
|
||
|
||
ContactsIterator _iter;
|
||
uint32_t _iter_filter_since;
|
||
uint32_t _most_recent_lastmod;
|
||
uint32_t _contact_send_index; // temporary diagnostic: index of CONTACT sent
|
||
int _contact_list_reply_target; // reply target for this contact list (so CONTACT/END go to same client as START)
|
||
uint32_t _active_ble_pin;
|
||
bool _iter_started;
|
||
bool _cli_rescue;
|
||
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
|
||
bool scope_direct_floods = false; // opt-in (#64): tag direct/login/admin floods with the default region scope
|
||
// 200 not 80: the serial sideload ("fadd ...", see cliPutChunk) carries up
|
||
// to ~130 chars per line; every other command is far shorter.
|
||
char cli_command[200];
|
||
#if defined(ESP32)
|
||
// Serial sideload state ("fput" / "fadd" / "fend"): the file being written.
|
||
File _cli_put;
|
||
uint32_t _cli_put_len = 0;
|
||
void cliPutBegin(const char* path);
|
||
void cliPutChunk(const char* args);
|
||
void cliPutEnd();
|
||
#endif
|
||
uint8_t app_target_ver;
|
||
uint8_t *sign_data;
|
||
uint32_t sign_data_len;
|
||
unsigned long dirty_contacts_expiry;
|
||
// Advert-driven contacts-save coalescer (see MyMesh::loop). On card-less devices a
|
||
// full contacts rewrite can trigger a multi-second SPIFFS GC that freezes the loop;
|
||
// constant advert refreshes would churn it. A change in the contact SET saves
|
||
// promptly; pure refreshes coalesce to CONTACTS_REFRESH_SAVE_INTERVAL. -1 forces
|
||
// the first save. Kept in sync by every MyMesh::saveContacts() call.
|
||
int _last_saved_contacts_n = -1;
|
||
uint32_t _next_contacts_refresh_save = 0;
|
||
// Separate, much SHORTER floor for saves caused by an add/remove. Those used to bypass the
|
||
// refresh window completely, so on a growing mesh every newly-heard node forced its own full
|
||
// rewrite. See CONTACTS_ADD_SAVE_MIN_INTERVAL.
|
||
uint32_t _next_contacts_add_save = 0;
|
||
// Pubkey prefixes whose advert blob has already been persisted this boot — see
|
||
// putBlobByKey. Sized above a full contact list so the common case never thrashes.
|
||
static const uint16_t kBlobSeenSlots = 512; // power of two (mask), 2 KB total
|
||
uint32_t _blob_seen[kBlobSeenSlots] = {0};
|
||
|
||
TransportKey send_scope;
|
||
TransportKey _chan_scope_saved; // push/popChannelScope stash
|
||
bool _chan_scope_pushed = false;
|
||
bool _chan_scope_saved_unscoped = false;
|
||
|
||
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];
|
||
|
||
// Per-client history: ring buffer of frames + per-client read position
|
||
struct HistoryEntry {
|
||
uint8_t len;
|
||
uint8_t buf[MAX_FRAME_SIZE];
|
||
uint32_t seq;
|
||
};
|
||
struct ClientHistoryState {
|
||
char client_id[MAX_CLIENT_ID_LEN + 1];
|
||
uint32_t last_delivered_seq;
|
||
};
|
||
struct ClientProtoState {
|
||
char client_id[MAX_CLIENT_ID_LEN + 1];
|
||
uint8_t target_ver;
|
||
bool prefer_live_advance;
|
||
};
|
||
HistoryEntry history_ring[HISTORY_RING_SIZE];
|
||
int history_count;
|
||
int history_head;
|
||
uint32_t history_next_seq;
|
||
ClientHistoryState history_clients[MAX_HISTORY_CLIENTS];
|
||
int history_num_clients;
|
||
|
||
// Companion sync-history persistence. The ring above is what CMD_SYNC_NEXT_MESSAGE /
|
||
// SyncSince replay to an app that (re)connects — and it used to be RAM-only, so any
|
||
// power cycle emptied it: an app that was not connected while messages arrived never
|
||
// got them after the reboot, while the on-device chat store (UITask) still showed them.
|
||
// Worst on boards whose only "off" is a hard power cut (ThinkNode M9 slider), but the
|
||
// V4/T-Deck power menus deep-sleep (RAM lost) just the same.
|
||
// /synchist append-only log of the sync-relevant frames (seq + raw frame); small
|
||
// per-message appends, compacted (tmp + swap) once it holds > 2x the ring.
|
||
// /synccur next_seq + per-client last_delivered_seq (tiny, tmp + swap) so a
|
||
// returning client resumes where it left off instead of re-receiving
|
||
// everything. Both live on DataStore::getHotDataFS() (SD when routed).
|
||
bool _synchist_log_dirty = false; // ring entries appended since the last log write
|
||
bool _synchist_cur_dirty = false; // client cursors / next_seq changed since last write
|
||
bool _synchist_need_compact = false; // torn/oversized log: rewrite from the ring instead of appending
|
||
int _synchist_unflushed = 0; // newest N ring entries not yet in the log
|
||
uint32_t _synchist_file_recs = 0; // records currently in /synchist
|
||
unsigned long _synchist_log_due = 0, _synchist_log_first = 0; // coalesce deadline / first-dirty time
|
||
unsigned long _synchist_cur_due = 0, _synchist_cur_first = 0;
|
||
bool _synchist_was_connected = false; // disconnect edge -> flush cursors now
|
||
static bool isSyncMessageCode(uint8_t code);
|
||
void markSyncLogDirty();
|
||
void markSyncCursorsDirty();
|
||
void loadSyncHistory(); // MyMesh::begin(): restore ring + cursors from storage
|
||
bool appendSyncLog(); // land the newest _synchist_unflushed ring entries
|
||
bool compactSyncLog(); // rewrite the whole ring (tmp + swap)
|
||
bool saveSyncCursors(); // /synccur (tmp + swap)
|
||
void serviceSyncHistory(); // MyMesh::loop(): coalesced flushes
|
||
ClientProtoState proto_clients[MAX_HISTORY_CLIENTS];
|
||
int proto_num_clients;
|
||
|
||
struct AckTableEntry {
|
||
unsigned long msg_sent = 0;
|
||
uint32_t ack = 0;
|
||
ContactInfo* contact = nullptr;
|
||
uint8_t text_fingerprint[MAX_HASH_SIZE] = {};
|
||
uint8_t retry_key[MAX_HASH_SIZE] = {};
|
||
};
|
||
#define EXPECTED_ACK_TABLE_SIZE 8
|
||
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
|
||
int next_ack_idx;
|
||
AckTableEntry* findPendingTextMessage(const uint8_t text_fingerprint[MAX_HASH_SIZE]);
|
||
void clearExpectedAck(AckTableEntry& entry, bool cancel_retry);
|
||
|
||
uint32_t _last_plain_tx_ack = 0;
|
||
uint8_t _last_plain_tx_fingerprint[MAX_HASH_SIZE] = {};
|
||
uint8_t _last_plain_tx_retry_key[MAX_HASH_SIZE] = {};
|
||
bool _last_plain_tx_meta_valid = false;
|
||
|
||
#define ADVERT_PATH_TABLE_SIZE 16
|
||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||
uint32_t advert_recv_seq = 0;
|
||
#if defined(ESP32)
|
||
portMUX_TYPE advert_paths_mux = portMUX_INITIALIZER_UNLOCKED;
|
||
#endif
|
||
|
||
// Client-side retry-until-echo state. Only one exact packet clone is retained
|
||
// in the outbound queue per active slot, matching the companion behavior.
|
||
static const uint8_t COMPANION_RETRY_SLOTS = 6;
|
||
struct CompanionRetrySlot {
|
||
mesh::Packet* queued_packet = nullptr;
|
||
uint32_t retry_at = 0;
|
||
uint32_t retry_delay = 0;
|
||
uint32_t missing_since = 0;
|
||
uint8_t retry_key[MAX_HASH_SIZE] = {};
|
||
uint8_t attempts_sent = 0;
|
||
uint8_t max_attempts = 0;
|
||
uint8_t priority = 0;
|
||
uint8_t progress_marker = 0;
|
||
uint8_t payload_type = 0;
|
||
bool direct = false;
|
||
bool waiting_final_echo = false;
|
||
bool active = false;
|
||
};
|
||
CompanionRetrySlot _companion_retries[COMPANION_RETRY_SLOTS];
|
||
uint8_t _active_companion_retries = 0;
|
||
|
||
// One-shot auto-advert on boot. Recipients with auto-add ON pick up our
|
||
// current pubkey, which is critical when the touch firmware regenerates
|
||
// identity after a SPIFFS wipe (otherwise old contacts have stale pubkey
|
||
// and silently drop our DMs because shared-secret no longer matches).
|
||
// 0 = already fired or disabled.
|
||
uint32_t _boot_advert_due_ms = 0;
|
||
bool _boot_advert_done = false;
|
||
|
||
// Most recently sent UI-initiated trace-ping tag. onTraceRecv compares
|
||
// this against the trace's tag field: if it matches, the trace was ours
|
||
// (a Ping from the action sheet) and we forward the SNRs to the UI; if
|
||
// not, it's an app-originated trace and we route to the companion app
|
||
// path. Single-slot is enough because the UI gates a new ping until the
|
||
// last one resolves or times out.
|
||
uint32_t _ui_trace_ping_tag = 0;
|
||
uint32_t _ui_sig_probe_tag = 0; // in-flight silent signal probe (updates _ui_sig_* only, no popup)
|
||
};
|
||
|
||
#if defined(ESP32_PLATFORM)
|
||
extern MyMesh& the_mesh; // PSRAM-resident (placement-new'd in main.cpp); reference keeps call sites unchanged
|
||
#else
|
||
extern MyMesh the_mesh;
|
||
#endif
|