#pragma once #include "Observer.h" #include "concurrency/Lock.h" #include "mesh-pb-constants.h" #include "meshtastic/portnums.pb.h" #include #include #include #include #include #include #include // NodeNum stored as raw uint32_t below; including MeshTypes.h here breaks the // LOG_WARN include order via Arduino.h/MemoryPool.h. // Make sure that we never let our packets grow too large for one BLE packet #define MAX_TO_FROM_RADIO_SIZE 512 #if meshtastic_FromRadio_size > MAX_TO_FROM_RADIO_SIZE #error "meshtastic_FromRadio_size is too large for our BLE packets" #endif #if meshtastic_ToRadio_size > MAX_TO_FROM_RADIO_SIZE #error "meshtastic_ToRadio_size is too large for our BLE packets" #endif #define SPECIAL_NONCE_ONLY_CONFIG 69420 #define SPECIAL_NONCE_ONLY_NODES 69421 // ( ͡° ͜ʖ ͡°) /** * Provides our protobuf based API which phone/PC clients can use to talk to our device * over UDP, bluetooth or serial. * * Subclass to customize behavior for particular type of transport (BLE, UDP, TCP, serial) * * Eventually there should be once instance of this class for each live connection (because it has a bit of state * for that connection) */ class PhoneAPI : public Observer // FIXME, we shouldn't be inheriting from Observer, instead use CallbackObserver as a member { enum State { STATE_SEND_NOTHING, // Initial state, don't send anything until the client starts asking for config STATE_SEND_UIDATA, // send stored data for device-ui STATE_SEND_MY_INFO, // send our my info record STATE_SEND_OWN_NODEINFO, STATE_SEND_METADATA, STATE_SEND_REGION_PRESETS, // Send the region->valid-preset map (one message) STATE_SEND_CHANNELS, // Send all channels STATE_SEND_CONFIG, // Replacement for the old Radioconfig STATE_SEND_MODULECONFIG, // Send Module specific config STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to to the client STATE_SEND_FILEMANIFEST, // Send file manifest STATE_SEND_COMPLETE_ID, STATE_SEND_PACKETS // live mesh packets + any cached satellite-DB replay that trails sync completion }; // Satellite-DB replay (positions / telemetry / environment / status) used to live // as four top-level states between STATE_SEND_OTHER_NODEINFOS and STATE_SEND_FILEMANIFEST. // It now drains *after* config_complete_id has been emitted: the phone considers the // initial sync done as soon as headers + manifest are delivered, and the cached // position/telemetry/etc. trickle in alongside live mesh traffic inside STATE_SEND_PACKETS. enum ReplayPhase : uint8_t { REPLAY_PHASE_IDLE = 0, // not replaying (legacy clients, no-op DBs, or replay finished) REPLAY_PHASE_POSITIONS, REPLAY_PHASE_TELEMETRY, REPLAY_PHASE_ENVIRONMENT, REPLAY_PHASE_STATUS, }; State state = STATE_SEND_NOTHING; uint8_t config_state = 0; // Hashmap of timestamps for last time we received a packet on the API per portnum std::unordered_map lastPortNumToRadio; uint32_t recentToRadioPacketIds[20]; // Last 20 ToRadio MeshPacket IDs we have seen /** * Each packet sent to the phone has an incrementing count */ uint32_t fromRadioNum = 0; /// We temporarily keep the packet here between the call to available and getFromRadio. We will free it after the phone /// downloads it meshtastic_MeshPacket *packetForPhone = NULL; // file transfer packets destined for phone. Push it to the queue then free it. meshtastic_XModem xmodemPacketForPhone = meshtastic_XModem_init_zero; // Keep QueueStatus packet just as packetForPhone meshtastic_QueueStatus *queueStatusPacketForPhone = NULL; // Keep MqttClientProxyMessage packet just as packetForPhone meshtastic_MqttClientProxyMessage *mqttClientProxyMessageForPhone = NULL; // Keep ClientNotification packet just as packetForPhone meshtastic_ClientNotification *clientNotification = NULL; /// We temporarily keep the nodeInfo here between the call to available and getFromRadio meshtastic_NodeInfo nodeInfoForPhone = meshtastic_NodeInfo_init_default; // Prefetched node info entries ready for immediate transmission to the phone. std::deque nodeInfoQueue; // Tunable size of the node info cache so we can keep BLE reads non-blocking. static constexpr size_t kNodePrefetchDepth = 4; // Protect nodeInfoForPhone + nodeInfoQueue because NimBLE callbacks run in a separate FreeRTOS task. concurrency::Lock nodeInfoMutex; // Synthetic-packet replay queue (paced via prefetch). std::deque replayQueue; static constexpr size_t kReplayPrefetchDepth = 4; // NodeNum snapshots taken at each replay phase start so concurrent // satellite-map mutations don't invalidate iteration. std::vector replayPositionOrder; std::vector replayTelemetryOrder; std::vector replayEnvironmentOrder; std::vector replayStatusOrder; size_t replayPositionIndex = 0; size_t replayTelemetryIndex = 0; size_t replayEnvironmentIndex = 0; size_t replayStatusIndex = 0; ReplayPhase replayPhase = REPLAY_PHASE_IDLE; // armed by sendConfigComplete() for full/default sync meshtastic_ToRadio toRadioScratch = { 0}; // this is a static scratch object, any data must be copied elsewhere before returning /// Use to ensure that clients don't get confused about old messages from the radio uint32_t config_nonce = 0; uint32_t readIndex = 0; std::vector filesManifest = {}; void resetReadIndex() { readIndex = 0; } public: PhoneAPI(); /// Destructor - calls close() virtual ~PhoneAPI(); // Call this when the client drops the connection, resets the state to STATE_SEND_NOTHING // Unregisters our observer. A closed connection **can** be reopened by calling init again. virtual void close(); /** * Handle a ToRadio protobuf * @return true true if a packet was queued for sending (so that caller can yield) */ virtual bool handleToRadio(const uint8_t *buf, size_t len); /** * Send a (client)notification to the phone */ virtual void sendNotification(meshtastic_LogRecord_Level level, uint32_t replyId, const char *message); /** * Get the next packet we want to send to the phone * * We assume buf is at least FromRadio_size bytes long. * Returns number of bytes in the FromRadio packet (or 0 if no packet available) */ size_t getFromRadio(uint8_t *buf); void sendConfigComplete(); /** * Return true if we have data available to send to the phone */ bool available(); bool isConnected() { return state != STATE_SEND_NOTHING; } bool isSendingPackets() { return state == STATE_SEND_PACKETS; } #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL /// Per-connection auth: tracked in a small file-scope slot table keyed /// by PhoneAPI*. Adding state members directly to PhoneAPI broke /// USB-CDC enumeration on current nRF52 framework - even one extra /// per-instance uint32_t was enough. Keeping all state out-of-line /// avoids the issue. void setAdminAuthorized(bool authorized); bool getAdminAuthorized() const; /// Lock Now: O(1) invalidation of every connection's auth by advancing /// the global epoch. Subsequent gate checks see slot.myEpoch != epoch /// and treat the connection as unauthenticated. static void revokeAllAuth(); /// Called from the main loop after NodeDB::reloadFromDisk() finishes. /// On reloadOk=true: any connection marked pending-unlock-after-reload /// is promoted to authorized and receives an UNLOCKED status; the /// screen-lock latch clears. On reloadOk=false: those connections /// receive a LOCKED(storage_corrupt) status and remain unauthorized /// so they cannot drive set_config against the corrupt baseline. static void completePendingUnlocks(bool reloadOk); /// Queue a LockdownStatus FromRadio for THIS connection only. Each /// PhoneAPI owns its own pending-status slot in a file-scope table /// (file-scope because adding fields directly to PhoneAPI broke /// USB-CDC enumeration on nRF52); a status produced here will not /// be delivered to any other connection. `lock_reason` may be /// nullptr / empty for non-LOCKED states. void queueLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining, uint32_t valid_until_epoch, uint32_t backoff_seconds); /// Queue the same LockdownStatus on every active connection's slot. /// Use for events with no specific originating connection (session /// expiry tick in main.cpp, broadcast revocations, etc.). Per- /// connection callers should prefer the instance method above to /// avoid leaking one client's auth state to another. static void broadcastLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining, uint32_t valid_until_epoch, uint32_t backoff_seconds); /// True iff this connection has a pending lockdown_status drain. bool hasPendingLockdownStatus() const; #endif protected: /// Our fromradio packet while it is being assembled meshtastic_FromRadio fromRadioScratch = {}; /** the last msec we heard from the client on the other side of this link */ uint32_t lastContactMsec = 0; /// Hookable to find out when connection changes virtual void onConnectionChanged(bool connected) {} /// If we haven't heard from the other side in a while then say not connected. Returns true if timeout occurred bool checkConnectionTimeout(); /// Check the current underlying physical link to see if the client is currently connected virtual bool checkIsConnected() = 0; /** * Subclasses can use this as a hook to provide custom notifications for their transport (i.e. bluetooth notifies) */ virtual void onNowHasData(uint32_t fromRadioNum) {} /// Subclasses can use these lifecycle hooks for transport-specific behavior around config/steady-state /// (i.e. BLE connection params) virtual void onConfigStart() {} virtual void onConfigComplete() {} /// begin a new connection void handleStartConfig(); enum APIType { TYPE_NONE, // Initial state, don't send anything until the client starts asking for config TYPE_BLE, TYPE_WIFI, TYPE_SERIAL, TYPE_PACKET, TYPE_HTTP, TYPE_ETH }; APIType api_type = TYPE_NONE; #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL // No per-instance auth members - see method-level note. All state lives // in a file-scope slot table in PhoneAPI.cpp keyed by `this` pointer. // Pending LockdownStatus storage is NOT a class member - having a // meshtastic_LockdownStatus (~50 bytes with the char[33] lock_reason) // as a PhoneAPI member broke USB-CDC enumeration on the nRF52 Adafruit // framework. The exact mechanism wasn't pinned down, but moving the // storage to a file-scope static in PhoneAPI.cpp side-steps it cleanly. // Trade-off: all PhoneAPI instances share one pending slot. Acceptable // because only one transport delivers a lockdown command at a time in // any realistic scenario. #endif private: void releasePhonePacket(); void releaseQueueStatusPhonePacket(); void prefetchNodeInfos(); void beginReplayPositions(); void prefetchReplayPositions(); void beginReplayTelemetry(); void prefetchReplayTelemetry(); void beginReplayEnvironment(); void prefetchReplayEnvironment(); void beginReplayStatus(); void prefetchReplayStatus(); meshtastic_MeshPacket makeReplayPositionPacket(uint32_t num, const meshtastic_PositionLite &pos); meshtastic_MeshPacket makeReplayTelemetryPacket(uint32_t num, const meshtastic_DeviceMetrics &metrics); meshtastic_MeshPacket makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env); meshtastic_MeshPacket makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status); // Post-sync replay drain: pop one cached packet from the active phase, advancing // through positions -> telemetry -> environment -> status until everything is drained. bool popReplayPacket(meshtastic_MeshPacket &out); void advanceReplayPhase(); bool replayPending() const { return replayPhase != REPLAY_PHASE_IDLE; } void releaseMqttClientProxyPhonePacket(); void releaseClientNotification(); bool wasSeenRecently(uint32_t packetId); /** * Handle a packet that the phone wants us to send. We can write to it but can not keep a reference to it * @return true true if a packet was queued for sending */ bool handleToRadioPacket(meshtastic_MeshPacket &p); #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) /// Synchronously handle a lockdown_auth AdminMessage from the local /// client. Runs inside handleToRadioPacket so the originating /// connection is reachable via `this` - avoids the async context /// loss that broke the previous AdminModule path. Always consumes the /// packet (returns true): lockdown_auth is local-only and must not be /// forwarded to the mesh router. bool handleLockdownAuthInline(const meshtastic_LockdownAuth &la); #endif /// If the mesh service tells us fromNum has changed, tell the phone virtual int onNotify(uint32_t newValue) override; public: /// How the lockdown admin gate should treat a phone->radio packet. enum class LocalAdminGate { NotAdmin, ///< Not a decodable ADMIN_APP payload; normal handling. LockdownAuth, ///< A lockdown_auth payload; authenticate the connection inline. DropUnauthorized, ///< Admin payload from a connection that has not authenticated; drop. AuthorizedPassThrough, ///< Admin payload from an authorized connection; normal handling. }; /// Classify a phone->radio packet for the lockdown admin gate, ignoring the wire `from` (which a /// client can forge) and deciding on the connection's authorization. Fills outAdmin for lockdown. static LocalAdminGate classifyLocalAdminPacket(const meshtastic_MeshPacket &p, bool adminAuthorized, meshtastic_AdminMessage &outAdmin); };