From 5e7adfb130fe28e6551a2c38e96a1d91e7279544 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Tue, 5 May 2026 14:56:44 +0200 Subject: [PATCH] normalize source-file line endings to LF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .gitattributes rules so .c/.h/.cpp/.hpp are always stored as LF (prevents EOL drift from editors with autocrlf-true defaults), and renormalize the 30 source files that had drifted to CRLF in the index. Pure mechanical change — `git diff --ignore-cr-at-eol` is empty. Co-Authored-By: Claude Opus 4.7 --- zephcore/.gitattributes | 5 + zephcore/adapters/datastore/ZephyrDataStore.h | 180 +- zephcore/adapters/ota/ota_page.h | 202 +- zephcore/adapters/ota/wifi_ota.c | 1060 +++---- zephcore/adapters/radio/LR1110Radio.cpp | 156 +- zephcore/adapters/radio/LR1110Radio.h | 66 +- zephcore/adapters/radio/LR2021Radio.cpp | 154 +- zephcore/adapters/radio/LR2021Radio.h | 66 +- zephcore/adapters/radio/LoRaRadioBase.cpp | 1566 +++++------ zephcore/adapters/radio/SX126xRadio.cpp | 200 +- zephcore/adapters/radio/SX126xRadio.h | 72 +- zephcore/adapters/radio/SX127xRadio.cpp | 228 +- zephcore/adapters/radio/SX127xRadio.h | 112 +- zephcore/app/ObserverMesh.h | 186 +- zephcore/app/RepeaterDataStore.cpp | 716 ++--- zephcore/helpers/NodePrefs.h | 254 +- zephcore/helpers/RegionMap.cpp | 652 ++--- zephcore/helpers/RegionMap.h | 144 +- zephcore/helpers/ui/display.c | 1050 +++---- zephcore/helpers/ui/display.h | 314 +-- zephcore/helpers/ui/ui_task.c | 2452 ++++++++--------- zephcore/include/mesh/ContentionTracker.h | 160 +- zephcore/include/mesh/Dispatcher.h | 254 +- zephcore/include/mesh/Mesh.h | 214 +- zephcore/include/mesh/PowerController.h | 226 +- zephcore/include/mesh/Radio.h | 86 +- .../drivers/lora/native/sx126x/sx126x_ext.h | 232 +- zephcore/src/ContentionTracker.cpp | 332 +-- zephcore/src/Mesh.cpp | 1464 +++++----- zephcore/src/Packet.cpp | 212 +- zephcore/src/PowerController.cpp | 556 ++-- 31 files changed, 6788 insertions(+), 6783 deletions(-) diff --git a/zephcore/.gitattributes b/zephcore/.gitattributes index 0b66b45..39a395b 100644 --- a/zephcore/.gitattributes +++ b/zephcore/.gitattributes @@ -1,2 +1,7 @@ +*.c text eol=lf +*.h text eol=lf +*.cpp text eol=lf +*.hpp text eol=lf + patches/zephyr/*.patch text eol=lf patches/modules/loramac-node/*.patch text eol=crlf diff --git a/zephcore/adapters/datastore/ZephyrDataStore.h b/zephcore/adapters/datastore/ZephyrDataStore.h index 8ffc4db..242f8dc 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.h +++ b/zephcore/adapters/datastore/ZephyrDataStore.h @@ -1,90 +1,90 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Zephyr DataStore - LittleFS-backed persistence with optional QSPI flash - * - * All platforms use DTS-automounted /lfs for identity, prefs, contacts. - * QSPI /ext overrides contacts path when available (any platform). - */ - -#pragma once - -#include -#include -#include -#include -#include - -class DataStoreHost { -public: - virtual bool onContactLoaded(const ContactInfo &contact) = 0; - virtual bool getContactForSave(uint32_t idx, ContactInfo &contact) = 0; - virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails &ch) = 0; - virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails &ch) = 0; -}; - -class ZephyrDataStore { -public: - explicit ZephyrDataStore(mesh::RTCClock &clock); - void begin(); - bool formatFileSystem(); - bool loadMainIdentity(mesh::LocalIdentity &identity); - bool saveMainIdentity(const mesh::LocalIdentity &identity); - void loadPrefs(NodePrefs &prefs); - void savePrefs(const NodePrefs &prefs); - void loadContacts(DataStoreHost *host); - void saveContacts(DataStoreHost *host); - void loadChannels(DataStoreHost *host); - void saveChannels(DataStoreHost *host); - uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); - bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); - bool deleteBlobByKey(const uint8_t key[], int key_len); - /* Used/total KiB for BLE storage report: /ext when mounted, else /lfs (Arduino parity). */ - uint32_t getStorageUsedKb() const; - uint32_t getStorageTotalKb() const; - - /* Factory reset - delete all stored data */ - void factoryReset(); - - /* Check if external QSPI flash is available */ - bool hasExternalStorage() const { return _has_ext_fs; } - uint32_t getExternalStorageKb() const; - - static bool mount(); - static void unmount(); - static const char *mountPoint() { return MNT_POINT; } - static const char *extMountPoint() { return EXT_MNT_POINT; } - -private: - /* Internal flash (always available) - identity, prefs */ - static constexpr const char *MNT_POINT = "/lfs"; - static constexpr const char *PREFS_FILE = "/lfs/new_prefs"; - static constexpr const char *MAIN_ID_FILE = "/lfs/_main.id"; - - /* External QSPI flash (optional) - contacts, channels, blobs */ - static constexpr const char *EXT_MNT_POINT = "/ext"; - static constexpr const char *EXT_CONTACTS_FILE = "/ext/contacts3"; - static constexpr const char *EXT_CHANNELS_FILE = "/ext/channels2"; - static constexpr const char *EXT_ADV_BLOBS_FILE = "/ext/adv_blobs"; - - /* Fallback to internal if no external */ - static constexpr const char *INT_CONTACTS_FILE = "/lfs/contacts3"; - static constexpr const char *INT_CHANNELS_FILE = "/lfs/channels2"; - static constexpr const char *INT_ADV_BLOBS_FILE = "/lfs/adv_blobs"; - - mesh::RTCClock *_clock; - bool _has_ext_fs; - - /* Get path based on external availability */ - const char *contactsFile() const { return _has_ext_fs ? EXT_CONTACTS_FILE : INT_CONTACTS_FILE; } - const char *channelsFile() const { return _has_ext_fs ? EXT_CHANNELS_FILE : INT_CHANNELS_FILE; } - const char *advBlobsFile() const { return _has_ext_fs ? EXT_ADV_BLOBS_FILE : INT_ADV_BLOBS_FILE; } - int maxBlobRecs() const { return _has_ext_fs ? 100 : 20; } - - void checkAdvBlobFile(); - void migrateToExternalFS(); - bool openRead(const char *path, uint8_t *buf, size_t buf_sz, size_t &out_len); - bool atomicReplaceFile(const char *path, const uint8_t *buf, size_t len); - bool exists(const char *path); - bool removeFile(const char *path); - bool copyFile(const char *src, const char *dst); -}; +/* + * SPDX-License-Identifier: Apache-2.0 + * Zephyr DataStore - LittleFS-backed persistence with optional QSPI flash + * + * All platforms use DTS-automounted /lfs for identity, prefs, contacts. + * QSPI /ext overrides contacts path when available (any platform). + */ + +#pragma once + +#include +#include +#include +#include +#include + +class DataStoreHost { +public: + virtual bool onContactLoaded(const ContactInfo &contact) = 0; + virtual bool getContactForSave(uint32_t idx, ContactInfo &contact) = 0; + virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails &ch) = 0; + virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails &ch) = 0; +}; + +class ZephyrDataStore { +public: + explicit ZephyrDataStore(mesh::RTCClock &clock); + void begin(); + bool formatFileSystem(); + bool loadMainIdentity(mesh::LocalIdentity &identity); + bool saveMainIdentity(const mesh::LocalIdentity &identity); + void loadPrefs(NodePrefs &prefs); + void savePrefs(const NodePrefs &prefs); + void loadContacts(DataStoreHost *host); + void saveContacts(DataStoreHost *host); + void loadChannels(DataStoreHost *host); + void saveChannels(DataStoreHost *host); + uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); + bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); + bool deleteBlobByKey(const uint8_t key[], int key_len); + /* Used/total KiB for BLE storage report: /ext when mounted, else /lfs (Arduino parity). */ + uint32_t getStorageUsedKb() const; + uint32_t getStorageTotalKb() const; + + /* Factory reset - delete all stored data */ + void factoryReset(); + + /* Check if external QSPI flash is available */ + bool hasExternalStorage() const { return _has_ext_fs; } + uint32_t getExternalStorageKb() const; + + static bool mount(); + static void unmount(); + static const char *mountPoint() { return MNT_POINT; } + static const char *extMountPoint() { return EXT_MNT_POINT; } + +private: + /* Internal flash (always available) - identity, prefs */ + static constexpr const char *MNT_POINT = "/lfs"; + static constexpr const char *PREFS_FILE = "/lfs/new_prefs"; + static constexpr const char *MAIN_ID_FILE = "/lfs/_main.id"; + + /* External QSPI flash (optional) - contacts, channels, blobs */ + static constexpr const char *EXT_MNT_POINT = "/ext"; + static constexpr const char *EXT_CONTACTS_FILE = "/ext/contacts3"; + static constexpr const char *EXT_CHANNELS_FILE = "/ext/channels2"; + static constexpr const char *EXT_ADV_BLOBS_FILE = "/ext/adv_blobs"; + + /* Fallback to internal if no external */ + static constexpr const char *INT_CONTACTS_FILE = "/lfs/contacts3"; + static constexpr const char *INT_CHANNELS_FILE = "/lfs/channels2"; + static constexpr const char *INT_ADV_BLOBS_FILE = "/lfs/adv_blobs"; + + mesh::RTCClock *_clock; + bool _has_ext_fs; + + /* Get path based on external availability */ + const char *contactsFile() const { return _has_ext_fs ? EXT_CONTACTS_FILE : INT_CONTACTS_FILE; } + const char *channelsFile() const { return _has_ext_fs ? EXT_CHANNELS_FILE : INT_CHANNELS_FILE; } + const char *advBlobsFile() const { return _has_ext_fs ? EXT_ADV_BLOBS_FILE : INT_ADV_BLOBS_FILE; } + int maxBlobRecs() const { return _has_ext_fs ? 100 : 20; } + + void checkAdvBlobFile(); + void migrateToExternalFS(); + bool openRead(const char *path, uint8_t *buf, size_t buf_sz, size_t &out_len); + bool atomicReplaceFile(const char *path, const uint8_t *buf, size_t len); + bool exists(const char *path); + bool removeFile(const char *path); + bool copyFile(const char *src, const char *dst); +}; diff --git a/zephcore/adapters/ota/ota_page.h b/zephcore/adapters/ota/ota_page.h index 421281a..12bbb17 100644 --- a/zephcore/adapters/ota/ota_page.h +++ b/zephcore/adapters/ota/ota_page.h @@ -1,101 +1,101 @@ -/* - * Auto-generated from ota_page.html — do not edit manually. - * Regenerate: python3 compress_html.py - */ - -#pragma once - -#include - -#define OTA_PAGE_GZ_SIZE 1408 - -static const uint8_t ota_page_gz[OTA_PAGE_GZ_SIZE] = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x95, 0x57, 0x6d, 0x6f, 0xdb, 0x36, - 0x10, 0xfe, 0xee, 0x5f, 0xa1, 0xa9, 0x08, 0x64, 0x37, 0xb1, 0x2c, 0x3b, 0x2f, 0x75, 0xa4, 0xc8, - 0xc0, 0xda, 0xb5, 0xd8, 0xb0, 0x75, 0x2d, 0xda, 0x14, 0xd8, 0x56, 0xf4, 0x03, 0x25, 0x9e, 0x2c, - 0x36, 0x32, 0xa9, 0x91, 0x54, 0x62, 0x4f, 0x30, 0xb0, 0x1f, 0xb1, 0x5f, 0xb8, 0x5f, 0xb2, 0x23, - 0x25, 0x25, 0xb2, 0x1b, 0x0f, 0xed, 0x97, 0x48, 0x26, 0x8f, 0xcf, 0xdd, 0x3d, 0xf7, 0xdc, 0x51, - 0xb9, 0xfa, 0xee, 0x87, 0x37, 0x2f, 0xae, 0x7f, 0x7f, 0xfb, 0xd2, 0xc9, 0xf5, 0xaa, 0x58, 0x0c, - 0xae, 0xba, 0x07, 0x10, 0x8a, 0x8f, 0x15, 0x68, 0xe2, 0xa4, 0x39, 0x91, 0x0a, 0x74, 0xec, 0x56, - 0x3a, 0x1b, 0xcf, 0xdd, 0x6e, 0x99, 0x93, 0x15, 0xc4, 0xee, 0x2d, 0x83, 0xbb, 0x52, 0x48, 0xed, - 0x3a, 0xa9, 0xe0, 0x1a, 0x38, 0x9a, 0xdd, 0x31, 0xaa, 0xf3, 0x98, 0xc2, 0x2d, 0x4b, 0x61, 0x6c, - 0x7f, 0x9c, 0x30, 0xce, 0x34, 0x23, 0xc5, 0x58, 0xa5, 0xa4, 0x80, 0x78, 0x6a, 0x30, 0x34, 0xd3, - 0x05, 0x2c, 0xfe, 0x80, 0x32, 0x7f, 0x21, 0x24, 0x38, 0x6f, 0xae, 0xbf, 0xbf, 0x9a, 0x34, 0x6b, - 0x83, 0x2b, 0xa5, 0x37, 0xe6, 0xf9, 0xb4, 0x4e, 0xc4, 0x7a, 0xac, 0xd8, 0x5f, 0x8c, 0x2f, 0xc3, - 0x44, 0x48, 0x0a, 0x72, 0x8c, 0x2b, 0xd1, 0x8a, 0xc8, 0x25, 0xe3, 0x61, 0x10, 0x95, 0x84, 0x52, - 0xb3, 0x17, 0x6c, 0x07, 0x89, 0xa0, 0x9b, 0x3a, 0xc3, 0x10, 0xc6, 0x19, 0x59, 0xb1, 0x62, 0x13, - 0xaa, 0x8d, 0xd2, 0xb0, 0x1a, 0x57, 0xec, 0x44, 0x11, 0xae, 0xc6, 0x0a, 0x24, 0xcb, 0xa2, 0x84, - 0xa4, 0x37, 0x4b, 0x29, 0x2a, 0x4e, 0xc3, 0x27, 0x53, 0x32, 0x25, 0x33, 0x88, 0x52, 0x51, 0x08, - 0x19, 0x3e, 0x01, 0x80, 0x88, 0x32, 0x55, 0x16, 0x64, 0x13, 0x66, 0x05, 0xac, 0xa3, 0xcf, 0x95, - 0xd2, 0x2c, 0xdb, 0x8c, 0xdb, 0xb4, 0xc2, 0x14, 0xff, 0x80, 0x8c, 0x48, 0xc1, 0x96, 0x7c, 0xcc, - 0x10, 0x5a, 0x75, 0x4b, 0x2b, 0xc6, 0xc7, 0x39, 0xb0, 0x65, 0xae, 0xc3, 0x69, 0x10, 0xdc, 0xe6, - 0xdb, 0x81, 0x9f, 0xd6, 0x3b, 0x9e, 0x2e, 0x66, 0xd3, 0x53, 0x88, 0xda, 0x0c, 0x24, 0xa1, 0xac, - 0x52, 0xe1, 0x74, 0x56, 0xae, 0xef, 0x13, 0x98, 0xc1, 0x0a, 0xb3, 0x5a, 0x37, 0x6c, 0x85, 0x67, - 0xb3, 0x00, 0xf7, 0x9a, 0xf7, 0xcb, 0xe0, 0x28, 0xd2, 0xb0, 0xd6, 0x63, 0xeb, 0xb8, 0x75, 0xb9, - 0x1d, 0xe4, 0xb3, 0xba, 0x61, 0x01, 0x09, 0xd1, 0x5a, 0xac, 0x42, 0xff, 0x1c, 0x21, 0xda, 0x5c, - 0x82, 0x2c, 0xc3, 0x18, 0x18, 0xad, 0xdb, 0xdf, 0xf3, 0xf9, 0x3c, 0xb2, 0xd4, 0x20, 0x95, 0x10, - 0xfa, 0xf3, 0x73, 0xeb, 0xad, 0x7f, 0x7a, 0x6a, 0x8e, 0x6f, 0x07, 0x8c, 0x97, 0x95, 0xfe, 0xa8, - 0x37, 0x25, 0xc4, 0x19, 0x2b, 0xe0, 0x53, 0xdd, 0x31, 0xc2, 0x05, 0x07, 0x84, 0x4c, 0x34, 0xbf, - 0x5f, 0x62, 0xbc, 0x60, 0x1c, 0xc6, 0x49, 0x21, 0xd2, 0x9b, 0xfb, 0x3c, 0xfc, 0x67, 0xb0, 0x72, - 0x2c, 0xd8, 0x5e, 0xb6, 0x73, 0x4c, 0xa8, 0x59, 0xb1, 0x58, 0x51, 0x5a, 0x49, 0x85, 0xa1, 0x95, - 0x82, 0x59, 0x0a, 0x1f, 0xa2, 0x9b, 0xe2, 0x51, 0xfb, 0xeb, 0xae, 0x61, 0xf4, 0x22, 0x08, 0x22, - 0x2d, 0xb1, 0x82, 0x28, 0x20, 0xc1, 0xc3, 0x07, 0x5a, 0x1d, 0x7f, 0xa6, 0x30, 0x24, 0x05, 0xc5, - 0x0e, 0xd7, 0x41, 0x76, 0x7a, 0x76, 0x11, 0xf4, 0xaa, 0xda, 0xd8, 0x84, 0xb9, 0xb8, 0x05, 0xb9, - 0x5b, 0x15, 0x72, 0x3e, 0x7b, 0x76, 0x81, 0xdb, 0x55, 0xb9, 0x07, 0x11, 0x24, 0xf3, 0xcb, 0xb3, - 0x0e, 0x22, 0xcb, 0xb2, 0x8e, 0xac, 0x02, 0x32, 0x1d, 0x36, 0x4c, 0x99, 0x43, 0x8f, 0x60, 0x06, - 0x01, 0x39, 0x9d, 0x4f, 0xdb, 0x6d, 0x64, 0x8a, 0x24, 0x05, 0xd0, 0x1d, 0x8b, 0xf3, 0xf3, 0xf3, - 0x2e, 0x79, 0x0a, 0x19, 0xa9, 0x0a, 0x6d, 0x78, 0x25, 0xb2, 0x6e, 0xca, 0x8d, 0x02, 0x3a, 0x8a, - 0x5a, 0x31, 0xcd, 0xce, 0x0c, 0x69, 0x7d, 0x74, 0x12, 0xa0, 0x66, 0x1f, 0xd3, 0x51, 0xdb, 0x11, - 0x53, 0x1f, 0x85, 0xe4, 0x04, 0x91, 0x89, 0x2b, 0x2b, 0xc4, 0x5d, 0x98, 0x33, 0x4a, 0x81, 0x47, - 0x7b, 0x65, 0xc4, 0xda, 0x16, 0xf5, 0x83, 0x62, 0x8f, 0xfa, 0x4e, 0x4c, 0x51, 0x89, 0x1c, 0x2f, - 0x0d, 0x3a, 0x6a, 0x6d, 0x78, 0x19, 0x50, 0x58, 0x9e, 0x18, 0x49, 0x9d, 0xb4, 0xc4, 0x8c, 0x1e, - 0x0b, 0xa0, 0x57, 0x22, 0x9b, 0x88, 0xe3, 0x9f, 0xaa, 0x56, 0xc1, 0xd8, 0x9c, 0x7e, 0x99, 0xea, - 0xfa, 0x80, 0x02, 0xb5, 0x28, 0x43, 0xff, 0xd4, 0x72, 0xaa, 0x74, 0xdd, 0x5b, 0xbc, 0x57, 0x42, - 0x73, 0xe6, 0xd2, 0x1c, 0xe9, 0x75, 0x9a, 0x7f, 0x66, 0xcf, 0x88, 0x9b, 0x4e, 0xe5, 0x4d, 0x74, - 0xb8, 0x84, 0x35, 0xe9, 0xca, 0xff, 0xec, 0x2c, 0x3d, 0x4d, 0xb7, 0x83, 0xab, 0x49, 0x3b, 0x51, - 0xae, 0x26, 0xed, 0x60, 0x33, 0xe3, 0x02, 0x1f, 0x94, 0xdd, 0x3a, 0x69, 0x41, 0x94, 0x8a, 0xdd, - 0xd4, 0x0c, 0xa5, 0x7c, 0xb6, 0x33, 0x91, 0x9c, 0x0f, 0x25, 0x25, 0x1a, 0xf0, 0xd4, 0x6c, 0xd7, - 0x98, 0x51, 0xd7, 0x61, 0x34, 0x76, 0xcd, 0x90, 0xa3, 0xee, 0xe2, 0x6a, 0x82, 0x7b, 0x8d, 0x85, - 0xc1, 0xae, 0xb0, 0xa7, 0x78, 0x67, 0x8a, 0x3d, 0xe3, 0xa0, 0x00, 0x5d, 0x47, 0xf0, 0xb4, 0x60, - 0xe9, 0x0d, 0x1e, 0x12, 0x69, 0xb5, 0x42, 0x6e, 0xfd, 0x25, 0xe8, 0x97, 0x05, 0x98, 0xd7, 0xe7, - 0x9b, 0x9f, 0xe8, 0xd0, 0xcb, 0xbc, 0x91, 0x6f, 0x6d, 0x86, 0x23, 0x77, 0xf1, 0x1e, 0x0a, 0x48, - 0xb5, 0xf3, 0x8a, 0xc9, 0xd5, 0x1d, 0x91, 0x18, 0x43, 0x03, 0x8b, 0xf8, 0xb6, 0x4b, 0x1d, 0xdb, - 0xa5, 0xae, 0x69, 0xd3, 0x26, 0x94, 0xcc, 0x75, 0x48, 0x9a, 0x42, 0x89, 0x03, 0xd8, 0x4f, 0x18, - 0xb7, 0xfe, 0x72, 0xc2, 0x97, 0xc6, 0x48, 0x0d, 0x75, 0xce, 0xd4, 0xc8, 0x7d, 0x34, 0x38, 0x94, - 0x6a, 0x83, 0x50, 0x25, 0xae, 0xd3, 0x69, 0xf6, 0x21, 0xda, 0xaa, 0x34, 0xd1, 0x7c, 0x28, 0x0b, - 0x41, 0x68, 0x2f, 0x88, 0x87, 0x8c, 0x1b, 0xef, 0xe8, 0xd0, 0x72, 0x1c, 0xbb, 0xfd, 0xba, 0xce, - 0x77, 0x6b, 0x38, 0xef, 0x0d, 0x29, 0x1c, 0x4a, 0x3b, 0xc4, 0xdd, 0x87, 0x44, 0x64, 0x13, 0x8e, - 0x79, 0x59, 0xf4, 0x77, 0x8c, 0x6c, 0xdb, 0x5c, 0xcd, 0x5b, 0x7b, 0xf8, 0x11, 0x08, 0x54, 0x5b, - 0x63, 0x67, 0x5e, 0x1e, 0x33, 0x50, 0xed, 0xbe, 0xea, 0x6d, 0xb7, 0x0f, 0x95, 0x4a, 0x56, 0xea, - 0xc5, 0xe0, 0x96, 0x48, 0xc7, 0x90, 0x1b, 0x0d, 0xb2, 0x8a, 0xa7, 0x46, 0xd8, 0x0e, 0xd2, 0xc8, - 0x46, 0xb5, 0x59, 0x8c, 0x99, 0xe9, 0x21, 0x50, 0x1f, 0x83, 0x4f, 0x11, 0xcb, 0x86, 0xe6, 0x7d, - 0x54, 0x1f, 0x2e, 0x2b, 0xc7, 0xba, 0x9a, 0x19, 0xfe, 0xa2, 0xbd, 0x21, 0x8d, 0xbd, 0x6f, 0xae, - 0xcf, 0x63, 0xcf, 0x19, 0x7a, 0xc7, 0xaf, 0x89, 0xce, 0x7d, 0xdb, 0x7d, 0x16, 0xc9, 0x37, 0x5c, - 0x4d, 0xa6, 0xc1, 0xec, 0x6c, 0x74, 0xec, 0xfd, 0xfc, 0x7c, 0xe4, 0x45, 0x07, 0x91, 0xab, 0x04, - 0x91, 0xbb, 0x92, 0xc5, 0x19, 0x29, 0x14, 0x1c, 0x36, 0x56, 0x7a, 0x2f, 0x0c, 0xcf, 0x8b, 0xb6, - 0xdb, 0x87, 0xf4, 0x4c, 0xa1, 0xeb, 0x01, 0xa6, 0xf3, 0x9d, 0xcd, 0x47, 0x82, 0xae, 0x24, 0x8f, - 0x2c, 0x11, 0xeb, 0x98, 0xc3, 0x9d, 0xf3, 0xdb, 0xeb, 0x5f, 0x7e, 0xd4, 0xba, 0x7c, 0x07, 0x7f, - 0x56, 0xa0, 0xf4, 0x70, 0xd4, 0xec, 0x25, 0xf1, 0x41, 0x8f, 0x58, 0x41, 0xaf, 0xb5, 0xca, 0x0e, - 0x5b, 0x99, 0x62, 0x76, 0x66, 0xe5, 0x61, 0x33, 0xac, 0x65, 0x67, 0xa5, 0xe2, 0xff, 0x4d, 0x32, - 0x1a, 0x24, 0xbe, 0xd5, 0xa2, 0xdf, 0xce, 0xbd, 0xd8, 0xb3, 0xb7, 0x94, 0x17, 0x0d, 0xbe, 0x92, - 0x49, 0x2d, 0x2b, 0x2c, 0xbc, 0xf2, 0xad, 0x5c, 0x7e, 0x35, 0xdf, 0x39, 0x06, 0x38, 0x52, 0xbb, - 0xf4, 0x35, 0x3d, 0x81, 0xd7, 0x9e, 0xef, 0xfb, 0x88, 0xbd, 0x36, 0x43, 0x1f, 0x17, 0x7c, 0xc1, - 0x4b, 0x29, 0x96, 0x12, 0x50, 0x69, 0x1d, 0xb9, 0x43, 0xd4, 0x07, 0x32, 0x0b, 0x7e, 0x01, 0x7c, - 0xa9, 0x71, 0xbe, 0xac, 0xb0, 0x85, 0x8d, 0xaf, 0x51, 0x6d, 0xd3, 0x4e, 0xe3, 0x9e, 0x08, 0xd0, - 0x0a, 0x61, 0x80, 0x4e, 0xc0, 0xd7, 0x42, 0x93, 0xe2, 0x29, 0xce, 0xe8, 0x51, 0x94, 0xb5, 0x39, - 0x35, 0x5f, 0x57, 0x65, 0x7a, 0xec, 0x1d, 0x79, 0x51, 0xb9, 0x13, 0x91, 0x5d, 0xdc, 0x53, 0xd4, - 0x3d, 0x58, 0x2b, 0xa8, 0xc9, 0xde, 0xae, 0xf5, 0xb0, 0xa3, 0xb6, 0xed, 0xd6, 0xe4, 0x22, 0xb8, - 0x39, 0xf7, 0x90, 0x80, 0x8d, 0x7f, 0x8d, 0x31, 0x10, 0x5d, 0xa9, 0x38, 0x9e, 0x61, 0x48, 0xf5, - 0x1e, 0x41, 0x8e, 0xb8, 0xf9, 0x82, 0xa3, 0x57, 0xf8, 0xe5, 0xa7, 0x72, 0xa0, 0x48, 0x91, 0xf3, - 0x0e, 0x12, 0x21, 0x74, 0xc7, 0xd7, 0x6e, 0x3e, 0x9e, 0xb9, 0x87, 0xd0, 0x37, 0xa0, 0x84, 0xbf, - 0xc0, 0x05, 0xf9, 0x05, 0xee, 0x4b, 0x29, 0x71, 0x88, 0x38, 0xde, 0xf1, 0xda, 0x47, 0xa2, 0x4b, - 0xc1, 0x15, 0x5c, 0xe3, 0xfe, 0xb7, 0xb5, 0x4a, 0x97, 0x29, 0x18, 0xb0, 0x7e, 0xaa, 0x5f, 0x11, - 0x40, 0x53, 0x7c, 0x27, 0x23, 0xcc, 0x4c, 0xcb, 0x7f, 0xff, 0xfe, 0xc7, 0x7c, 0xff, 0x72, 0x68, - 0x5a, 0xc9, 0x02, 0x7e, 0x63, 0xdf, 0x36, 0xb1, 0x94, 0xc0, 0x87, 0xde, 0xdb, 0x37, 0xef, 0xaf, - 0xbd, 0x13, 0x6f, 0xd2, 0xe8, 0xc9, 0x08, 0x1a, 0x89, 0x87, 0x76, 0x3c, 0xe0, 0x2f, 0x6c, 0x5a, - 0xd0, 0x69, 0x3e, 0xf4, 0x26, 0x0c, 0x6f, 0x77, 0xfc, 0x68, 0xde, 0x98, 0xce, 0xce, 0xf1, 0xa8, - 0x8c, 0x17, 0xd2, 0xff, 0xac, 0x4c, 0x12, 0xed, 0x0a, 0x8d, 0x17, 0x87, 0x27, 0x93, 0xbd, 0xbe, - 0xf6, 0xa6, 0x02, 0xed, 0x4d, 0x26, 0xea, 0x27, 0x82, 0x48, 0x7a, 0xec, 0x19, 0x59, 0xe0, 0xdd, - 0x44, 0x8c, 0xd7, 0xe1, 0x08, 0x21, 0xb7, 0x18, 0x06, 0xde, 0xaa, 0xed, 0xb0, 0xc4, 0x7b, 0xa1, - 0xb9, 0x4f, 0x27, 0xcd, 0xbf, 0x0f, 0xff, 0x01, 0x5f, 0x9f, 0x85, 0x5a, 0x56, 0x0c, 0x00, 0x00, -}; +/* + * Auto-generated from ota_page.html — do not edit manually. + * Regenerate: python3 compress_html.py + */ + +#pragma once + +#include + +#define OTA_PAGE_GZ_SIZE 1408 + +static const uint8_t ota_page_gz[OTA_PAGE_GZ_SIZE] = { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x95, 0x57, 0x6d, 0x6f, 0xdb, 0x36, + 0x10, 0xfe, 0xee, 0x5f, 0xa1, 0xa9, 0x08, 0x64, 0x37, 0xb1, 0x2c, 0x3b, 0x2f, 0x75, 0xa4, 0xc8, + 0xc0, 0xda, 0xb5, 0xd8, 0xb0, 0x75, 0x2d, 0xda, 0x14, 0xd8, 0x56, 0xf4, 0x03, 0x25, 0x9e, 0x2c, + 0x36, 0x32, 0xa9, 0x91, 0x54, 0x62, 0x4f, 0x30, 0xb0, 0x1f, 0xb1, 0x5f, 0xb8, 0x5f, 0xb2, 0x23, + 0x25, 0x25, 0xb2, 0x1b, 0x0f, 0xed, 0x97, 0x48, 0x26, 0x8f, 0xcf, 0xdd, 0x3d, 0xf7, 0xdc, 0x51, + 0xb9, 0xfa, 0xee, 0x87, 0x37, 0x2f, 0xae, 0x7f, 0x7f, 0xfb, 0xd2, 0xc9, 0xf5, 0xaa, 0x58, 0x0c, + 0xae, 0xba, 0x07, 0x10, 0x8a, 0x8f, 0x15, 0x68, 0xe2, 0xa4, 0x39, 0x91, 0x0a, 0x74, 0xec, 0x56, + 0x3a, 0x1b, 0xcf, 0xdd, 0x6e, 0x99, 0x93, 0x15, 0xc4, 0xee, 0x2d, 0x83, 0xbb, 0x52, 0x48, 0xed, + 0x3a, 0xa9, 0xe0, 0x1a, 0x38, 0x9a, 0xdd, 0x31, 0xaa, 0xf3, 0x98, 0xc2, 0x2d, 0x4b, 0x61, 0x6c, + 0x7f, 0x9c, 0x30, 0xce, 0x34, 0x23, 0xc5, 0x58, 0xa5, 0xa4, 0x80, 0x78, 0x6a, 0x30, 0x34, 0xd3, + 0x05, 0x2c, 0xfe, 0x80, 0x32, 0x7f, 0x21, 0x24, 0x38, 0x6f, 0xae, 0xbf, 0xbf, 0x9a, 0x34, 0x6b, + 0x83, 0x2b, 0xa5, 0x37, 0xe6, 0xf9, 0xb4, 0x4e, 0xc4, 0x7a, 0xac, 0xd8, 0x5f, 0x8c, 0x2f, 0xc3, + 0x44, 0x48, 0x0a, 0x72, 0x8c, 0x2b, 0xd1, 0x8a, 0xc8, 0x25, 0xe3, 0x61, 0x10, 0x95, 0x84, 0x52, + 0xb3, 0x17, 0x6c, 0x07, 0x89, 0xa0, 0x9b, 0x3a, 0xc3, 0x10, 0xc6, 0x19, 0x59, 0xb1, 0x62, 0x13, + 0xaa, 0x8d, 0xd2, 0xb0, 0x1a, 0x57, 0xec, 0x44, 0x11, 0xae, 0xc6, 0x0a, 0x24, 0xcb, 0xa2, 0x84, + 0xa4, 0x37, 0x4b, 0x29, 0x2a, 0x4e, 0xc3, 0x27, 0x53, 0x32, 0x25, 0x33, 0x88, 0x52, 0x51, 0x08, + 0x19, 0x3e, 0x01, 0x80, 0x88, 0x32, 0x55, 0x16, 0x64, 0x13, 0x66, 0x05, 0xac, 0xa3, 0xcf, 0x95, + 0xd2, 0x2c, 0xdb, 0x8c, 0xdb, 0xb4, 0xc2, 0x14, 0xff, 0x80, 0x8c, 0x48, 0xc1, 0x96, 0x7c, 0xcc, + 0x10, 0x5a, 0x75, 0x4b, 0x2b, 0xc6, 0xc7, 0x39, 0xb0, 0x65, 0xae, 0xc3, 0x69, 0x10, 0xdc, 0xe6, + 0xdb, 0x81, 0x9f, 0xd6, 0x3b, 0x9e, 0x2e, 0x66, 0xd3, 0x53, 0x88, 0xda, 0x0c, 0x24, 0xa1, 0xac, + 0x52, 0xe1, 0x74, 0x56, 0xae, 0xef, 0x13, 0x98, 0xc1, 0x0a, 0xb3, 0x5a, 0x37, 0x6c, 0x85, 0x67, + 0xb3, 0x00, 0xf7, 0x9a, 0xf7, 0xcb, 0xe0, 0x28, 0xd2, 0xb0, 0xd6, 0x63, 0xeb, 0xb8, 0x75, 0xb9, + 0x1d, 0xe4, 0xb3, 0xba, 0x61, 0x01, 0x09, 0xd1, 0x5a, 0xac, 0x42, 0xff, 0x1c, 0x21, 0xda, 0x5c, + 0x82, 0x2c, 0xc3, 0x18, 0x18, 0xad, 0xdb, 0xdf, 0xf3, 0xf9, 0x3c, 0xb2, 0xd4, 0x20, 0x95, 0x10, + 0xfa, 0xf3, 0x73, 0xeb, 0xad, 0x7f, 0x7a, 0x6a, 0x8e, 0x6f, 0x07, 0x8c, 0x97, 0x95, 0xfe, 0xa8, + 0x37, 0x25, 0xc4, 0x19, 0x2b, 0xe0, 0x53, 0xdd, 0x31, 0xc2, 0x05, 0x07, 0x84, 0x4c, 0x34, 0xbf, + 0x5f, 0x62, 0xbc, 0x60, 0x1c, 0xc6, 0x49, 0x21, 0xd2, 0x9b, 0xfb, 0x3c, 0xfc, 0x67, 0xb0, 0x72, + 0x2c, 0xd8, 0x5e, 0xb6, 0x73, 0x4c, 0xa8, 0x59, 0xb1, 0x58, 0x51, 0x5a, 0x49, 0x85, 0xa1, 0x95, + 0x82, 0x59, 0x0a, 0x1f, 0xa2, 0x9b, 0xe2, 0x51, 0xfb, 0xeb, 0xae, 0x61, 0xf4, 0x22, 0x08, 0x22, + 0x2d, 0xb1, 0x82, 0x28, 0x20, 0xc1, 0xc3, 0x07, 0x5a, 0x1d, 0x7f, 0xa6, 0x30, 0x24, 0x05, 0xc5, + 0x0e, 0xd7, 0x41, 0x76, 0x7a, 0x76, 0x11, 0xf4, 0xaa, 0xda, 0xd8, 0x84, 0xb9, 0xb8, 0x05, 0xb9, + 0x5b, 0x15, 0x72, 0x3e, 0x7b, 0x76, 0x81, 0xdb, 0x55, 0xb9, 0x07, 0x11, 0x24, 0xf3, 0xcb, 0xb3, + 0x0e, 0x22, 0xcb, 0xb2, 0x8e, 0xac, 0x02, 0x32, 0x1d, 0x36, 0x4c, 0x99, 0x43, 0x8f, 0x60, 0x06, + 0x01, 0x39, 0x9d, 0x4f, 0xdb, 0x6d, 0x64, 0x8a, 0x24, 0x05, 0xd0, 0x1d, 0x8b, 0xf3, 0xf3, 0xf3, + 0x2e, 0x79, 0x0a, 0x19, 0xa9, 0x0a, 0x6d, 0x78, 0x25, 0xb2, 0x6e, 0xca, 0x8d, 0x02, 0x3a, 0x8a, + 0x5a, 0x31, 0xcd, 0xce, 0x0c, 0x69, 0x7d, 0x74, 0x12, 0xa0, 0x66, 0x1f, 0xd3, 0x51, 0xdb, 0x11, + 0x53, 0x1f, 0x85, 0xe4, 0x04, 0x91, 0x89, 0x2b, 0x2b, 0xc4, 0x5d, 0x98, 0x33, 0x4a, 0x81, 0x47, + 0x7b, 0x65, 0xc4, 0xda, 0x16, 0xf5, 0x83, 0x62, 0x8f, 0xfa, 0x4e, 0x4c, 0x51, 0x89, 0x1c, 0x2f, + 0x0d, 0x3a, 0x6a, 0x6d, 0x78, 0x19, 0x50, 0x58, 0x9e, 0x18, 0x49, 0x9d, 0xb4, 0xc4, 0x8c, 0x1e, + 0x0b, 0xa0, 0x57, 0x22, 0x9b, 0x88, 0xe3, 0x9f, 0xaa, 0x56, 0xc1, 0xd8, 0x9c, 0x7e, 0x99, 0xea, + 0xfa, 0x80, 0x02, 0xb5, 0x28, 0x43, 0xff, 0xd4, 0x72, 0xaa, 0x74, 0xdd, 0x5b, 0xbc, 0x57, 0x42, + 0x73, 0xe6, 0xd2, 0x1c, 0xe9, 0x75, 0x9a, 0x7f, 0x66, 0xcf, 0x88, 0x9b, 0x4e, 0xe5, 0x4d, 0x74, + 0xb8, 0x84, 0x35, 0xe9, 0xca, 0xff, 0xec, 0x2c, 0x3d, 0x4d, 0xb7, 0x83, 0xab, 0x49, 0x3b, 0x51, + 0xae, 0x26, 0xed, 0x60, 0x33, 0xe3, 0x02, 0x1f, 0x94, 0xdd, 0x3a, 0x69, 0x41, 0x94, 0x8a, 0xdd, + 0xd4, 0x0c, 0xa5, 0x7c, 0xb6, 0x33, 0x91, 0x9c, 0x0f, 0x25, 0x25, 0x1a, 0xf0, 0xd4, 0x6c, 0xd7, + 0x98, 0x51, 0xd7, 0x61, 0x34, 0x76, 0xcd, 0x90, 0xa3, 0xee, 0xe2, 0x6a, 0x82, 0x7b, 0x8d, 0x85, + 0xc1, 0xae, 0xb0, 0xa7, 0x78, 0x67, 0x8a, 0x3d, 0xe3, 0xa0, 0x00, 0x5d, 0x47, 0xf0, 0xb4, 0x60, + 0xe9, 0x0d, 0x1e, 0x12, 0x69, 0xb5, 0x42, 0x6e, 0xfd, 0x25, 0xe8, 0x97, 0x05, 0x98, 0xd7, 0xe7, + 0x9b, 0x9f, 0xe8, 0xd0, 0xcb, 0xbc, 0x91, 0x6f, 0x6d, 0x86, 0x23, 0x77, 0xf1, 0x1e, 0x0a, 0x48, + 0xb5, 0xf3, 0x8a, 0xc9, 0xd5, 0x1d, 0x91, 0x18, 0x43, 0x03, 0x8b, 0xf8, 0xb6, 0x4b, 0x1d, 0xdb, + 0xa5, 0xae, 0x69, 0xd3, 0x26, 0x94, 0xcc, 0x75, 0x48, 0x9a, 0x42, 0x89, 0x03, 0xd8, 0x4f, 0x18, + 0xb7, 0xfe, 0x72, 0xc2, 0x97, 0xc6, 0x48, 0x0d, 0x75, 0xce, 0xd4, 0xc8, 0x7d, 0x34, 0x38, 0x94, + 0x6a, 0x83, 0x50, 0x25, 0xae, 0xd3, 0x69, 0xf6, 0x21, 0xda, 0xaa, 0x34, 0xd1, 0x7c, 0x28, 0x0b, + 0x41, 0x68, 0x2f, 0x88, 0x87, 0x8c, 0x1b, 0xef, 0xe8, 0xd0, 0x72, 0x1c, 0xbb, 0xfd, 0xba, 0xce, + 0x77, 0x6b, 0x38, 0xef, 0x0d, 0x29, 0x1c, 0x4a, 0x3b, 0xc4, 0xdd, 0x87, 0x44, 0x64, 0x13, 0x8e, + 0x79, 0x59, 0xf4, 0x77, 0x8c, 0x6c, 0xdb, 0x5c, 0xcd, 0x5b, 0x7b, 0xf8, 0x11, 0x08, 0x54, 0x5b, + 0x63, 0x67, 0x5e, 0x1e, 0x33, 0x50, 0xed, 0xbe, 0xea, 0x6d, 0xb7, 0x0f, 0x95, 0x4a, 0x56, 0xea, + 0xc5, 0xe0, 0x96, 0x48, 0xc7, 0x90, 0x1b, 0x0d, 0xb2, 0x8a, 0xa7, 0x46, 0xd8, 0x0e, 0xd2, 0xc8, + 0x46, 0xb5, 0x59, 0x8c, 0x99, 0xe9, 0x21, 0x50, 0x1f, 0x83, 0x4f, 0x11, 0xcb, 0x86, 0xe6, 0x7d, + 0x54, 0x1f, 0x2e, 0x2b, 0xc7, 0xba, 0x9a, 0x19, 0xfe, 0xa2, 0xbd, 0x21, 0x8d, 0xbd, 0x6f, 0xae, + 0xcf, 0x63, 0xcf, 0x19, 0x7a, 0xc7, 0xaf, 0x89, 0xce, 0x7d, 0xdb, 0x7d, 0x16, 0xc9, 0x37, 0x5c, + 0x4d, 0xa6, 0xc1, 0xec, 0x6c, 0x74, 0xec, 0xfd, 0xfc, 0x7c, 0xe4, 0x45, 0x07, 0x91, 0xab, 0x04, + 0x91, 0xbb, 0x92, 0xc5, 0x19, 0x29, 0x14, 0x1c, 0x36, 0x56, 0x7a, 0x2f, 0x0c, 0xcf, 0x8b, 0xb6, + 0xdb, 0x87, 0xf4, 0x4c, 0xa1, 0xeb, 0x01, 0xa6, 0xf3, 0x9d, 0xcd, 0x47, 0x82, 0xae, 0x24, 0x8f, + 0x2c, 0x11, 0xeb, 0x98, 0xc3, 0x9d, 0xf3, 0xdb, 0xeb, 0x5f, 0x7e, 0xd4, 0xba, 0x7c, 0x07, 0x7f, + 0x56, 0xa0, 0xf4, 0x70, 0xd4, 0xec, 0x25, 0xf1, 0x41, 0x8f, 0x58, 0x41, 0xaf, 0xb5, 0xca, 0x0e, + 0x5b, 0x99, 0x62, 0x76, 0x66, 0xe5, 0x61, 0x33, 0xac, 0x65, 0x67, 0xa5, 0xe2, 0xff, 0x4d, 0x32, + 0x1a, 0x24, 0xbe, 0xd5, 0xa2, 0xdf, 0xce, 0xbd, 0xd8, 0xb3, 0xb7, 0x94, 0x17, 0x0d, 0xbe, 0x92, + 0x49, 0x2d, 0x2b, 0x2c, 0xbc, 0xf2, 0xad, 0x5c, 0x7e, 0x35, 0xdf, 0x39, 0x06, 0x38, 0x52, 0xbb, + 0xf4, 0x35, 0x3d, 0x81, 0xd7, 0x9e, 0xef, 0xfb, 0x88, 0xbd, 0x36, 0x43, 0x1f, 0x17, 0x7c, 0xc1, + 0x4b, 0x29, 0x96, 0x12, 0x50, 0x69, 0x1d, 0xb9, 0x43, 0xd4, 0x07, 0x32, 0x0b, 0x7e, 0x01, 0x7c, + 0xa9, 0x71, 0xbe, 0xac, 0xb0, 0x85, 0x8d, 0xaf, 0x51, 0x6d, 0xd3, 0x4e, 0xe3, 0x9e, 0x08, 0xd0, + 0x0a, 0x61, 0x80, 0x4e, 0xc0, 0xd7, 0x42, 0x93, 0xe2, 0x29, 0xce, 0xe8, 0x51, 0x94, 0xb5, 0x39, + 0x35, 0x5f, 0x57, 0x65, 0x7a, 0xec, 0x1d, 0x79, 0x51, 0xb9, 0x13, 0x91, 0x5d, 0xdc, 0x53, 0xd4, + 0x3d, 0x58, 0x2b, 0xa8, 0xc9, 0xde, 0xae, 0xf5, 0xb0, 0xa3, 0xb6, 0xed, 0xd6, 0xe4, 0x22, 0xb8, + 0x39, 0xf7, 0x90, 0x80, 0x8d, 0x7f, 0x8d, 0x31, 0x10, 0x5d, 0xa9, 0x38, 0x9e, 0x61, 0x48, 0xf5, + 0x1e, 0x41, 0x8e, 0xb8, 0xf9, 0x82, 0xa3, 0x57, 0xf8, 0xe5, 0xa7, 0x72, 0xa0, 0x48, 0x91, 0xf3, + 0x0e, 0x12, 0x21, 0x74, 0xc7, 0xd7, 0x6e, 0x3e, 0x9e, 0xb9, 0x87, 0xd0, 0x37, 0xa0, 0x84, 0xbf, + 0xc0, 0x05, 0xf9, 0x05, 0xee, 0x4b, 0x29, 0x71, 0x88, 0x38, 0xde, 0xf1, 0xda, 0x47, 0xa2, 0x4b, + 0xc1, 0x15, 0x5c, 0xe3, 0xfe, 0xb7, 0xb5, 0x4a, 0x97, 0x29, 0x18, 0xb0, 0x7e, 0xaa, 0x5f, 0x11, + 0x40, 0x53, 0x7c, 0x27, 0x23, 0xcc, 0x4c, 0xcb, 0x7f, 0xff, 0xfe, 0xc7, 0x7c, 0xff, 0x72, 0x68, + 0x5a, 0xc9, 0x02, 0x7e, 0x63, 0xdf, 0x36, 0xb1, 0x94, 0xc0, 0x87, 0xde, 0xdb, 0x37, 0xef, 0xaf, + 0xbd, 0x13, 0x6f, 0xd2, 0xe8, 0xc9, 0x08, 0x1a, 0x89, 0x87, 0x76, 0x3c, 0xe0, 0x2f, 0x6c, 0x5a, + 0xd0, 0x69, 0x3e, 0xf4, 0x26, 0x0c, 0x6f, 0x77, 0xfc, 0x68, 0xde, 0x98, 0xce, 0xce, 0xf1, 0xa8, + 0x8c, 0x17, 0xd2, 0xff, 0xac, 0x4c, 0x12, 0xed, 0x0a, 0x8d, 0x17, 0x87, 0x27, 0x93, 0xbd, 0xbe, + 0xf6, 0xa6, 0x02, 0xed, 0x4d, 0x26, 0xea, 0x27, 0x82, 0x48, 0x7a, 0xec, 0x19, 0x59, 0xe0, 0xdd, + 0x44, 0x8c, 0xd7, 0xe1, 0x08, 0x21, 0xb7, 0x18, 0x06, 0xde, 0xaa, 0xed, 0xb0, 0xc4, 0x7b, 0xa1, + 0xb9, 0x4f, 0x27, 0xcd, 0xbf, 0x0f, 0xff, 0x01, 0x5f, 0x9f, 0x85, 0x5a, 0x56, 0x0c, 0x00, 0x00, +}; diff --git a/zephcore/adapters/ota/wifi_ota.c b/zephcore/adapters/ota/wifi_ota.c index de3b5f0..0e0a9f9 100644 --- a/zephcore/adapters/ota/wifi_ota.c +++ b/zephcore/adapters/ota/wifi_ota.c @@ -1,530 +1,530 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * WiFi OTA Firmware Update - * - * Starts a WiFi AP + HTTP server for browser-based firmware upload. - * Mirrors Arduino MeshCore's ElegantOTA: - * - WiFi SoftAP "ZephCore-OTA" at 192.168.100.1 - * - DHCP server for client IP assignment - * - HTTP server with upload page at /update - * - Firmware written to MCUboot slot1 via flash_img API - * - Reboot after successful upload (MCUboot activates new image) - */ - -#include "wifi_ota.h" -#include "ota_page.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -LOG_MODULE_REGISTER(wifi_ota); - -/* ========== Configuration ========== */ - -#define OTA_AP_SSID CONFIG_ZEPHCORE_OTA_AP_SSID -#define OTA_STATIC_IP CONFIG_ZEPHCORE_OTA_AP_IP -#define OTA_NETMASK "255.255.255.0" -#define OTA_DHCP_BASE "192.168.100.10" - -/* ========== State ========== */ - -static bool ota_active; -static struct flash_img_context flash_ctx; -static size_t total_bytes_received; -static bool flash_ctx_initialized; - -/* Identity strings for web page */ -static char identity_json[128]; -static char home_html[384]; - -/* ========== Reboot work (delayed to let HTTP response send) ========== */ - -static void ota_reboot_fn(struct k_work *work) -{ - LOG_INF("OTA reboot"); - sys_reboot(SYS_REBOOT_COLD); -} - -static K_WORK_DELAYABLE_DEFINE(ota_reboot_work, ota_reboot_fn); - -/* ========== HTTP Service ========== */ - -static uint16_t http_port = 80; - -/* Forward declarations */ -static int home_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data); -static int identity_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data); -static int update_page_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data); -static int upload_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data); - -/* HTTP service on port 80 */ -HTTP_SERVICE_DEFINE(ota_service, NULL, &http_port, 1, 5, NULL, NULL, NULL); - -/* GET / — home page */ -static struct http_resource_detail_dynamic home_detail = { - .common = { - .type = HTTP_RESOURCE_TYPE_DYNAMIC, - .bitmask_of_supported_http_methods = BIT(HTTP_GET), - }, - .cb = home_handler, -}; -HTTP_RESOURCE_DEFINE(home_res, ota_service, "/", &home_detail); - -/* GET /identity — JSON device info (for JS fetch) */ -static struct http_resource_detail_dynamic identity_detail = { - .common = { - .type = HTTP_RESOURCE_TYPE_DYNAMIC, - .bitmask_of_supported_http_methods = BIT(HTTP_GET), - }, - .cb = identity_handler, -}; -HTTP_RESOURCE_DEFINE(identity_res, ota_service, "/identity", &identity_detail); - -/* GET /update — upload page (gzip-compressed HTML) */ -static struct http_resource_detail_dynamic update_page_detail = { - .common = { - .type = HTTP_RESOURCE_TYPE_DYNAMIC, - .bitmask_of_supported_http_methods = BIT(HTTP_GET), - }, - .cb = update_page_handler, -}; -HTTP_RESOURCE_DEFINE(update_res, ota_service, "/update", &update_page_detail); - -/* POST /upload — firmware receive */ -static struct http_resource_detail_dynamic upload_detail = { - .common = { - .type = HTTP_RESOURCE_TYPE_DYNAMIC, - .bitmask_of_supported_http_methods = BIT(HTTP_POST), - }, - .cb = upload_handler, -}; -HTTP_RESOURCE_DEFINE(upload_res, ota_service, "/upload", &upload_detail); - -/* ========== HTTP Handlers ========== */ - -static int home_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data) -{ - LOG_DBG("home_handler status=%d", status); - if (status == HTTP_SERVER_REQUEST_DATA_FINAL) { - response_ctx->body = (const uint8_t *)home_html; - response_ctx->body_len = strlen(home_html); - response_ctx->final_chunk = true; - response_ctx->status = HTTP_200_OK; - LOG_DBG("Served home page (%u bytes)", (unsigned)response_ctx->body_len); - } - return 0; -} - -static int identity_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data) -{ - static const char ct[] = "application/json"; - - LOG_DBG("identity_handler status=%d", status); - if (status == HTTP_SERVER_REQUEST_DATA_FINAL) { - response_ctx->body = (const uint8_t *)identity_json; - response_ctx->body_len = strlen(identity_json); - response_ctx->final_chunk = true; - response_ctx->status = HTTP_200_OK; - response_ctx->header_count = 1; - static struct http_header ct_hdr = { - .name = "Content-Type", - .value = ct, - }; - response_ctx->headers = &ct_hdr; - } - return 0; -} - -static int update_page_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data) -{ - LOG_DBG("update_page_handler status=%d", status); - if (status == HTTP_SERVER_REQUEST_DATA_FINAL) { - response_ctx->body = ota_page_gz; - response_ctx->body_len = OTA_PAGE_GZ_SIZE; - response_ctx->final_chunk = true; - response_ctx->status = HTTP_200_OK; - - /* Headers for gzip-compressed HTML */ - static struct http_header hdrs[] = { - { .name = "Content-Type", .value = "text/html" }, - { .name = "Content-Encoding", .value = "gzip" }, - }; - response_ctx->headers = hdrs; - response_ctx->header_count = 2; - } - return 0; -} - -static int upload_handler(struct http_client_ctx *client, - enum http_transaction_status status, - const struct http_request_ctx *request_ctx, - struct http_response_ctx *response_ctx, - void *user_data) -{ - static const char ok_resp[] = "OK"; - static const char fail_resp[] = "FAIL"; - int ret; - - if (status == HTTP_SERVER_REQUEST_DATA_MORE || - status == HTTP_SERVER_REQUEST_DATA_FINAL) { - - /* First call: initialize flash context. The first dispatch from - * the HTTP server may have data_len==0 if headers and body - * landed in separate TCP segments — flash_img_buffered_write - * handles zero-length writes fine. */ - if (!flash_ctx_initialized) { - ret = flash_img_init(&flash_ctx); - if (ret) { - LOG_ERR("flash_img_init failed: %d", ret); - response_ctx->status = HTTP_500_INTERNAL_SERVER_ERROR; - response_ctx->body = (const uint8_t *)fail_resp; - response_ctx->body_len = sizeof(fail_resp) - 1; - response_ctx->final_chunk = true; - return 0; - } - flash_ctx_initialized = true; - total_bytes_received = 0; - LOG_INF("OTA upload started"); - } - - /* Write chunk to flash (slot1). Always call on final, even with - * data_len==0, so the trailing partial block is flushed and the - * flash_area handle is closed. */ - bool is_final = (status == HTTP_SERVER_REQUEST_DATA_FINAL); - - if (request_ctx->data_len > 0 || is_final) { - ret = flash_img_buffered_write(&flash_ctx, - request_ctx->data, - request_ctx->data_len, - is_final); - if (ret) { - LOG_ERR("Flash write failed at %u bytes: %d", - (unsigned)total_bytes_received, ret); - flash_ctx_initialized = false; - response_ctx->status = HTTP_500_INTERNAL_SERVER_ERROR; - response_ctx->body = (const uint8_t *)fail_resp; - response_ctx->body_len = sizeof(fail_resp) - 1; - response_ctx->final_chunk = true; - return 0; - } - total_bytes_received += request_ctx->data_len; - } - - if (is_final) { - LOG_INF("OTA upload complete: %u bytes", - (unsigned)total_bytes_received); - - /* No client-side validation — let MCUboot decide on next - * boot. A bogus image fails signature/magic check there - * and the bootloader falls back to slot0. Worst case is - * an unnecessary reboot; we never brick. */ - - /* Mark new image for boot (overwrite-only: permanent) */ - ret = boot_request_upgrade(BOOT_UPGRADE_PERMANENT); - if (ret) { - LOG_ERR("boot_request_upgrade failed: %d", ret); - flash_ctx_initialized = false; - response_ctx->status = HTTP_500_INTERNAL_SERVER_ERROR; - response_ctx->body = (const uint8_t *)fail_resp; - response_ctx->body_len = sizeof(fail_resp) - 1; - response_ctx->final_chunk = true; - return 0; - } - - flash_ctx_initialized = false; - - response_ctx->status = HTTP_200_OK; - response_ctx->body = (const uint8_t *)ok_resp; - response_ctx->body_len = sizeof(ok_resp) - 1; - response_ctx->final_chunk = true; - - /* Reboot after 2s (let HTTP response send) */ - k_work_schedule(&ota_reboot_work, K_SECONDS(2)); - } - } else if (status == HTTP_SERVER_TRANSACTION_ABORTED) { - LOG_WRN("OTA upload aborted at %u bytes", - (unsigned)total_bytes_received); - /* Flush+close the flash_area to release the handle. The - * partial slot1 contents are harmless — MCUboot will reject - * an unfinished image, and the next upload will progressively - * re-erase as it writes. */ - if (flash_ctx_initialized) { - (void)flash_img_buffered_write(&flash_ctx, NULL, 0, true); - } - flash_ctx_initialized = false; - total_bytes_received = 0; - } - - return 0; -} - -/* ========== WiFi Event Monitoring ========== */ - -static struct net_mgmt_event_callback wifi_mgmt_cb; - -static void wifi_mgmt_event_handler(struct net_mgmt_event_callback *cb, - uint64_t mgmt_event, - struct net_if *iface) -{ - switch (mgmt_event) { - case NET_EVENT_WIFI_AP_ENABLE_RESULT: - LOG_DBG("WiFi AP enable result event received"); - break; - case NET_EVENT_WIFI_AP_DISABLE_RESULT: - LOG_DBG("WiFi AP disable result event"); - break; - case NET_EVENT_WIFI_AP_STA_CONNECTED: - LOG_INF("WiFi client CONNECTED to AP"); - break; - case NET_EVENT_WIFI_AP_STA_DISCONNECTED: - LOG_INF("WiFi client DISCONNECTED from AP"); - break; - default: - LOG_DBG("WiFi mgmt event: 0x%016llx", mgmt_event); - break; - } -} - -/* ========== WiFi AP Setup ========== */ - -static int wifi_ap_start(void) -{ - struct net_if *iface = net_if_get_default(); - - if (!iface) { - LOG_ERR("No network interface"); - return -ENODEV; - } - - LOG_DBG("Network iface: %p, idx=%d", iface, net_if_get_by_iface(iface)); - - /* Register WiFi management event callback */ - net_mgmt_init_event_callback(&wifi_mgmt_cb, wifi_mgmt_event_handler, - NET_EVENT_WIFI_AP_ENABLE_RESULT | - NET_EVENT_WIFI_AP_DISABLE_RESULT | - NET_EVENT_WIFI_AP_STA_CONNECTED | - NET_EVENT_WIFI_AP_STA_DISCONNECTED); - net_mgmt_add_event_callback(&wifi_mgmt_cb); - - /* Set static IP */ - struct in_addr addr, netmask; - - if (net_addr_pton(AF_INET, OTA_STATIC_IP, &addr)) { - LOG_ERR("Invalid IP: %s", OTA_STATIC_IP); - return -EINVAL; - } - if (net_addr_pton(AF_INET, OTA_NETMASK, &netmask)) { - LOG_ERR("Invalid netmask"); - return -EINVAL; - } - - struct net_if_addr *ifaddr = net_if_ipv4_addr_add(iface, &addr, NET_ADDR_MANUAL, 0); - if (!ifaddr) { - LOG_ERR("Failed to set static IP %s", OTA_STATIC_IP); - return -ENOMEM; - } - net_if_ipv4_set_netmask_by_addr(iface, &addr, &netmask); - net_if_ipv4_set_gw(iface, &addr); - LOG_INF("Static IP set: %s/%s", OTA_STATIC_IP, OTA_NETMASK); - - /* Enable WiFi AP */ - struct wifi_connect_req_params ap_params = {0}; - - ap_params.ssid = (const uint8_t *)OTA_AP_SSID; - ap_params.ssid_length = strlen(OTA_AP_SSID); - ap_params.channel = WIFI_CHANNEL_ANY; - ap_params.security = WIFI_SECURITY_TYPE_NONE; - ap_params.band = WIFI_FREQ_BAND_2_4_GHZ; - - LOG_INF("Enabling WiFi AP: SSID=%s, channel=any, security=open", OTA_AP_SSID); - int ret = net_mgmt(NET_REQUEST_WIFI_AP_ENABLE, iface, - &ap_params, sizeof(ap_params)); - if (ret) { - LOG_ERR("WiFi AP enable failed: %d", ret); - return ret; - } - - LOG_INF("WiFi AP started: SSID=%s", OTA_AP_SSID); - - /* Bring the interface up explicitly */ - if (!net_if_is_up(iface)) { - LOG_WRN("Interface not up, bringing up..."); - ret = net_if_up(iface); - if (ret) { - LOG_ERR("net_if_up failed: %d", ret); - } - } - - /* Start DHCP server */ - struct in_addr dhcp_base; - - if (net_addr_pton(AF_INET, OTA_DHCP_BASE, &dhcp_base) == 0) { - ret = net_dhcpv4_server_start(iface, &dhcp_base); - if (ret) { - LOG_WRN("DHCP server start failed: %d (non-fatal)", ret); - } else { - LOG_INF("DHCP server started (pool: %s+)", OTA_DHCP_BASE); - } - } - - return 0; -} - -static int wifi_ap_stop(void) -{ - struct net_if *iface = net_if_get_default(); - - if (!iface) { - return -ENODEV; - } - - net_dhcpv4_server_stop(iface); - - int ret = net_mgmt(NET_REQUEST_WIFI_AP_DISABLE, iface, NULL, 0); - - if (ret) { - LOG_WRN("WiFi AP disable failed: %d", ret); - } - - /* Remove static IP */ - struct in_addr addr; - - if (net_addr_pton(AF_INET, OTA_STATIC_IP, &addr) == 0) { - net_if_ipv4_addr_rm(iface, &addr); - } - - return ret; -} - -/* ========== Public API ========== */ - -int wifi_ota_start(const char *node_name, const char *board_name) -{ - if (ota_active) { - return -EALREADY; - } - - /* Prepare identity strings for web page */ - snprintf(identity_json, sizeof(identity_json), - "{\"name\":\"%s\",\"board\":\"%s\"}", - node_name ? node_name : "Unknown", - board_name ? board_name : "Unknown"); - - snprintf(home_html, sizeof(home_html), - "" - "

ZephCore OTA: %s (%s)
" - "Go to Update Page

" - "", - node_name ? node_name : "Unknown", - board_name ? board_name : "Unknown"); - - /* Start WiFi AP */ - int ret = wifi_ap_start(); - - if (ret) { - return ret; - } - - /* Start HTTP server */ - LOG_INF("Starting HTTP server on port %u...", http_port); - ret = http_server_start(); - if (ret) { - LOG_ERR("HTTP server start failed: %d", ret); - wifi_ap_stop(); - return ret; - } - - ota_active = true; - flash_ctx_initialized = false; - total_bytes_received = 0; - - LOG_INF("OTA server ready at http://%s/update", OTA_STATIC_IP); - LOG_INF("HTTP routes: / (home), /identity (json), /update (upload page), /upload (POST)"); - return 0; -} - -int wifi_ota_stop(void) -{ - if (!ota_active) { - return 0; - } - - /* Cancel any pending post-upload reboot — user explicitly asked us to - * stop, so don't reboot out from under them. */ - (void)k_work_cancel_delayable(&ota_reboot_work); - - /* If an upload was in flight, flush+close the flash_area handle. */ - if (flash_ctx_initialized) { - (void)flash_img_buffered_write(&flash_ctx, NULL, 0, true); - } - - http_server_stop(); - wifi_ap_stop(); - ota_active = false; - flash_ctx_initialized = false; - total_bytes_received = 0; - - LOG_INF("OTA server stopped"); - return 0; -} - -bool wifi_ota_is_active(void) -{ - return ota_active; -} - -void wifi_ota_confirm_image(void) -{ -#if IS_ENABLED(CONFIG_BOOTLOADER_MCUBOOT) - if (!boot_is_img_confirmed()) { - int ret = boot_write_img_confirmed(); - - if (ret) { - LOG_ERR("Failed to confirm MCUboot image: %d", ret); - } else { - LOG_INF("MCUboot image confirmed"); - } - } -#endif -} +/* + * SPDX-License-Identifier: Apache-2.0 + * WiFi OTA Firmware Update + * + * Starts a WiFi AP + HTTP server for browser-based firmware upload. + * Mirrors Arduino MeshCore's ElegantOTA: + * - WiFi SoftAP "ZephCore-OTA" at 192.168.100.1 + * - DHCP server for client IP assignment + * - HTTP server with upload page at /update + * - Firmware written to MCUboot slot1 via flash_img API + * - Reboot after successful upload (MCUboot activates new image) + */ + +#include "wifi_ota.h" +#include "ota_page.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +LOG_MODULE_REGISTER(wifi_ota); + +/* ========== Configuration ========== */ + +#define OTA_AP_SSID CONFIG_ZEPHCORE_OTA_AP_SSID +#define OTA_STATIC_IP CONFIG_ZEPHCORE_OTA_AP_IP +#define OTA_NETMASK "255.255.255.0" +#define OTA_DHCP_BASE "192.168.100.10" + +/* ========== State ========== */ + +static bool ota_active; +static struct flash_img_context flash_ctx; +static size_t total_bytes_received; +static bool flash_ctx_initialized; + +/* Identity strings for web page */ +static char identity_json[128]; +static char home_html[384]; + +/* ========== Reboot work (delayed to let HTTP response send) ========== */ + +static void ota_reboot_fn(struct k_work *work) +{ + LOG_INF("OTA reboot"); + sys_reboot(SYS_REBOOT_COLD); +} + +static K_WORK_DELAYABLE_DEFINE(ota_reboot_work, ota_reboot_fn); + +/* ========== HTTP Service ========== */ + +static uint16_t http_port = 80; + +/* Forward declarations */ +static int home_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data); +static int identity_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data); +static int update_page_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data); +static int upload_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data); + +/* HTTP service on port 80 */ +HTTP_SERVICE_DEFINE(ota_service, NULL, &http_port, 1, 5, NULL, NULL, NULL); + +/* GET / — home page */ +static struct http_resource_detail_dynamic home_detail = { + .common = { + .type = HTTP_RESOURCE_TYPE_DYNAMIC, + .bitmask_of_supported_http_methods = BIT(HTTP_GET), + }, + .cb = home_handler, +}; +HTTP_RESOURCE_DEFINE(home_res, ota_service, "/", &home_detail); + +/* GET /identity — JSON device info (for JS fetch) */ +static struct http_resource_detail_dynamic identity_detail = { + .common = { + .type = HTTP_RESOURCE_TYPE_DYNAMIC, + .bitmask_of_supported_http_methods = BIT(HTTP_GET), + }, + .cb = identity_handler, +}; +HTTP_RESOURCE_DEFINE(identity_res, ota_service, "/identity", &identity_detail); + +/* GET /update — upload page (gzip-compressed HTML) */ +static struct http_resource_detail_dynamic update_page_detail = { + .common = { + .type = HTTP_RESOURCE_TYPE_DYNAMIC, + .bitmask_of_supported_http_methods = BIT(HTTP_GET), + }, + .cb = update_page_handler, +}; +HTTP_RESOURCE_DEFINE(update_res, ota_service, "/update", &update_page_detail); + +/* POST /upload — firmware receive */ +static struct http_resource_detail_dynamic upload_detail = { + .common = { + .type = HTTP_RESOURCE_TYPE_DYNAMIC, + .bitmask_of_supported_http_methods = BIT(HTTP_POST), + }, + .cb = upload_handler, +}; +HTTP_RESOURCE_DEFINE(upload_res, ota_service, "/upload", &upload_detail); + +/* ========== HTTP Handlers ========== */ + +static int home_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data) +{ + LOG_DBG("home_handler status=%d", status); + if (status == HTTP_SERVER_REQUEST_DATA_FINAL) { + response_ctx->body = (const uint8_t *)home_html; + response_ctx->body_len = strlen(home_html); + response_ctx->final_chunk = true; + response_ctx->status = HTTP_200_OK; + LOG_DBG("Served home page (%u bytes)", (unsigned)response_ctx->body_len); + } + return 0; +} + +static int identity_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data) +{ + static const char ct[] = "application/json"; + + LOG_DBG("identity_handler status=%d", status); + if (status == HTTP_SERVER_REQUEST_DATA_FINAL) { + response_ctx->body = (const uint8_t *)identity_json; + response_ctx->body_len = strlen(identity_json); + response_ctx->final_chunk = true; + response_ctx->status = HTTP_200_OK; + response_ctx->header_count = 1; + static struct http_header ct_hdr = { + .name = "Content-Type", + .value = ct, + }; + response_ctx->headers = &ct_hdr; + } + return 0; +} + +static int update_page_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data) +{ + LOG_DBG("update_page_handler status=%d", status); + if (status == HTTP_SERVER_REQUEST_DATA_FINAL) { + response_ctx->body = ota_page_gz; + response_ctx->body_len = OTA_PAGE_GZ_SIZE; + response_ctx->final_chunk = true; + response_ctx->status = HTTP_200_OK; + + /* Headers for gzip-compressed HTML */ + static struct http_header hdrs[] = { + { .name = "Content-Type", .value = "text/html" }, + { .name = "Content-Encoding", .value = "gzip" }, + }; + response_ctx->headers = hdrs; + response_ctx->header_count = 2; + } + return 0; +} + +static int upload_handler(struct http_client_ctx *client, + enum http_transaction_status status, + const struct http_request_ctx *request_ctx, + struct http_response_ctx *response_ctx, + void *user_data) +{ + static const char ok_resp[] = "OK"; + static const char fail_resp[] = "FAIL"; + int ret; + + if (status == HTTP_SERVER_REQUEST_DATA_MORE || + status == HTTP_SERVER_REQUEST_DATA_FINAL) { + + /* First call: initialize flash context. The first dispatch from + * the HTTP server may have data_len==0 if headers and body + * landed in separate TCP segments — flash_img_buffered_write + * handles zero-length writes fine. */ + if (!flash_ctx_initialized) { + ret = flash_img_init(&flash_ctx); + if (ret) { + LOG_ERR("flash_img_init failed: %d", ret); + response_ctx->status = HTTP_500_INTERNAL_SERVER_ERROR; + response_ctx->body = (const uint8_t *)fail_resp; + response_ctx->body_len = sizeof(fail_resp) - 1; + response_ctx->final_chunk = true; + return 0; + } + flash_ctx_initialized = true; + total_bytes_received = 0; + LOG_INF("OTA upload started"); + } + + /* Write chunk to flash (slot1). Always call on final, even with + * data_len==0, so the trailing partial block is flushed and the + * flash_area handle is closed. */ + bool is_final = (status == HTTP_SERVER_REQUEST_DATA_FINAL); + + if (request_ctx->data_len > 0 || is_final) { + ret = flash_img_buffered_write(&flash_ctx, + request_ctx->data, + request_ctx->data_len, + is_final); + if (ret) { + LOG_ERR("Flash write failed at %u bytes: %d", + (unsigned)total_bytes_received, ret); + flash_ctx_initialized = false; + response_ctx->status = HTTP_500_INTERNAL_SERVER_ERROR; + response_ctx->body = (const uint8_t *)fail_resp; + response_ctx->body_len = sizeof(fail_resp) - 1; + response_ctx->final_chunk = true; + return 0; + } + total_bytes_received += request_ctx->data_len; + } + + if (is_final) { + LOG_INF("OTA upload complete: %u bytes", + (unsigned)total_bytes_received); + + /* No client-side validation — let MCUboot decide on next + * boot. A bogus image fails signature/magic check there + * and the bootloader falls back to slot0. Worst case is + * an unnecessary reboot; we never brick. */ + + /* Mark new image for boot (overwrite-only: permanent) */ + ret = boot_request_upgrade(BOOT_UPGRADE_PERMANENT); + if (ret) { + LOG_ERR("boot_request_upgrade failed: %d", ret); + flash_ctx_initialized = false; + response_ctx->status = HTTP_500_INTERNAL_SERVER_ERROR; + response_ctx->body = (const uint8_t *)fail_resp; + response_ctx->body_len = sizeof(fail_resp) - 1; + response_ctx->final_chunk = true; + return 0; + } + + flash_ctx_initialized = false; + + response_ctx->status = HTTP_200_OK; + response_ctx->body = (const uint8_t *)ok_resp; + response_ctx->body_len = sizeof(ok_resp) - 1; + response_ctx->final_chunk = true; + + /* Reboot after 2s (let HTTP response send) */ + k_work_schedule(&ota_reboot_work, K_SECONDS(2)); + } + } else if (status == HTTP_SERVER_TRANSACTION_ABORTED) { + LOG_WRN("OTA upload aborted at %u bytes", + (unsigned)total_bytes_received); + /* Flush+close the flash_area to release the handle. The + * partial slot1 contents are harmless — MCUboot will reject + * an unfinished image, and the next upload will progressively + * re-erase as it writes. */ + if (flash_ctx_initialized) { + (void)flash_img_buffered_write(&flash_ctx, NULL, 0, true); + } + flash_ctx_initialized = false; + total_bytes_received = 0; + } + + return 0; +} + +/* ========== WiFi Event Monitoring ========== */ + +static struct net_mgmt_event_callback wifi_mgmt_cb; + +static void wifi_mgmt_event_handler(struct net_mgmt_event_callback *cb, + uint64_t mgmt_event, + struct net_if *iface) +{ + switch (mgmt_event) { + case NET_EVENT_WIFI_AP_ENABLE_RESULT: + LOG_DBG("WiFi AP enable result event received"); + break; + case NET_EVENT_WIFI_AP_DISABLE_RESULT: + LOG_DBG("WiFi AP disable result event"); + break; + case NET_EVENT_WIFI_AP_STA_CONNECTED: + LOG_INF("WiFi client CONNECTED to AP"); + break; + case NET_EVENT_WIFI_AP_STA_DISCONNECTED: + LOG_INF("WiFi client DISCONNECTED from AP"); + break; + default: + LOG_DBG("WiFi mgmt event: 0x%016llx", mgmt_event); + break; + } +} + +/* ========== WiFi AP Setup ========== */ + +static int wifi_ap_start(void) +{ + struct net_if *iface = net_if_get_default(); + + if (!iface) { + LOG_ERR("No network interface"); + return -ENODEV; + } + + LOG_DBG("Network iface: %p, idx=%d", iface, net_if_get_by_iface(iface)); + + /* Register WiFi management event callback */ + net_mgmt_init_event_callback(&wifi_mgmt_cb, wifi_mgmt_event_handler, + NET_EVENT_WIFI_AP_ENABLE_RESULT | + NET_EVENT_WIFI_AP_DISABLE_RESULT | + NET_EVENT_WIFI_AP_STA_CONNECTED | + NET_EVENT_WIFI_AP_STA_DISCONNECTED); + net_mgmt_add_event_callback(&wifi_mgmt_cb); + + /* Set static IP */ + struct in_addr addr, netmask; + + if (net_addr_pton(AF_INET, OTA_STATIC_IP, &addr)) { + LOG_ERR("Invalid IP: %s", OTA_STATIC_IP); + return -EINVAL; + } + if (net_addr_pton(AF_INET, OTA_NETMASK, &netmask)) { + LOG_ERR("Invalid netmask"); + return -EINVAL; + } + + struct net_if_addr *ifaddr = net_if_ipv4_addr_add(iface, &addr, NET_ADDR_MANUAL, 0); + if (!ifaddr) { + LOG_ERR("Failed to set static IP %s", OTA_STATIC_IP); + return -ENOMEM; + } + net_if_ipv4_set_netmask_by_addr(iface, &addr, &netmask); + net_if_ipv4_set_gw(iface, &addr); + LOG_INF("Static IP set: %s/%s", OTA_STATIC_IP, OTA_NETMASK); + + /* Enable WiFi AP */ + struct wifi_connect_req_params ap_params = {0}; + + ap_params.ssid = (const uint8_t *)OTA_AP_SSID; + ap_params.ssid_length = strlen(OTA_AP_SSID); + ap_params.channel = WIFI_CHANNEL_ANY; + ap_params.security = WIFI_SECURITY_TYPE_NONE; + ap_params.band = WIFI_FREQ_BAND_2_4_GHZ; + + LOG_INF("Enabling WiFi AP: SSID=%s, channel=any, security=open", OTA_AP_SSID); + int ret = net_mgmt(NET_REQUEST_WIFI_AP_ENABLE, iface, + &ap_params, sizeof(ap_params)); + if (ret) { + LOG_ERR("WiFi AP enable failed: %d", ret); + return ret; + } + + LOG_INF("WiFi AP started: SSID=%s", OTA_AP_SSID); + + /* Bring the interface up explicitly */ + if (!net_if_is_up(iface)) { + LOG_WRN("Interface not up, bringing up..."); + ret = net_if_up(iface); + if (ret) { + LOG_ERR("net_if_up failed: %d", ret); + } + } + + /* Start DHCP server */ + struct in_addr dhcp_base; + + if (net_addr_pton(AF_INET, OTA_DHCP_BASE, &dhcp_base) == 0) { + ret = net_dhcpv4_server_start(iface, &dhcp_base); + if (ret) { + LOG_WRN("DHCP server start failed: %d (non-fatal)", ret); + } else { + LOG_INF("DHCP server started (pool: %s+)", OTA_DHCP_BASE); + } + } + + return 0; +} + +static int wifi_ap_stop(void) +{ + struct net_if *iface = net_if_get_default(); + + if (!iface) { + return -ENODEV; + } + + net_dhcpv4_server_stop(iface); + + int ret = net_mgmt(NET_REQUEST_WIFI_AP_DISABLE, iface, NULL, 0); + + if (ret) { + LOG_WRN("WiFi AP disable failed: %d", ret); + } + + /* Remove static IP */ + struct in_addr addr; + + if (net_addr_pton(AF_INET, OTA_STATIC_IP, &addr) == 0) { + net_if_ipv4_addr_rm(iface, &addr); + } + + return ret; +} + +/* ========== Public API ========== */ + +int wifi_ota_start(const char *node_name, const char *board_name) +{ + if (ota_active) { + return -EALREADY; + } + + /* Prepare identity strings for web page */ + snprintf(identity_json, sizeof(identity_json), + "{\"name\":\"%s\",\"board\":\"%s\"}", + node_name ? node_name : "Unknown", + board_name ? board_name : "Unknown"); + + snprintf(home_html, sizeof(home_html), + "" + "

ZephCore OTA: %s (%s)
" + "Go to Update Page

" + "", + node_name ? node_name : "Unknown", + board_name ? board_name : "Unknown"); + + /* Start WiFi AP */ + int ret = wifi_ap_start(); + + if (ret) { + return ret; + } + + /* Start HTTP server */ + LOG_INF("Starting HTTP server on port %u...", http_port); + ret = http_server_start(); + if (ret) { + LOG_ERR("HTTP server start failed: %d", ret); + wifi_ap_stop(); + return ret; + } + + ota_active = true; + flash_ctx_initialized = false; + total_bytes_received = 0; + + LOG_INF("OTA server ready at http://%s/update", OTA_STATIC_IP); + LOG_INF("HTTP routes: / (home), /identity (json), /update (upload page), /upload (POST)"); + return 0; +} + +int wifi_ota_stop(void) +{ + if (!ota_active) { + return 0; + } + + /* Cancel any pending post-upload reboot — user explicitly asked us to + * stop, so don't reboot out from under them. */ + (void)k_work_cancel_delayable(&ota_reboot_work); + + /* If an upload was in flight, flush+close the flash_area handle. */ + if (flash_ctx_initialized) { + (void)flash_img_buffered_write(&flash_ctx, NULL, 0, true); + } + + http_server_stop(); + wifi_ap_stop(); + ota_active = false; + flash_ctx_initialized = false; + total_bytes_received = 0; + + LOG_INF("OTA server stopped"); + return 0; +} + +bool wifi_ota_is_active(void) +{ + return ota_active; +} + +void wifi_ota_confirm_image(void) +{ +#if IS_ENABLED(CONFIG_BOOTLOADER_MCUBOOT) + if (!boot_is_img_confirmed()) { + int ret = boot_write_img_confirmed(); + + if (ret) { + LOG_ERR("Failed to confirm MCUboot image: %d", ret); + } else { + LOG_INF("MCUboot image confirmed"); + } + } +#endif +} diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index dcf5f33..c8f4c7c 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -1,78 +1,78 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * LR1110 hardware hooks for LoRaRadioBase. - */ - -#include "LR1110Radio.h" -#include - -/* LR11xx driver extension API */ -extern "C" { -#include "lr11xx_lora.h" -} - -#include -LOG_MODULE_REGISTER(lr1110_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); - -namespace mesh { - -K_THREAD_STACK_DEFINE(lr11xx_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -LR1110Radio::LR1110Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) -{ -} - -void LR1110Radio::begin() -{ - startTxThread(lr11xx_tx_wait_stack, - K_THREAD_STACK_SIZEOF(lr11xx_tx_wait_stack)); - LoRaRadioBase::begin(); -} - -/* ── Hardware primitives ──────────────────────────────────────────────── */ - -bool LR1110Radio::hwConfigure(const struct lora_modem_config &cfg) -{ - int ret = lora_config(_dev, const_cast(&cfg)); - if (ret < 0) { - LOG_ERR("lora_config failed: %d", ret); - return false; - } - return true; -} - -void LR1110Radio::hwCancelReceive() -{ - lora_recv_async(_dev, NULL, NULL); -} - -int LR1110Radio::hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) -{ - return lora_send_async(_dev, buf, len, sig); -} - -int16_t LR1110Radio::hwGetCurrentRSSI() -{ - return lr11xx_get_rssi_inst(_dev); -} - -bool LR1110Radio::hwIsPreambleDetected() -{ - return lr11xx_is_receiving(_dev); -} - -void LR1110Radio::hwSetRxBoost(bool enable) -{ - lr11xx_set_rx_boost(_dev, enable); -} - -void LR1110Radio::hwResetAGC() -{ - /* Warm sleep → Calibrate(ALL) → re-calibrate image → re-apply RX boost */ - lr11xx_reset_agc(_dev); -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * LR1110 hardware hooks for LoRaRadioBase. + */ + +#include "LR1110Radio.h" +#include + +/* LR11xx driver extension API */ +extern "C" { +#include "lr11xx_lora.h" +} + +#include +LOG_MODULE_REGISTER(lr1110_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); + +namespace mesh { + +K_THREAD_STACK_DEFINE(lr11xx_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); + +LR1110Radio::LR1110Radio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs) + : LoRaRadioBase(lora_dev, board, prefs) +{ +} + +void LR1110Radio::begin() +{ + startTxThread(lr11xx_tx_wait_stack, + K_THREAD_STACK_SIZEOF(lr11xx_tx_wait_stack)); + LoRaRadioBase::begin(); +} + +/* ── Hardware primitives ──────────────────────────────────────────────── */ + +bool LR1110Radio::hwConfigure(const struct lora_modem_config &cfg) +{ + int ret = lora_config(_dev, const_cast(&cfg)); + if (ret < 0) { + LOG_ERR("lora_config failed: %d", ret); + return false; + } + return true; +} + +void LR1110Radio::hwCancelReceive() +{ + lora_recv_async(_dev, NULL, NULL); +} + +int LR1110Radio::hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) +{ + return lora_send_async(_dev, buf, len, sig); +} + +int16_t LR1110Radio::hwGetCurrentRSSI() +{ + return lr11xx_get_rssi_inst(_dev); +} + +bool LR1110Radio::hwIsPreambleDetected() +{ + return lr11xx_is_receiving(_dev); +} + +void LR1110Radio::hwSetRxBoost(bool enable) +{ + lr11xx_set_rx_boost(_dev, enable); +} + +void LR1110Radio::hwResetAGC() +{ + /* Warm sleep → Calibrate(ALL) → re-calibrate image → re-apply RX boost */ + lr11xx_reset_agc(_dev); +} + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index 60cc4b7..6b60f8c 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -1,33 +1,33 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Radio adapter for LR1110/LR1120/LR1121 using Zephyr LoRa driver - * - * Thin wrapper around LoRaRadioBase — only hardware-specific hooks. - */ - -#pragma once - -#include "LoRaRadioBase.h" - -namespace mesh { - -class LR1110Radio : public LoRaRadioBase { -public: - LR1110Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); - - void begin() override; - -protected: - /* Hardware primitives */ - bool hwConfigure(const struct lora_modem_config &cfg) override; - void hwCancelReceive() override; - int hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) override; - int16_t hwGetCurrentRSSI() override; - bool hwIsPreambleDetected() override; - void hwSetRxBoost(bool enable) override; - void hwResetAGC() override; -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Radio adapter for LR1110/LR1120/LR1121 using Zephyr LoRa driver + * + * Thin wrapper around LoRaRadioBase — only hardware-specific hooks. + */ + +#pragma once + +#include "LoRaRadioBase.h" + +namespace mesh { + +class LR1110Radio : public LoRaRadioBase { +public: + LR1110Radio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs = nullptr); + + void begin() override; + +protected: + /* Hardware primitives */ + bool hwConfigure(const struct lora_modem_config &cfg) override; + void hwCancelReceive() override; + int hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) override; + int16_t hwGetCurrentRSSI() override; + bool hwIsPreambleDetected() override; + void hwSetRxBoost(bool enable) override; + void hwResetAGC() override; +}; + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR2021Radio.cpp b/zephcore/adapters/radio/LR2021Radio.cpp index 6e3b38b..88f9ae7 100644 --- a/zephcore/adapters/radio/LR2021Radio.cpp +++ b/zephcore/adapters/radio/LR2021Radio.cpp @@ -1,77 +1,77 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * LR2021 hardware hooks for LoRaRadioBase. - */ - -#include "LR2021Radio.h" -#include - -/* LR20xx driver extension API */ -extern "C" { -#include "lr20xx_lora.h" -} - -#include -LOG_MODULE_REGISTER(lr2021_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); - -namespace mesh { - -K_THREAD_STACK_DEFINE(lr20xx_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -LR2021Radio::LR2021Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) -{ -} - -void LR2021Radio::begin() -{ - startTxThread(lr20xx_tx_wait_stack, - K_THREAD_STACK_SIZEOF(lr20xx_tx_wait_stack)); - LoRaRadioBase::begin(); -} - -/* ── Hardware primitives ──────────────────────────────────────────────── */ - -bool LR2021Radio::hwConfigure(const struct lora_modem_config &cfg) -{ - int ret = lora_config(_dev, const_cast(&cfg)); - if (ret < 0) { - LOG_ERR("lora_config failed: %d", ret); - return false; - } - return true; -} - -void LR2021Radio::hwCancelReceive() -{ - lora_recv_async(_dev, NULL, NULL); -} - -int LR2021Radio::hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) -{ - return lora_send_async(_dev, buf, len, sig); -} - -int16_t LR2021Radio::hwGetCurrentRSSI() -{ - return lr20xx_get_rssi_inst(_dev); -} - -bool LR2021Radio::hwIsPreambleDetected() -{ - return lr20xx_is_receiving(_dev); -} - -void LR2021Radio::hwSetRxBoost(bool enable) -{ - lr20xx_set_rx_boost(_dev, enable); -} - -void LR2021Radio::hwResetAGC() -{ - lr20xx_reset_agc(_dev); -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * LR2021 hardware hooks for LoRaRadioBase. + */ + +#include "LR2021Radio.h" +#include + +/* LR20xx driver extension API */ +extern "C" { +#include "lr20xx_lora.h" +} + +#include +LOG_MODULE_REGISTER(lr2021_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); + +namespace mesh { + +K_THREAD_STACK_DEFINE(lr20xx_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); + +LR2021Radio::LR2021Radio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs) + : LoRaRadioBase(lora_dev, board, prefs) +{ +} + +void LR2021Radio::begin() +{ + startTxThread(lr20xx_tx_wait_stack, + K_THREAD_STACK_SIZEOF(lr20xx_tx_wait_stack)); + LoRaRadioBase::begin(); +} + +/* ── Hardware primitives ──────────────────────────────────────────────── */ + +bool LR2021Radio::hwConfigure(const struct lora_modem_config &cfg) +{ + int ret = lora_config(_dev, const_cast(&cfg)); + if (ret < 0) { + LOG_ERR("lora_config failed: %d", ret); + return false; + } + return true; +} + +void LR2021Radio::hwCancelReceive() +{ + lora_recv_async(_dev, NULL, NULL); +} + +int LR2021Radio::hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) +{ + return lora_send_async(_dev, buf, len, sig); +} + +int16_t LR2021Radio::hwGetCurrentRSSI() +{ + return lr20xx_get_rssi_inst(_dev); +} + +bool LR2021Radio::hwIsPreambleDetected() +{ + return lr20xx_is_receiving(_dev); +} + +void LR2021Radio::hwSetRxBoost(bool enable) +{ + lr20xx_set_rx_boost(_dev, enable); +} + +void LR2021Radio::hwResetAGC() +{ + lr20xx_reset_agc(_dev); +} + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR2021Radio.h b/zephcore/adapters/radio/LR2021Radio.h index cf34d7a..4edd3fb 100644 --- a/zephcore/adapters/radio/LR2021Radio.h +++ b/zephcore/adapters/radio/LR2021Radio.h @@ -1,33 +1,33 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Radio adapter for LR2021 using Zephyr LoRa driver - * - * Thin wrapper around LoRaRadioBase — only hardware-specific hooks. - */ - -#pragma once - -#include "LoRaRadioBase.h" - -namespace mesh { - -class LR2021Radio : public LoRaRadioBase { -public: - LR2021Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); - - void begin() override; - -protected: - /* Hardware primitives */ - bool hwConfigure(const struct lora_modem_config &cfg) override; - void hwCancelReceive() override; - int hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) override; - int16_t hwGetCurrentRSSI() override; - bool hwIsPreambleDetected() override; - void hwSetRxBoost(bool enable) override; - void hwResetAGC() override; -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Radio adapter for LR2021 using Zephyr LoRa driver + * + * Thin wrapper around LoRaRadioBase — only hardware-specific hooks. + */ + +#pragma once + +#include "LoRaRadioBase.h" + +namespace mesh { + +class LR2021Radio : public LoRaRadioBase { +public: + LR2021Radio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs = nullptr); + + void begin() override; + +protected: + /* Hardware primitives */ + bool hwConfigure(const struct lora_modem_config &cfg) override; + void hwCancelReceive() override; + int hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) override; + int16_t hwGetCurrentRSSI() override; + bool hwIsPreambleDetected() override; + void hwSetRxBoost(bool enable) override; + void hwResetAGC() override; +}; + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index dc00894..d0d7a66 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -1,783 +1,783 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * LoRa radio base class — shared algorithms for all radio adapters. - */ - -#include "LoRaRadioBase.h" -#include "radio_common.h" -#include -#include -#include -#include -#include - - -#include -LOG_MODULE_REGISTER(lora_radio_base, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); - -namespace mesh { - -static uint16_t preambleLengthForSF(uint8_t sf) -{ - /* PR #1954 parity: longer preamble for lower SF. */ - return (sf <= 8) ? 32 : 16; -} - -static constexpr uint16_t RX_DUTY_RX_SYMBOLS = 13; -static constexpr uint16_t RX_DUTY_SLEEP_SYMBOLS = 3; - -/* ── Constructor ─────────────────────────────────────────────── */ - -LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : _loramac_node(false), - _dev(lora_dev), _prefs(prefs), _board(&board), - _in_recv_mode(0), _tx_active(0), - _last_rssi(0), _last_snr(0), - _rx_head(0), _rx_tail(0), - _noise_floor(DEFAULT_NOISE_FLOOR), _calibration_threshold(0), _ema_unguarded(0), - _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), - _rx_boost_enabled(true), - _tx_power_reduction_db(0), - _config_cached(false), - _has_radio_override(false), - _override_freq(0), _override_bw(0), - _override_sf(0), _override_cr(0), - _rx_cb(nullptr), _rx_cb_user_data(nullptr), - _tx_done_cb(nullptr), _tx_done_cb_user_data(nullptr), - _tx_thread_running(false), - _packets_recv(0), _packets_sent(0), _packets_recv_errors(0) -{ - k_poll_signal_init(&_tx_signal); - k_sem_init(&_tx_start_sem, 0, 1); - memset(_rx_ring, 0, sizeof(_rx_ring)); -} - -/* ── TX wait thread ──────────────────────────────────────────── */ - -void LoRaRadioBase::txWaitThreadFn(void *p1, void *p2, void *p3) -{ - LoRaRadioBase *self = static_cast(p1); - ARG_UNUSED(p2); - ARG_UNUSED(p3); - - LOG_INF("TX wait thread started"); - - for (;;) { - k_sem_take(&self->_tx_start_sem, K_FOREVER); - - if (!atomic_get(&self->_tx_active)) { - continue; - } - - LOG_DBG("TX wait: waiting for signal..."); - - struct k_poll_event events[1] = { - K_POLL_EVENT_INITIALIZER(K_POLL_TYPE_SIGNAL, - K_POLL_MODE_NOTIFY_ONLY, - &self->_tx_signal), - }; - - unsigned int signaled; - int result; - k_poll_signal_check(&self->_tx_signal, &signaled, &result); - if (signaled) { - LOG_DBG("TX wait: signal already raised (result=%d)", result); - k_poll_signal_reset(&self->_tx_signal); - self->_board->onAfterTransmit(); - self->startReceive(); - atomic_set(&self->_tx_active, 0); - atomic_inc(&self->_packets_sent); - if (self->_tx_done_cb) { - self->_tx_done_cb(self->_tx_done_cb_user_data); - } - continue; - } - - int ret = k_poll(events, 1, K_MSEC(TX_TIMEOUT_MS)); - if (ret == -EAGAIN) { - LOG_ERR("TX wait: TIMEOUT!"); - self->_board->onAfterTransmit(); - self->startReceive(); - atomic_set(&self->_tx_active, 0); - if (self->_tx_done_cb) { - self->_tx_done_cb(self->_tx_done_cb_user_data); - } - continue; - } - - if (ret == 0 && events[0].state == K_POLL_STATE_SIGNALED) { - k_poll_signal_reset(&self->_tx_signal); - self->_board->onAfterTransmit(); - self->startReceive(); - atomic_set(&self->_tx_active, 0); - atomic_inc(&self->_packets_sent); - LOG_INF("TX complete, RX restarted"); - - if (self->_tx_done_cb) { - self->_tx_done_cb(self->_tx_done_cb_user_data); - } - } else { - LOG_ERR("TX wait: k_poll returned %d, state=%d — recovering", - ret, events[0].state); - k_poll_signal_reset(&self->_tx_signal); - self->_board->onAfterTransmit(); - self->startReceive(); - atomic_set(&self->_tx_active, 0); - - if (self->_tx_done_cb) { - self->_tx_done_cb(self->_tx_done_cb_user_data); - } - } - } -} - -void LoRaRadioBase::startTxThread(k_thread_stack_t *stack, size_t stack_size) -{ - if (_tx_thread_running) { - return; - } - k_thread_create(&_tx_wait_thread, stack, stack_size, - txWaitThreadFn, this, NULL, NULL, - TX_WAIT_THREAD_PRIORITY, 0, K_NO_WAIT); - k_thread_name_set(&_tx_wait_thread, "lora_tx_wait"); - _tx_thread_running = true; -} - -/* ── RX callback (static, ISR-safe) ──────────────────────────────────── */ - -void LoRaRadioBase::rxCallbackStatic(const struct device *dev, uint8_t *data, - uint16_t size, int16_t rssi, int8_t snr, - void *user_data) -{ - LoRaRadioBase *self = static_cast(user_data); - - /* NULL data = RX error (CRC/header error) */ - if (data == NULL && size == 0) { - atomic_inc(&self->_packets_recv_errors); - LOG_DBG("RX error (CRC/header), total errors: %u", - (uint32_t)atomic_get(&self->_packets_recv_errors)); - return; - } - - LOG_DBG("RX callback: size=%u rssi=%d snr=%d", size, rssi, snr); - - /* Ring buffer write — SPSC: only ISR writes _rx_head, only main - * thread writes _rx_tail. On overflow, drop the NEW packet to - * preserve this invariant (ISR must never touch _rx_tail). */ - uint8_t head = (uint8_t)atomic_get(&self->_rx_head); - uint8_t next_head = (head + 1) % RX_RING_SIZE; - if (next_head == (uint8_t)atomic_get(&self->_rx_tail)) { - LOG_WRN("RX ring full, dropping new packet"); - atomic_inc(&self->_packets_recv_errors); - if (self->_rx_cb) { - self->_rx_cb(self->_rx_cb_user_data); - } - return; - } - - RxPacket *pkt = &self->_rx_ring[head]; - uint16_t copy_len = (size > sizeof(pkt->data)) ? sizeof(pkt->data) : size; - memcpy(pkt->data, data, copy_len); - pkt->len = copy_len; - pkt->rssi = rssi; - pkt->snr = snr; - - atomic_set(&self->_rx_head, next_head); - self->_last_rssi = (float)rssi; - self->_last_snr = (float)snr; - atomic_inc(&self->_packets_recv); - - if (self->_rx_cb) { - self->_rx_cb(self->_rx_cb_user_data); - } -} - -/* ── Config helpers ───────────────────────────────────────────────────── */ - -void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx) -{ - memset(&cfg, 0, sizeof(cfg)); - /* Override wins for freq/bw/sf/cr (tempradio). Power, preamble, and - * other fields still come from _prefs. */ - float freq_mhz = _has_radio_override ? _override_freq - : (_prefs ? _prefs->freq : (LoRaConfig::FREQ_HZ / 1000000.0f)); - float bw_khz = _has_radio_override ? _override_bw - : (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH); - uint8_t sf = _has_radio_override ? _override_sf - : (_prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR); - uint8_t cr = _has_radio_override ? _override_cr - : (_prefs ? _prefs->cr : LoRaConfig::CODING_RATE); - cfg.frequency = (uint32_t)(freq_mhz * 1000000.0f); - cfg.bandwidth = bw_khz_to_enum((uint16_t)bw_khz); - cfg.datarate = (enum lora_datarate)sf; - cfg.coding_rate = cr_to_enum(cr); - cfg.preamble_len = preambleLengthForSF(sf); - cfg.tx_power = _prefs ? (int8_t)_prefs->tx_power_dbm - : LoRaConfig::TX_POWER_DBM; -#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM - if (cfg.tx_power > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) { - cfg.tx_power = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; - } -#endif - /* APC reduction (applied after all clamps) */ - cfg.tx_power -= _tx_power_reduction_db; - if (cfg.tx_power < -9) cfg.tx_power = -9; - - cfg.tx = tx; - cfg.iq_inverted = false; - cfg.public_network = false; - cfg.packet_crc_disable = false; - - /* LBT: driver performs hardware CAD before TX, returns -EBUSY if busy */ - if (tx) { - cfg.cad.mode = LORA_CAD_MODE_LBT; - } -} - -/** - * Compare radio-relevant fields of two modem configs. - * Ignores the tx flag — that only selects TX vs RX mode, the actual - * modem parameters (freq, SF, BW, CR, power) are what the driver - * programs into registers. - */ -static bool configParamsEqual(const struct lora_modem_config &a, - const struct lora_modem_config &b) -{ - /* CRITICAL: a.tx == b.tx MUST be compared — without it, switching - * RX→TX skips lora_config() for TX params, breaking transmit. */ - return a.frequency == b.frequency && - a.bandwidth == b.bandwidth && - a.datarate == b.datarate && - a.coding_rate == b.coding_rate && - a.preamble_len == b.preamble_len && - a.tx_power == b.tx_power && - a.tx == b.tx && - a.iq_inverted == b.iq_inverted && - a.public_network == b.public_network; -} - -/** - * Check if only the TX/RX direction changed (all radio params identical). - * Used to skip the full lora_config() call on TX↔RX transitions when - * the driver already has valid TX and RX configs from previous calls. - */ -static bool onlyDirectionDiffers(const struct lora_modem_config &a, - const struct lora_modem_config &b) -{ - return a.frequency == b.frequency && - a.bandwidth == b.bandwidth && - a.datarate == b.datarate && - a.coding_rate == b.coding_rate && - a.preamble_len == b.preamble_len && - a.tx_power == b.tx_power && - a.iq_inverted == b.iq_inverted && - a.public_network == b.public_network && - a.tx != b.tx; -} - -void LoRaRadioBase::configureRx() -{ - struct lora_modem_config cfg; - buildModemConfig(cfg, false); - - if (_config_cached && configParamsEqual(cfg, _last_cfg)) { - LOG_DBG("configureRx: params unchanged, skipping hwConfigure"); - return; - } - - /* Fast path: if only the TX/RX direction changed, skip the full - * hwConfigure → lora_config() call. The driver already has a valid - * RX config (RadioSetRxConfig) from a previous cycle — Radio.Rx(0) - * in hwStartReceive() will use those register values directly. - * This avoids the modem_acquire → modem_release → Radio.Sleep() - * round-trip that wastes ~5 ms on every TX→RX transition. - * - * Not used for loramac-node: Radio.SetTxConfig() and Radio.SetRxConfig() - * configure completely disjoint internal state (including TxTimeout). - * Skipping either on a direction change leaves that state uninitialized. */ - if (!_loramac_node && _config_cached && onlyDirectionDiffers(cfg, _last_cfg)) { - LOG_DBG("configureRx: direction-only change, skip hwConfigure"); - _last_cfg = cfg; - return; - } - - LOG_DBG("configureRx: freq=%u bw=%d sf=%d cr=%d pwr=%d", - cfg.frequency, (int)cfg.bandwidth, (int)cfg.datarate, - (int)cfg.coding_rate, cfg.tx_power); - - if (hwConfigure(cfg)) { - _last_cfg = cfg; - _config_cached = true; - } else { - _config_cached = false; - } -} - -void LoRaRadioBase::configureTx() -{ - struct lora_modem_config cfg; - buildModemConfig(cfg, true); - - if (_config_cached && configParamsEqual(cfg, _last_cfg)) { - LOG_DBG("configureTx: params unchanged, skipping hwConfigure"); - return; - } - - /* Fast path: direction-only change (RX→TX). The driver already - * has a valid TX config (RadioSetTxConfig with TxTimeout=4000) - * from a previous cycle — Radio.Send() will use those values. - * Not used for loramac-node (see configureRx comment above). */ - if (!_loramac_node && _config_cached && onlyDirectionDiffers(cfg, _last_cfg)) { - LOG_DBG("configureTx: direction-only change, skip hwConfigure"); - _last_cfg = cfg; - return; - } - - if (hwConfigure(cfg)) { - _last_cfg = cfg; - _config_cached = true; - } else { - _config_cached = false; - } -} - -/* ── Lifecycle ────────────────────────────────────────────────────────── */ - -void LoRaRadioBase::begin() -{ - if (!device_is_ready(_dev)) { - LOG_ERR("LoRa device not ready"); - return; - } - - /* Subclass begin() calls startTxThread() before calling us. - * - * RX boost and duty cycle are set via constructor defaults: - * _rx_boost_enabled = true (boosted +3dB, overridable via setRxBoost()) - * _rx_duty_cycle_enabled = CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE - * Callers can override after begin() via setRxBoost() / enableRxDutyCycle(). - */ - - startReceive(); - - uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f) - : LoRaConfig::FREQ_HZ; - uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; - uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw) - : (uint16_t)LoRaConfig::BANDWIDTH; - uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; - int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm - : LoRaConfig::TX_POWER_DBM; - - LOG_INF("radio started: freq=%u bw=%u sf=%u cr=%u pwr=%d", - freq, bw_khz, sf, cr, tx_pwr); -} - -void LoRaRadioBase::reconfigure() -{ - hwCancelReceive(); - atomic_set(&_in_recv_mode, 0); - _config_cached = false; /* Force full reconfigure */ - startReceive(); - - uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f) - : LoRaConfig::FREQ_HZ; - uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; - uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw) - : (uint16_t)LoRaConfig::BANDWIDTH; - uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; - int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm - : LoRaConfig::TX_POWER_DBM; - - LOG_INF("radio reconfigured: freq=%u bw=%u sf=%u cr=%u pwr=%d", - freq, bw_khz, sf, cr, tx_pwr); -} - -void LoRaRadioBase::reconfigureWithParams(float freq, float bw, uint8_t sf, uint8_t cr) -{ - /* Callers (ObserverMesh CLI handlers) write to _prefs and call - * savePrefs() before invoking us — the radio just needs to pick up - * the new params. Tempradio uses setRadioOverride() instead so it - * never touches _prefs. */ - (void)freq; (void)bw; (void)sf; (void)cr; - reconfigure(); -} - -void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr) -{ - _override_freq = freq; - _override_bw = bw; - _override_sf = sf; - _override_cr = cr; - _has_radio_override = true; - reconfigure(); -} - -void LoRaRadioBase::clearRadioOverride() -{ - if (!_has_radio_override) { - return; - } - _has_radio_override = false; - reconfigure(); -} - -void LoRaRadioBase::startReceive() -{ - configureRx(); - - int ret; - - if (_rx_duty_cycle_enabled) { - /* Fixed duty-cycle window for field validation. - * Keep build-time tunable via constants above. */ - struct lora_modem_config cfg; - buildModemConfig(cfg, false); - - uint8_t sf = (uint8_t)cfg.datarate; - uint32_t bw_hz = bandwidth_to_hz(cfg.bandwidth); - float bw_khz = (float)bw_hz / 1000.0f; - if (bw_khz > 0.0f) { - uint32_t symbol_us = (uint32_t)((float)(1 << sf) * 1000.0f / bw_khz); - uint32_t rx_us = RX_DUTY_RX_SYMBOLS * symbol_us; - uint32_t sleep_us = RX_DUTY_SLEEP_SYMBOLS * symbol_us; - - ret = lora_recv_duty_cycle(_dev, - K_USEC(rx_us), - K_USEC(sleep_us), - rxCallbackStatic, this); - if (ret == 0) { - atomic_set(&_in_recv_mode, 1); - return; - } - if (ret != -ENOSYS) { - LOG_ERR("lora_recv_duty_cycle failed: %d", ret); - } - } - /* Fall through to normal recv_async */ - } - - ret = lora_recv_async(_dev, rxCallbackStatic, this); - if (ret < 0) { - LOG_ERR("lora_recv_async failed: %d", ret); - atomic_set(&_in_recv_mode, 0); - return; - } - atomic_set(&_in_recv_mode, 1); -} - -/* ── RX/TX ────────────────────────────────────────────────────────────── */ - -int LoRaRadioBase::recvRaw(uint8_t *bytes, int sz) -{ - uint8_t tail = (uint8_t)atomic_get(&_rx_tail); - if (atomic_get(&_rx_head) == tail) { - return 0; - } - - RxPacket *pkt = &_rx_ring[tail]; - uint16_t len = pkt->len; - if (len > (uint16_t)sz) { - len = (uint16_t)sz; - } - - memcpy(bytes, pkt->data, len); - _last_rssi = (float)pkt->rssi; - _last_snr = (float)pkt->snr; - atomic_set(&_rx_tail, (tail + 1) % RX_RING_SIZE); - return (int)len; -} - -bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) -{ - if (len > (int)sizeof(_tx_buf)) { - return false; - } - - /* Defensive gate: callers should defer TX while radio is BUSY. */ - if (!isRadioReady()) { - return false; - } - - /* Last-moment hardware check before killing active RX. - * Closes the race between the Dispatcher's isReceiving() guard - * and hwCancelReceive() — if a preamble arrived in that gap, - * abort TX and let the Dispatcher re-queue. */ - if (hwIsPreambleDetected()) { - return false; - } - - _board->onBeforeTransmit(); - atomic_set(&_tx_active, 1); - atomic_set(&_in_recv_mode, 0); - - hwCancelReceive(); - configureTx(); - - memcpy(_tx_buf, bytes, len); - k_poll_signal_reset(&_tx_signal); - - int ret = hwSendAsync(_tx_buf, (uint32_t)len, &_tx_signal); - if (ret < 0) { - LOG_ERR("hwSendAsync failed: %d", ret); - _board->onAfterTransmit(); - atomic_set(&_tx_active, 0); - startReceive(); - return false; - } - - LOG_DBG("TX started async, len=%d", len); - k_sem_give(&_tx_start_sem); - return true; -} - -bool LoRaRadioBase::isSendComplete() -{ - return !atomic_get(&_tx_active); -} - -void LoRaRadioBase::onSendFinished() -{ - /* Nothing needed — TX state tracked via _tx_active */ -} - -bool LoRaRadioBase::isInRecvMode() const -{ - return atomic_get(&_in_recv_mode) != 0; -} - -float LoRaRadioBase::getLastRSSI() const -{ - return _last_rssi; -} - -float LoRaRadioBase::getLastSNR() const -{ - return _last_snr; -} - -bool LoRaRadioBase::isRadioReady() -{ - /* BUSY high means the radio cannot accept SPI commands now - * (e.g. duty-cycle sleep phase on SX126x/LR11xx). */ - return !hwIsChipBusy(); -} - -/* ── Airtime + scoring ────────────────────────────────────────────────── */ - -uint32_t LoRaRadioBase::getEstAirtimeFor(int len_bytes) -{ - uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; - float bw = _prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH; - uint8_t cr_val = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; - - if (sf < 6) sf = 6; - if (sf > 12) sf = 12; - if (bw < 7.0f) bw = 125.0f; - if (cr_val < 5) cr_val = 5; - if (cr_val > 8) cr_val = 8; - - float t_sym = (float)(1 << sf) / (bw * 1000.0f); - float t_preamble = (preambleLengthForSF(sf) + 4.25f) * t_sym; - - float de = (sf >= 11) ? 1.0f : 0.0f; - float num = 8.0f * len_bytes - 4.0f * sf + 28.0f + 16.0f; - float den = 4.0f * (sf - 2.0f * de); - if (den < 1.0f) den = 4.0f; - float n_payload = 8.0f + fmaxf(ceilf(num / den) * (cr_val - 4 + 4), 0.0f); - - float t_payload = n_payload * t_sym; - return (uint32_t)((t_preamble + t_payload) * 1000.0f); -} - -float LoRaRadioBase::packetScore(float snr, int packet_len) -{ - int sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; - if (sf < 7 || sf > 12) return 0.0f; - if (snr < lora_snr_threshold[sf - 7]) return 0.0f; - - float success_rate = (snr - lora_snr_threshold[sf - 7]) / 10.0f; - float collision_penalty = 1.0f - ((float)packet_len / 256.0f); - float score = success_rate * collision_penalty; - if (score < 0.0f) score = 0.0f; - if (score > 1.0f) score = 1.0f; - return score; -} - -/* ── Advanced radio features ──────────────────────────────────────────── */ - -int LoRaRadioBase::getNoiseFloor() const -{ - return _noise_floor; -} - -void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) -{ - _calibration_threshold = threshold; - - if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { - return; - } - - /* Skip when the radio cannot accept commands right now - * (e.g. duty-cycle sleep BUSY window). */ - if (!isRadioReady()) { - return; - } - - /* Skip if mid-receive — don't want signal energy in the floor. */ - if (isReceiving()) { - return; - } - - /* Median of multiple RSSI reads (~200 us). Rejects up to N/2-1 - * outliers in either direction without the downward bias of min - * or the spike sensitivity of average. Insertion sort is fine - * for N=8 (28 comparisons worst case, all in registers). */ - int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK]; - for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { - samples[i] = hwGetCurrentRSSI(); - if (samples[i] == -128) { - /* Chip busy or RSSI read contended — retry next tick. */ - return; - } - } - /* Insertion sort — tiny array, branch-friendly on Cortex-M */ - for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { - int16_t key = samples[i]; - int j = i - 1; - while (j >= 0 && samples[j] > key) { - samples[j + 1] = samples[j]; - j--; - } - samples[j + 1] = key; - } - int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] + - samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2; - - /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. */ - if (_noise_floor == DEFAULT_NOISE_FLOOR) { - _noise_floor = rssi; - if (_noise_floor < -120) _noise_floor = -120; - if (_noise_floor > -50) _noise_floor = -50; - _ema_unguarded = 0; - LOG_DBG("noise_floor_cal: seed=%d", _noise_floor); - return; - } - - /* Threshold filter with warmup and periodic bypass. - * - * _ema_unguarded counts up from 0 on every tick. - * Ticks 0..W-1 (warmup): all samples accepted for fast convergence - * after seed/reset — prevents a bad seed from locking out the - * real noise floor via a too-tight threshold. - * Ticks W+: threshold filter active. Every Pth tick one sample - * bypasses the filter so the floor can track sustained upward - * shifts (new interference, antenna change). - * The EMA's 1/8 weight naturally dampens isolated spikes. */ - const int W = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 — warmup ticks */ - const int P = NOISE_FLOOR_UNGUARDED_INTERVAL; /* 16 — periodic interval */ - bool warmup = (_ema_unguarded < W); - bool periodic = (!warmup && (_ema_unguarded & (P - 1)) == 0); - _ema_unguarded++; /* wraps at 255 — harmless */ - - if (!warmup && !periodic && - rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { - return; - } - - /* EMA: floor += round_nearest((sample - floor) / W). - * Plain >> has downward bias (-1>>3 == -1 but +1>>3 == 0). - * Plain / has a ±7 dead zone (small drifts ignored). - * Round-to-nearest: add half the divisor before dividing, - * with sign-aware bias so both directions are symmetric. */ - int diff = rssi - _noise_floor; - int half = W / 2; /* 4 */ - int step = (diff + (diff > 0 ? half : -half)) / W; - _noise_floor += step; - if (_noise_floor < -120) _noise_floor = -120; - if (_noise_floor > -50) _noise_floor = -50; - - LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", - rssi, _noise_floor, _ema_unguarded - 1); -} - -void LoRaRadioBase::resetAGC() -{ - /* Don't reset AGC while transmitting or receiving — warm sleep would - * abort the TX or corrupt the incoming packet. maintenanceLoop() - * will retry next housekeeping cycle. - * Also skip if the chip is in its duty-cycle sleep phase: hwResetAGC() - * holds the SPI mutex with K_FOREVER and would hang for 3 s. */ - if (atomic_get(&_tx_active) || isReceiving()) { - return; - } - if (_rx_duty_cycle_enabled && hwIsChipBusy()) { - return; - } - - hwResetAGC(); - - /* Warm sleep + calibrate leaves the radio in STANDBY. - * Restart receive if we were in RX mode. */ - if (atomic_get(&_in_recv_mode)) { - startReceive(); - } - - /* Reset noise floor so it reconverges from scratch (seed + warmup). - * Without this, a stuck _noise_floor of -120 makes the sampling threshold - * too low to accept normal samples, self-reinforcing the stuck value. */ - _noise_floor = DEFAULT_NOISE_FLOOR; - _ema_unguarded = 0; -} - -bool LoRaRadioBase::isReceiving() -{ - if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { - return false; - } - if (hwIsPreambleDetected()) { - return true; - } - return isChannelActive(); -} - -bool LoRaRadioBase::isChannelActive(int threshold) -{ - if (threshold == 0) { - threshold = _calibration_threshold; - } - if (threshold == 0) { - return false; - } - int16_t rssi = hwGetCurrentRSSI(); - return rssi > (_noise_floor + threshold); -} - -/* ── Power saving ─────────────────────────────────────────────────────── */ - -void LoRaRadioBase::enableRxDutyCycle(bool enable) -{ - _rx_duty_cycle_enabled = enable; - LOG_INF("RX duty cycle %s", enable ? "enabled" : "disabled"); - - if (atomic_get(&_in_recv_mode)) { - /* Restart receive to apply new duty cycle state */ - hwCancelReceive(); - atomic_set(&_in_recv_mode, 0); - startReceive(); - } -} - -void LoRaRadioBase::setRxBoost(bool enable) -{ - _rx_boost_enabled = enable; - LOG_INF("RX boost %s (+3dB sensitivity, +2mA)", - enable ? "enabled" : "disabled"); - if (atomic_get(&_in_recv_mode)) { - hwSetRxBoost(enable); - } -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * LoRa radio base class — shared algorithms for all radio adapters. + */ + +#include "LoRaRadioBase.h" +#include "radio_common.h" +#include +#include +#include +#include +#include + + +#include +LOG_MODULE_REGISTER(lora_radio_base, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); + +namespace mesh { + +static uint16_t preambleLengthForSF(uint8_t sf) +{ + /* PR #1954 parity: longer preamble for lower SF. */ + return (sf <= 8) ? 32 : 16; +} + +static constexpr uint16_t RX_DUTY_RX_SYMBOLS = 13; +static constexpr uint16_t RX_DUTY_SLEEP_SYMBOLS = 3; + +/* ── Constructor ─────────────────────────────────────────────── */ + +LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs) + : _loramac_node(false), + _dev(lora_dev), _prefs(prefs), _board(&board), + _in_recv_mode(0), _tx_active(0), + _last_rssi(0), _last_snr(0), + _rx_head(0), _rx_tail(0), + _noise_floor(DEFAULT_NOISE_FLOOR), _calibration_threshold(0), _ema_unguarded(0), + _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), + _rx_boost_enabled(true), + _tx_power_reduction_db(0), + _config_cached(false), + _has_radio_override(false), + _override_freq(0), _override_bw(0), + _override_sf(0), _override_cr(0), + _rx_cb(nullptr), _rx_cb_user_data(nullptr), + _tx_done_cb(nullptr), _tx_done_cb_user_data(nullptr), + _tx_thread_running(false), + _packets_recv(0), _packets_sent(0), _packets_recv_errors(0) +{ + k_poll_signal_init(&_tx_signal); + k_sem_init(&_tx_start_sem, 0, 1); + memset(_rx_ring, 0, sizeof(_rx_ring)); +} + +/* ── TX wait thread ──────────────────────────────────────────── */ + +void LoRaRadioBase::txWaitThreadFn(void *p1, void *p2, void *p3) +{ + LoRaRadioBase *self = static_cast(p1); + ARG_UNUSED(p2); + ARG_UNUSED(p3); + + LOG_INF("TX wait thread started"); + + for (;;) { + k_sem_take(&self->_tx_start_sem, K_FOREVER); + + if (!atomic_get(&self->_tx_active)) { + continue; + } + + LOG_DBG("TX wait: waiting for signal..."); + + struct k_poll_event events[1] = { + K_POLL_EVENT_INITIALIZER(K_POLL_TYPE_SIGNAL, + K_POLL_MODE_NOTIFY_ONLY, + &self->_tx_signal), + }; + + unsigned int signaled; + int result; + k_poll_signal_check(&self->_tx_signal, &signaled, &result); + if (signaled) { + LOG_DBG("TX wait: signal already raised (result=%d)", result); + k_poll_signal_reset(&self->_tx_signal); + self->_board->onAfterTransmit(); + self->startReceive(); + atomic_set(&self->_tx_active, 0); + atomic_inc(&self->_packets_sent); + if (self->_tx_done_cb) { + self->_tx_done_cb(self->_tx_done_cb_user_data); + } + continue; + } + + int ret = k_poll(events, 1, K_MSEC(TX_TIMEOUT_MS)); + if (ret == -EAGAIN) { + LOG_ERR("TX wait: TIMEOUT!"); + self->_board->onAfterTransmit(); + self->startReceive(); + atomic_set(&self->_tx_active, 0); + if (self->_tx_done_cb) { + self->_tx_done_cb(self->_tx_done_cb_user_data); + } + continue; + } + + if (ret == 0 && events[0].state == K_POLL_STATE_SIGNALED) { + k_poll_signal_reset(&self->_tx_signal); + self->_board->onAfterTransmit(); + self->startReceive(); + atomic_set(&self->_tx_active, 0); + atomic_inc(&self->_packets_sent); + LOG_INF("TX complete, RX restarted"); + + if (self->_tx_done_cb) { + self->_tx_done_cb(self->_tx_done_cb_user_data); + } + } else { + LOG_ERR("TX wait: k_poll returned %d, state=%d — recovering", + ret, events[0].state); + k_poll_signal_reset(&self->_tx_signal); + self->_board->onAfterTransmit(); + self->startReceive(); + atomic_set(&self->_tx_active, 0); + + if (self->_tx_done_cb) { + self->_tx_done_cb(self->_tx_done_cb_user_data); + } + } + } +} + +void LoRaRadioBase::startTxThread(k_thread_stack_t *stack, size_t stack_size) +{ + if (_tx_thread_running) { + return; + } + k_thread_create(&_tx_wait_thread, stack, stack_size, + txWaitThreadFn, this, NULL, NULL, + TX_WAIT_THREAD_PRIORITY, 0, K_NO_WAIT); + k_thread_name_set(&_tx_wait_thread, "lora_tx_wait"); + _tx_thread_running = true; +} + +/* ── RX callback (static, ISR-safe) ──────────────────────────────────── */ + +void LoRaRadioBase::rxCallbackStatic(const struct device *dev, uint8_t *data, + uint16_t size, int16_t rssi, int8_t snr, + void *user_data) +{ + LoRaRadioBase *self = static_cast(user_data); + + /* NULL data = RX error (CRC/header error) */ + if (data == NULL && size == 0) { + atomic_inc(&self->_packets_recv_errors); + LOG_DBG("RX error (CRC/header), total errors: %u", + (uint32_t)atomic_get(&self->_packets_recv_errors)); + return; + } + + LOG_DBG("RX callback: size=%u rssi=%d snr=%d", size, rssi, snr); + + /* Ring buffer write — SPSC: only ISR writes _rx_head, only main + * thread writes _rx_tail. On overflow, drop the NEW packet to + * preserve this invariant (ISR must never touch _rx_tail). */ + uint8_t head = (uint8_t)atomic_get(&self->_rx_head); + uint8_t next_head = (head + 1) % RX_RING_SIZE; + if (next_head == (uint8_t)atomic_get(&self->_rx_tail)) { + LOG_WRN("RX ring full, dropping new packet"); + atomic_inc(&self->_packets_recv_errors); + if (self->_rx_cb) { + self->_rx_cb(self->_rx_cb_user_data); + } + return; + } + + RxPacket *pkt = &self->_rx_ring[head]; + uint16_t copy_len = (size > sizeof(pkt->data)) ? sizeof(pkt->data) : size; + memcpy(pkt->data, data, copy_len); + pkt->len = copy_len; + pkt->rssi = rssi; + pkt->snr = snr; + + atomic_set(&self->_rx_head, next_head); + self->_last_rssi = (float)rssi; + self->_last_snr = (float)snr; + atomic_inc(&self->_packets_recv); + + if (self->_rx_cb) { + self->_rx_cb(self->_rx_cb_user_data); + } +} + +/* ── Config helpers ───────────────────────────────────────────────────── */ + +void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx) +{ + memset(&cfg, 0, sizeof(cfg)); + /* Override wins for freq/bw/sf/cr (tempradio). Power, preamble, and + * other fields still come from _prefs. */ + float freq_mhz = _has_radio_override ? _override_freq + : (_prefs ? _prefs->freq : (LoRaConfig::FREQ_HZ / 1000000.0f)); + float bw_khz = _has_radio_override ? _override_bw + : (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH); + uint8_t sf = _has_radio_override ? _override_sf + : (_prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR); + uint8_t cr = _has_radio_override ? _override_cr + : (_prefs ? _prefs->cr : LoRaConfig::CODING_RATE); + cfg.frequency = (uint32_t)(freq_mhz * 1000000.0f); + cfg.bandwidth = bw_khz_to_enum((uint16_t)bw_khz); + cfg.datarate = (enum lora_datarate)sf; + cfg.coding_rate = cr_to_enum(cr); + cfg.preamble_len = preambleLengthForSF(sf); + cfg.tx_power = _prefs ? (int8_t)_prefs->tx_power_dbm + : LoRaConfig::TX_POWER_DBM; +#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM + if (cfg.tx_power > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) { + cfg.tx_power = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; + } +#endif + /* APC reduction (applied after all clamps) */ + cfg.tx_power -= _tx_power_reduction_db; + if (cfg.tx_power < -9) cfg.tx_power = -9; + + cfg.tx = tx; + cfg.iq_inverted = false; + cfg.public_network = false; + cfg.packet_crc_disable = false; + + /* LBT: driver performs hardware CAD before TX, returns -EBUSY if busy */ + if (tx) { + cfg.cad.mode = LORA_CAD_MODE_LBT; + } +} + +/** + * Compare radio-relevant fields of two modem configs. + * Ignores the tx flag — that only selects TX vs RX mode, the actual + * modem parameters (freq, SF, BW, CR, power) are what the driver + * programs into registers. + */ +static bool configParamsEqual(const struct lora_modem_config &a, + const struct lora_modem_config &b) +{ + /* CRITICAL: a.tx == b.tx MUST be compared — without it, switching + * RX→TX skips lora_config() for TX params, breaking transmit. */ + return a.frequency == b.frequency && + a.bandwidth == b.bandwidth && + a.datarate == b.datarate && + a.coding_rate == b.coding_rate && + a.preamble_len == b.preamble_len && + a.tx_power == b.tx_power && + a.tx == b.tx && + a.iq_inverted == b.iq_inverted && + a.public_network == b.public_network; +} + +/** + * Check if only the TX/RX direction changed (all radio params identical). + * Used to skip the full lora_config() call on TX↔RX transitions when + * the driver already has valid TX and RX configs from previous calls. + */ +static bool onlyDirectionDiffers(const struct lora_modem_config &a, + const struct lora_modem_config &b) +{ + return a.frequency == b.frequency && + a.bandwidth == b.bandwidth && + a.datarate == b.datarate && + a.coding_rate == b.coding_rate && + a.preamble_len == b.preamble_len && + a.tx_power == b.tx_power && + a.iq_inverted == b.iq_inverted && + a.public_network == b.public_network && + a.tx != b.tx; +} + +void LoRaRadioBase::configureRx() +{ + struct lora_modem_config cfg; + buildModemConfig(cfg, false); + + if (_config_cached && configParamsEqual(cfg, _last_cfg)) { + LOG_DBG("configureRx: params unchanged, skipping hwConfigure"); + return; + } + + /* Fast path: if only the TX/RX direction changed, skip the full + * hwConfigure → lora_config() call. The driver already has a valid + * RX config (RadioSetRxConfig) from a previous cycle — Radio.Rx(0) + * in hwStartReceive() will use those register values directly. + * This avoids the modem_acquire → modem_release → Radio.Sleep() + * round-trip that wastes ~5 ms on every TX→RX transition. + * + * Not used for loramac-node: Radio.SetTxConfig() and Radio.SetRxConfig() + * configure completely disjoint internal state (including TxTimeout). + * Skipping either on a direction change leaves that state uninitialized. */ + if (!_loramac_node && _config_cached && onlyDirectionDiffers(cfg, _last_cfg)) { + LOG_DBG("configureRx: direction-only change, skip hwConfigure"); + _last_cfg = cfg; + return; + } + + LOG_DBG("configureRx: freq=%u bw=%d sf=%d cr=%d pwr=%d", + cfg.frequency, (int)cfg.bandwidth, (int)cfg.datarate, + (int)cfg.coding_rate, cfg.tx_power); + + if (hwConfigure(cfg)) { + _last_cfg = cfg; + _config_cached = true; + } else { + _config_cached = false; + } +} + +void LoRaRadioBase::configureTx() +{ + struct lora_modem_config cfg; + buildModemConfig(cfg, true); + + if (_config_cached && configParamsEqual(cfg, _last_cfg)) { + LOG_DBG("configureTx: params unchanged, skipping hwConfigure"); + return; + } + + /* Fast path: direction-only change (RX→TX). The driver already + * has a valid TX config (RadioSetTxConfig with TxTimeout=4000) + * from a previous cycle — Radio.Send() will use those values. + * Not used for loramac-node (see configureRx comment above). */ + if (!_loramac_node && _config_cached && onlyDirectionDiffers(cfg, _last_cfg)) { + LOG_DBG("configureTx: direction-only change, skip hwConfigure"); + _last_cfg = cfg; + return; + } + + if (hwConfigure(cfg)) { + _last_cfg = cfg; + _config_cached = true; + } else { + _config_cached = false; + } +} + +/* ── Lifecycle ────────────────────────────────────────────────────────── */ + +void LoRaRadioBase::begin() +{ + if (!device_is_ready(_dev)) { + LOG_ERR("LoRa device not ready"); + return; + } + + /* Subclass begin() calls startTxThread() before calling us. + * + * RX boost and duty cycle are set via constructor defaults: + * _rx_boost_enabled = true (boosted +3dB, overridable via setRxBoost()) + * _rx_duty_cycle_enabled = CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE + * Callers can override after begin() via setRxBoost() / enableRxDutyCycle(). + */ + + startReceive(); + + uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f) + : LoRaConfig::FREQ_HZ; + uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; + uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw) + : (uint16_t)LoRaConfig::BANDWIDTH; + uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; + int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm + : LoRaConfig::TX_POWER_DBM; + + LOG_INF("radio started: freq=%u bw=%u sf=%u cr=%u pwr=%d", + freq, bw_khz, sf, cr, tx_pwr); +} + +void LoRaRadioBase::reconfigure() +{ + hwCancelReceive(); + atomic_set(&_in_recv_mode, 0); + _config_cached = false; /* Force full reconfigure */ + startReceive(); + + uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f) + : LoRaConfig::FREQ_HZ; + uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; + uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw) + : (uint16_t)LoRaConfig::BANDWIDTH; + uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; + int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm + : LoRaConfig::TX_POWER_DBM; + + LOG_INF("radio reconfigured: freq=%u bw=%u sf=%u cr=%u pwr=%d", + freq, bw_khz, sf, cr, tx_pwr); +} + +void LoRaRadioBase::reconfigureWithParams(float freq, float bw, uint8_t sf, uint8_t cr) +{ + /* Callers (ObserverMesh CLI handlers) write to _prefs and call + * savePrefs() before invoking us — the radio just needs to pick up + * the new params. Tempradio uses setRadioOverride() instead so it + * never touches _prefs. */ + (void)freq; (void)bw; (void)sf; (void)cr; + reconfigure(); +} + +void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr) +{ + _override_freq = freq; + _override_bw = bw; + _override_sf = sf; + _override_cr = cr; + _has_radio_override = true; + reconfigure(); +} + +void LoRaRadioBase::clearRadioOverride() +{ + if (!_has_radio_override) { + return; + } + _has_radio_override = false; + reconfigure(); +} + +void LoRaRadioBase::startReceive() +{ + configureRx(); + + int ret; + + if (_rx_duty_cycle_enabled) { + /* Fixed duty-cycle window for field validation. + * Keep build-time tunable via constants above. */ + struct lora_modem_config cfg; + buildModemConfig(cfg, false); + + uint8_t sf = (uint8_t)cfg.datarate; + uint32_t bw_hz = bandwidth_to_hz(cfg.bandwidth); + float bw_khz = (float)bw_hz / 1000.0f; + if (bw_khz > 0.0f) { + uint32_t symbol_us = (uint32_t)((float)(1 << sf) * 1000.0f / bw_khz); + uint32_t rx_us = RX_DUTY_RX_SYMBOLS * symbol_us; + uint32_t sleep_us = RX_DUTY_SLEEP_SYMBOLS * symbol_us; + + ret = lora_recv_duty_cycle(_dev, + K_USEC(rx_us), + K_USEC(sleep_us), + rxCallbackStatic, this); + if (ret == 0) { + atomic_set(&_in_recv_mode, 1); + return; + } + if (ret != -ENOSYS) { + LOG_ERR("lora_recv_duty_cycle failed: %d", ret); + } + } + /* Fall through to normal recv_async */ + } + + ret = lora_recv_async(_dev, rxCallbackStatic, this); + if (ret < 0) { + LOG_ERR("lora_recv_async failed: %d", ret); + atomic_set(&_in_recv_mode, 0); + return; + } + atomic_set(&_in_recv_mode, 1); +} + +/* ── RX/TX ────────────────────────────────────────────────────────────── */ + +int LoRaRadioBase::recvRaw(uint8_t *bytes, int sz) +{ + uint8_t tail = (uint8_t)atomic_get(&_rx_tail); + if (atomic_get(&_rx_head) == tail) { + return 0; + } + + RxPacket *pkt = &_rx_ring[tail]; + uint16_t len = pkt->len; + if (len > (uint16_t)sz) { + len = (uint16_t)sz; + } + + memcpy(bytes, pkt->data, len); + _last_rssi = (float)pkt->rssi; + _last_snr = (float)pkt->snr; + atomic_set(&_rx_tail, (tail + 1) % RX_RING_SIZE); + return (int)len; +} + +bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) +{ + if (len > (int)sizeof(_tx_buf)) { + return false; + } + + /* Defensive gate: callers should defer TX while radio is BUSY. */ + if (!isRadioReady()) { + return false; + } + + /* Last-moment hardware check before killing active RX. + * Closes the race between the Dispatcher's isReceiving() guard + * and hwCancelReceive() — if a preamble arrived in that gap, + * abort TX and let the Dispatcher re-queue. */ + if (hwIsPreambleDetected()) { + return false; + } + + _board->onBeforeTransmit(); + atomic_set(&_tx_active, 1); + atomic_set(&_in_recv_mode, 0); + + hwCancelReceive(); + configureTx(); + + memcpy(_tx_buf, bytes, len); + k_poll_signal_reset(&_tx_signal); + + int ret = hwSendAsync(_tx_buf, (uint32_t)len, &_tx_signal); + if (ret < 0) { + LOG_ERR("hwSendAsync failed: %d", ret); + _board->onAfterTransmit(); + atomic_set(&_tx_active, 0); + startReceive(); + return false; + } + + LOG_DBG("TX started async, len=%d", len); + k_sem_give(&_tx_start_sem); + return true; +} + +bool LoRaRadioBase::isSendComplete() +{ + return !atomic_get(&_tx_active); +} + +void LoRaRadioBase::onSendFinished() +{ + /* Nothing needed — TX state tracked via _tx_active */ +} + +bool LoRaRadioBase::isInRecvMode() const +{ + return atomic_get(&_in_recv_mode) != 0; +} + +float LoRaRadioBase::getLastRSSI() const +{ + return _last_rssi; +} + +float LoRaRadioBase::getLastSNR() const +{ + return _last_snr; +} + +bool LoRaRadioBase::isRadioReady() +{ + /* BUSY high means the radio cannot accept SPI commands now + * (e.g. duty-cycle sleep phase on SX126x/LR11xx). */ + return !hwIsChipBusy(); +} + +/* ── Airtime + scoring ────────────────────────────────────────────────── */ + +uint32_t LoRaRadioBase::getEstAirtimeFor(int len_bytes) +{ + uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; + float bw = _prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH; + uint8_t cr_val = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; + + if (sf < 6) sf = 6; + if (sf > 12) sf = 12; + if (bw < 7.0f) bw = 125.0f; + if (cr_val < 5) cr_val = 5; + if (cr_val > 8) cr_val = 8; + + float t_sym = (float)(1 << sf) / (bw * 1000.0f); + float t_preamble = (preambleLengthForSF(sf) + 4.25f) * t_sym; + + float de = (sf >= 11) ? 1.0f : 0.0f; + float num = 8.0f * len_bytes - 4.0f * sf + 28.0f + 16.0f; + float den = 4.0f * (sf - 2.0f * de); + if (den < 1.0f) den = 4.0f; + float n_payload = 8.0f + fmaxf(ceilf(num / den) * (cr_val - 4 + 4), 0.0f); + + float t_payload = n_payload * t_sym; + return (uint32_t)((t_preamble + t_payload) * 1000.0f); +} + +float LoRaRadioBase::packetScore(float snr, int packet_len) +{ + int sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; + if (sf < 7 || sf > 12) return 0.0f; + if (snr < lora_snr_threshold[sf - 7]) return 0.0f; + + float success_rate = (snr - lora_snr_threshold[sf - 7]) / 10.0f; + float collision_penalty = 1.0f - ((float)packet_len / 256.0f); + float score = success_rate * collision_penalty; + if (score < 0.0f) score = 0.0f; + if (score > 1.0f) score = 1.0f; + return score; +} + +/* ── Advanced radio features ──────────────────────────────────────────── */ + +int LoRaRadioBase::getNoiseFloor() const +{ + return _noise_floor; +} + +void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) +{ + _calibration_threshold = threshold; + + if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { + return; + } + + /* Skip when the radio cannot accept commands right now + * (e.g. duty-cycle sleep BUSY window). */ + if (!isRadioReady()) { + return; + } + + /* Skip if mid-receive — don't want signal energy in the floor. */ + if (isReceiving()) { + return; + } + + /* Median of multiple RSSI reads (~200 us). Rejects up to N/2-1 + * outliers in either direction without the downward bias of min + * or the spike sensitivity of average. Insertion sort is fine + * for N=8 (28 comparisons worst case, all in registers). */ + int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK]; + for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { + samples[i] = hwGetCurrentRSSI(); + if (samples[i] == -128) { + /* Chip busy or RSSI read contended — retry next tick. */ + return; + } + } + /* Insertion sort — tiny array, branch-friendly on Cortex-M */ + for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { + int16_t key = samples[i]; + int j = i - 1; + while (j >= 0 && samples[j] > key) { + samples[j + 1] = samples[j]; + j--; + } + samples[j + 1] = key; + } + int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] + + samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2; + + /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. */ + if (_noise_floor == DEFAULT_NOISE_FLOOR) { + _noise_floor = rssi; + if (_noise_floor < -120) _noise_floor = -120; + if (_noise_floor > -50) _noise_floor = -50; + _ema_unguarded = 0; + LOG_DBG("noise_floor_cal: seed=%d", _noise_floor); + return; + } + + /* Threshold filter with warmup and periodic bypass. + * + * _ema_unguarded counts up from 0 on every tick. + * Ticks 0..W-1 (warmup): all samples accepted for fast convergence + * after seed/reset — prevents a bad seed from locking out the + * real noise floor via a too-tight threshold. + * Ticks W+: threshold filter active. Every Pth tick one sample + * bypasses the filter so the floor can track sustained upward + * shifts (new interference, antenna change). + * The EMA's 1/8 weight naturally dampens isolated spikes. */ + const int W = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 — warmup ticks */ + const int P = NOISE_FLOOR_UNGUARDED_INTERVAL; /* 16 — periodic interval */ + bool warmup = (_ema_unguarded < W); + bool periodic = (!warmup && (_ema_unguarded & (P - 1)) == 0); + _ema_unguarded++; /* wraps at 255 — harmless */ + + if (!warmup && !periodic && + rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { + return; + } + + /* EMA: floor += round_nearest((sample - floor) / W). + * Plain >> has downward bias (-1>>3 == -1 but +1>>3 == 0). + * Plain / has a ±7 dead zone (small drifts ignored). + * Round-to-nearest: add half the divisor before dividing, + * with sign-aware bias so both directions are symmetric. */ + int diff = rssi - _noise_floor; + int half = W / 2; /* 4 */ + int step = (diff + (diff > 0 ? half : -half)) / W; + _noise_floor += step; + if (_noise_floor < -120) _noise_floor = -120; + if (_noise_floor > -50) _noise_floor = -50; + + LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", + rssi, _noise_floor, _ema_unguarded - 1); +} + +void LoRaRadioBase::resetAGC() +{ + /* Don't reset AGC while transmitting or receiving — warm sleep would + * abort the TX or corrupt the incoming packet. maintenanceLoop() + * will retry next housekeeping cycle. + * Also skip if the chip is in its duty-cycle sleep phase: hwResetAGC() + * holds the SPI mutex with K_FOREVER and would hang for 3 s. */ + if (atomic_get(&_tx_active) || isReceiving()) { + return; + } + if (_rx_duty_cycle_enabled && hwIsChipBusy()) { + return; + } + + hwResetAGC(); + + /* Warm sleep + calibrate leaves the radio in STANDBY. + * Restart receive if we were in RX mode. */ + if (atomic_get(&_in_recv_mode)) { + startReceive(); + } + + /* Reset noise floor so it reconverges from scratch (seed + warmup). + * Without this, a stuck _noise_floor of -120 makes the sampling threshold + * too low to accept normal samples, self-reinforcing the stuck value. */ + _noise_floor = DEFAULT_NOISE_FLOOR; + _ema_unguarded = 0; +} + +bool LoRaRadioBase::isReceiving() +{ + if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { + return false; + } + if (hwIsPreambleDetected()) { + return true; + } + return isChannelActive(); +} + +bool LoRaRadioBase::isChannelActive(int threshold) +{ + if (threshold == 0) { + threshold = _calibration_threshold; + } + if (threshold == 0) { + return false; + } + int16_t rssi = hwGetCurrentRSSI(); + return rssi > (_noise_floor + threshold); +} + +/* ── Power saving ─────────────────────────────────────────────────────── */ + +void LoRaRadioBase::enableRxDutyCycle(bool enable) +{ + _rx_duty_cycle_enabled = enable; + LOG_INF("RX duty cycle %s", enable ? "enabled" : "disabled"); + + if (atomic_get(&_in_recv_mode)) { + /* Restart receive to apply new duty cycle state */ + hwCancelReceive(); + atomic_set(&_in_recv_mode, 0); + startReceive(); + } +} + +void LoRaRadioBase::setRxBoost(bool enable) +{ + _rx_boost_enabled = enable; + LOG_INF("RX boost %s (+3dB sensitivity, +2mA)", + enable ? "enabled" : "disabled"); + if (atomic_get(&_in_recv_mode)) { + hwSetRxBoost(enable); + } +} + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index 207416b..1612d84 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -1,100 +1,100 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * SX126x hardware hooks for LoRaRadioBase — native Zephyr driver. - */ - -#include "SX126xRadio.h" -#include - -/* Native SX126x driver extension API */ -extern "C" { -#include "sx126x_ext.h" -} - -#include -LOG_MODULE_REGISTER(sx126x_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); - -namespace mesh { - -K_THREAD_STACK_DEFINE(sx126x_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -SX126xRadio::SX126xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) -{ -} - -void SX126xRadio::begin() -{ - startTxThread(sx126x_tx_wait_stack, - K_THREAD_STACK_SIZEOF(sx126x_tx_wait_stack)); - LoRaRadioBase::begin(); - -#if IS_ENABLED(CONFIG_ZEPHCORE_SX126X_HELTEC_REG_PATCH) - /* Apply undocumented register 0x8B5 RX improvement (MeshCore PR#1398). - * Must run after lora_config() has been called (via startReceive above). */ - sx126x_apply_heltec_reg_patch(_dev); - LOG_INF("Applied Heltec reg 0x8B5 RX patch"); -#endif -} - -/* ── Hardware primitives ──────────────────────────────────────────────── */ - -bool SX126xRadio::hwConfigure(const struct lora_modem_config &cfg) -{ - int ret = lora_config(_dev, const_cast(&cfg)); - if (ret < 0) { - LOG_ERR("lora_config failed: %d", ret); - return false; - } - return true; -} - -void SX126xRadio::hwCancelReceive() -{ - lora_recv_async(_dev, NULL, NULL); -} - -int SX126xRadio::hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) -{ - return lora_send_async(_dev, buf, len, sig); -} - -int16_t SX126xRadio::hwGetCurrentRSSI() -{ - return sx126x_get_rssi_inst(_dev); -} - -bool SX126xRadio::hwIsPreambleDetected() -{ - return sx126x_is_receiving(_dev); -} - -void SX126xRadio::hwSetRxBoost(bool enable) -{ - sx126x_set_rx_boost(_dev, enable); -} - -void SX126xRadio::hwResetAGC() -{ - /* Warm sleep → Calibrate(ALL) → re-calibrate image → re-apply RX settings */ - sx126x_reset_agc(_dev); -} - -bool SX126xRadio::hwIsChipBusy() -{ - return sx126x_is_chip_busy(_dev); -} - -uint32_t SX126xRadio::getDutyCycleTimeoutRestarts() const -{ - return sx126x_get_dc_timeout_restarts(_dev); -} - -void SX126xRadio::resetDutyCycleTimeoutRestarts() -{ - sx126x_reset_dc_timeout_restarts(_dev); -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * SX126x hardware hooks for LoRaRadioBase — native Zephyr driver. + */ + +#include "SX126xRadio.h" +#include + +/* Native SX126x driver extension API */ +extern "C" { +#include "sx126x_ext.h" +} + +#include +LOG_MODULE_REGISTER(sx126x_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); + +namespace mesh { + +K_THREAD_STACK_DEFINE(sx126x_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); + +SX126xRadio::SX126xRadio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs) + : LoRaRadioBase(lora_dev, board, prefs) +{ +} + +void SX126xRadio::begin() +{ + startTxThread(sx126x_tx_wait_stack, + K_THREAD_STACK_SIZEOF(sx126x_tx_wait_stack)); + LoRaRadioBase::begin(); + +#if IS_ENABLED(CONFIG_ZEPHCORE_SX126X_HELTEC_REG_PATCH) + /* Apply undocumented register 0x8B5 RX improvement (MeshCore PR#1398). + * Must run after lora_config() has been called (via startReceive above). */ + sx126x_apply_heltec_reg_patch(_dev); + LOG_INF("Applied Heltec reg 0x8B5 RX patch"); +#endif +} + +/* ── Hardware primitives ──────────────────────────────────────────────── */ + +bool SX126xRadio::hwConfigure(const struct lora_modem_config &cfg) +{ + int ret = lora_config(_dev, const_cast(&cfg)); + if (ret < 0) { + LOG_ERR("lora_config failed: %d", ret); + return false; + } + return true; +} + +void SX126xRadio::hwCancelReceive() +{ + lora_recv_async(_dev, NULL, NULL); +} + +int SX126xRadio::hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) +{ + return lora_send_async(_dev, buf, len, sig); +} + +int16_t SX126xRadio::hwGetCurrentRSSI() +{ + return sx126x_get_rssi_inst(_dev); +} + +bool SX126xRadio::hwIsPreambleDetected() +{ + return sx126x_is_receiving(_dev); +} + +void SX126xRadio::hwSetRxBoost(bool enable) +{ + sx126x_set_rx_boost(_dev, enable); +} + +void SX126xRadio::hwResetAGC() +{ + /* Warm sleep → Calibrate(ALL) → re-calibrate image → re-apply RX settings */ + sx126x_reset_agc(_dev); +} + +bool SX126xRadio::hwIsChipBusy() +{ + return sx126x_is_chip_busy(_dev); +} + +uint32_t SX126xRadio::getDutyCycleTimeoutRestarts() const +{ + return sx126x_get_dc_timeout_restarts(_dev); +} + +void SX126xRadio::resetDutyCycleTimeoutRestarts() +{ + sx126x_reset_dc_timeout_restarts(_dev); +} + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index 397e39a..745dad0 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -1,36 +1,36 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Radio adapter for SX126x (SX1261/SX1262/SX1268) using native Zephyr driver - */ - -#pragma once - -#include "LoRaRadioBase.h" - -namespace mesh { - -class SX126xRadio : public LoRaRadioBase { -public: - SX126xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); - - void begin() override; - - /* Duty-cycle preamble false-positive stats (SX126x-specific) */ - uint32_t getDutyCycleTimeoutRestarts() const override; - void resetDutyCycleTimeoutRestarts() override; - -protected: - /* Hardware primitives */ - bool hwConfigure(const struct lora_modem_config &cfg) override; - void hwCancelReceive() override; - int hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) override; - int16_t hwGetCurrentRSSI() override; - bool hwIsPreambleDetected() override; - void hwSetRxBoost(bool enable) override; - void hwResetAGC() override; - bool hwIsChipBusy() override; -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Radio adapter for SX126x (SX1261/SX1262/SX1268) using native Zephyr driver + */ + +#pragma once + +#include "LoRaRadioBase.h" + +namespace mesh { + +class SX126xRadio : public LoRaRadioBase { +public: + SX126xRadio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs = nullptr); + + void begin() override; + + /* Duty-cycle preamble false-positive stats (SX126x-specific) */ + uint32_t getDutyCycleTimeoutRestarts() const override; + void resetDutyCycleTimeoutRestarts() override; + +protected: + /* Hardware primitives */ + bool hwConfigure(const struct lora_modem_config &cfg) override; + void hwCancelReceive() override; + int hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) override; + int16_t hwGetCurrentRSSI() override; + bool hwIsPreambleDetected() override; + void hwSetRxBoost(bool enable) override; + void hwResetAGC() override; + bool hwIsChipBusy() override; +}; + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/SX127xRadio.cpp b/zephcore/adapters/radio/SX127xRadio.cpp index 9449e96..bc46034 100644 --- a/zephcore/adapters/radio/SX127xRadio.cpp +++ b/zephcore/adapters/radio/SX127xRadio.cpp @@ -1,114 +1,114 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * SX127x hardware hooks for LoRaRadioBase — Zephyr loramac-node driver. - * - * The SX127x driver (loramac-node/sx127x.c) exposes only the standard - * Zephyr LoRa API. Chip-specific features that the SX126x native driver - * provides via sx126x_ext.h are not available here: - * - * hwGetCurrentRSSI() — returns -80 dBm sentinel (no hardware path) - * hwIsPreambleDetected()— always false (no preamble-detect IRQ exposed) - * hwSetRxBoost() — no-op (SX127x has no RX boost register) - * hwResetAGC() — no-op (loramac-node manages AGC internally) - * hwIsChipBusy() — inherited false (no BUSY pin on SX127x) - * - * Everything else (configure, send, receive) uses the standard API. - */ - -#include "SX127xRadio.h" -#include -#include - -#include -LOG_MODULE_REGISTER(sx127x_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); - -namespace mesh { - -K_THREAD_STACK_DEFINE(sx127x_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -SX127xRadio::SX127xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) -{ - /* SX127x has no RX boost feature — start with boost disabled */ - _rx_boost_enabled = false; - /* loramac-node requires full lora_config() on every TX/RX direction - * change — Radio.SetTxConfig() and Radio.SetRxConfig() configure - * completely disjoint internal state in the loramac-node library. */ - _loramac_node = true; -} - -void SX127xRadio::begin() -{ - startTxThread(sx127x_tx_wait_stack, - K_THREAD_STACK_SIZEOF(sx127x_tx_wait_stack)); - LoRaRadioBase::begin(); -} - -/* ── Hardware primitives ──────────────────────────────────────────────── */ - -bool SX127xRadio::hwConfigure(const struct lora_modem_config &cfg) -{ - int ret = lora_config(_dev, const_cast(&cfg)); - if (ret < 0) { - LOG_ERR("lora_config failed: %d", ret); - return false; - } - return true; -} - -void SX127xRadio::hwCancelReceive() -{ - lora_recv_async(_dev, NULL, NULL); -} - -int SX127xRadio::hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) -{ - return lora_send_async(_dev, buf, len, sig); -} - -int16_t SX127xRadio::hwGetCurrentRSSI() -{ - /* SX127x loramac-node driver does not expose an instantaneous RSSI - * function via the standard Zephyr API. Return a plausible noise-floor - * sentinel so LoRaRadioBase::triggerNoiseFloorCalibrate() converges - * to a reasonable value rather than being seeded with garbage. */ - return -80; -} - -bool SX127xRadio::hwIsPreambleDetected() -{ - /* No preamble-detect IRQ accessible through the standard Zephyr LoRa - * API for the loramac-node driver. Returning false means TX will - * not abort for an in-progress preamble — acceptable on SX127x. */ - return false; -} - -void SX127xRadio::hwSetRxBoost(bool enable) -{ - /* SX127x does not have a dedicated RX boost / high-sensitivity mode - * switch. Sensitivity is controlled via lora_config tx_power and - * the DTS power-amplifier-output property. Nothing to do here. */ - ARG_UNUSED(enable); -} - -void SX127xRadio::hwResetAGC() -{ - /* The loramac-node SX127x driver manages AGC recalibration internally - * (RadioSetRxConfig re-programs all gain registers on every RX config - * call). No explicit AGC reset is needed or possible via the standard - * API. */ -} - -void SX127xRadio::resetAGC() -{ - /* hwResetAGC() is a no-op, so skip the base-class resetAGC() entirely. - * The base class calls startReceive() after hwResetAGC(), but the - * loramac-node modem mutex (STATE_BUSY during async RX) causes - * lora_recv_async() to return -EBUSY, setting _in_recv_mode = 0 and - * corrupting the state machine. The loramac-node driver self-manages - * AGC, so nothing needs to happen here. */ -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * SX127x hardware hooks for LoRaRadioBase — Zephyr loramac-node driver. + * + * The SX127x driver (loramac-node/sx127x.c) exposes only the standard + * Zephyr LoRa API. Chip-specific features that the SX126x native driver + * provides via sx126x_ext.h are not available here: + * + * hwGetCurrentRSSI() — returns -80 dBm sentinel (no hardware path) + * hwIsPreambleDetected()— always false (no preamble-detect IRQ exposed) + * hwSetRxBoost() — no-op (SX127x has no RX boost register) + * hwResetAGC() — no-op (loramac-node manages AGC internally) + * hwIsChipBusy() — inherited false (no BUSY pin on SX127x) + * + * Everything else (configure, send, receive) uses the standard API. + */ + +#include "SX127xRadio.h" +#include +#include + +#include +LOG_MODULE_REGISTER(sx127x_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); + +namespace mesh { + +K_THREAD_STACK_DEFINE(sx127x_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); + +SX127xRadio::SX127xRadio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs) + : LoRaRadioBase(lora_dev, board, prefs) +{ + /* SX127x has no RX boost feature — start with boost disabled */ + _rx_boost_enabled = false; + /* loramac-node requires full lora_config() on every TX/RX direction + * change — Radio.SetTxConfig() and Radio.SetRxConfig() configure + * completely disjoint internal state in the loramac-node library. */ + _loramac_node = true; +} + +void SX127xRadio::begin() +{ + startTxThread(sx127x_tx_wait_stack, + K_THREAD_STACK_SIZEOF(sx127x_tx_wait_stack)); + LoRaRadioBase::begin(); +} + +/* ── Hardware primitives ──────────────────────────────────────────────── */ + +bool SX127xRadio::hwConfigure(const struct lora_modem_config &cfg) +{ + int ret = lora_config(_dev, const_cast(&cfg)); + if (ret < 0) { + LOG_ERR("lora_config failed: %d", ret); + return false; + } + return true; +} + +void SX127xRadio::hwCancelReceive() +{ + lora_recv_async(_dev, NULL, NULL); +} + +int SX127xRadio::hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) +{ + return lora_send_async(_dev, buf, len, sig); +} + +int16_t SX127xRadio::hwGetCurrentRSSI() +{ + /* SX127x loramac-node driver does not expose an instantaneous RSSI + * function via the standard Zephyr API. Return a plausible noise-floor + * sentinel so LoRaRadioBase::triggerNoiseFloorCalibrate() converges + * to a reasonable value rather than being seeded with garbage. */ + return -80; +} + +bool SX127xRadio::hwIsPreambleDetected() +{ + /* No preamble-detect IRQ accessible through the standard Zephyr LoRa + * API for the loramac-node driver. Returning false means TX will + * not abort for an in-progress preamble — acceptable on SX127x. */ + return false; +} + +void SX127xRadio::hwSetRxBoost(bool enable) +{ + /* SX127x does not have a dedicated RX boost / high-sensitivity mode + * switch. Sensitivity is controlled via lora_config tx_power and + * the DTS power-amplifier-output property. Nothing to do here. */ + ARG_UNUSED(enable); +} + +void SX127xRadio::hwResetAGC() +{ + /* The loramac-node SX127x driver manages AGC recalibration internally + * (RadioSetRxConfig re-programs all gain registers on every RX config + * call). No explicit AGC reset is needed or possible via the standard + * API. */ +} + +void SX127xRadio::resetAGC() +{ + /* hwResetAGC() is a no-op, so skip the base-class resetAGC() entirely. + * The base class calls startReceive() after hwResetAGC(), but the + * loramac-node modem mutex (STATE_BUSY during async RX) causes + * lora_recv_async() to return -EBUSY, setting _in_recv_mode = 0 and + * corrupting the state machine. The loramac-node driver self-manages + * AGC, so nothing needs to happen here. */ +} + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/SX127xRadio.h b/zephcore/adapters/radio/SX127xRadio.h index 1635c8a..2f9c7d1 100644 --- a/zephcore/adapters/radio/SX127xRadio.h +++ b/zephcore/adapters/radio/SX127xRadio.h @@ -1,56 +1,56 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Radio adapter for SX127x (SX1272/SX1276/SX1278) using Zephyr loramac-node driver. - * - * The SX127x loramac-node driver supports the standard Zephyr LoRa API - * (lora_config, lora_send_async, lora_recv_async) but has no extension - * API for instantaneous RSSI, preamble detection, or RX boost. - * Those features are stubbed out below. - */ - -#pragma once - -#include "LoRaRadioBase.h" - -namespace mesh { - -class SX127xRadio : public LoRaRadioBase { -public: - SX127xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); - - void begin() override; - -protected: - /* Hardware primitives */ - bool hwConfigure(const struct lora_modem_config &cfg) override; - void hwCancelReceive() override; - int hwSendAsync(uint8_t *buf, uint32_t len, - struct k_poll_signal *sig) override; - - /* SX127x via loramac-node has no instantaneous RSSI API. - * Returns a fixed sentinel (-80 dBm) — noise floor calibration - * will converge on this value rather than the real noise floor. */ - int16_t hwGetCurrentRSSI() override; - - /* SX127x has no preamble-detected IRQ accessible via standard API. - * Always returns false — TX will not abort for a detected preamble. */ - bool hwIsPreambleDetected() override; - - /* SX127x has no RX boost / LNA gain switch via standard API. No-op. */ - void hwSetRxBoost(bool enable) override; - - /* SX127x loramac-node driver manages AGC automatically. No-op. */ - void hwResetAGC() override; - - /* Override public resetAGC(): hwResetAGC() is a no-op, and the - * loramac-node modem mutex makes the base-class startReceive() call - * fail with -EBUSY (modem STATE_BUSY during async RX), which would - * set _in_recv_mode = 0 and corrupt the state machine. */ - void resetAGC() override; - - /* SX127x has no BUSY pin. Default (false) from base is correct. */ - /* bool hwIsChipBusy() — inherited, returns false */ -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Radio adapter for SX127x (SX1272/SX1276/SX1278) using Zephyr loramac-node driver. + * + * The SX127x loramac-node driver supports the standard Zephyr LoRa API + * (lora_config, lora_send_async, lora_recv_async) but has no extension + * API for instantaneous RSSI, preamble detection, or RX boost. + * Those features are stubbed out below. + */ + +#pragma once + +#include "LoRaRadioBase.h" + +namespace mesh { + +class SX127xRadio : public LoRaRadioBase { +public: + SX127xRadio(const struct device *lora_dev, MainBoard &board, + NodePrefs *prefs = nullptr); + + void begin() override; + +protected: + /* Hardware primitives */ + bool hwConfigure(const struct lora_modem_config &cfg) override; + void hwCancelReceive() override; + int hwSendAsync(uint8_t *buf, uint32_t len, + struct k_poll_signal *sig) override; + + /* SX127x via loramac-node has no instantaneous RSSI API. + * Returns a fixed sentinel (-80 dBm) — noise floor calibration + * will converge on this value rather than the real noise floor. */ + int16_t hwGetCurrentRSSI() override; + + /* SX127x has no preamble-detected IRQ accessible via standard API. + * Always returns false — TX will not abort for a detected preamble. */ + bool hwIsPreambleDetected() override; + + /* SX127x has no RX boost / LNA gain switch via standard API. No-op. */ + void hwSetRxBoost(bool enable) override; + + /* SX127x loramac-node driver manages AGC automatically. No-op. */ + void hwResetAGC() override; + + /* Override public resetAGC(): hwResetAGC() is a no-op, and the + * loramac-node modem mutex makes the base-class startReceive() call + * fail with -EBUSY (modem STATE_BUSY during async RX), which would + * set _in_recv_mode = 0 and corrupt the state machine. */ + void resetAGC() override; + + /* SX127x has no BUSY pin. Default (false) from base is correct. */ + /* bool hwIsChipBusy() — inherited, returns false */ +}; + +} /* namespace mesh */ diff --git a/zephcore/app/ObserverMesh.h b/zephcore/app/ObserverMesh.h index 4881afb..8621180 100644 --- a/zephcore/app/ObserverMesh.h +++ b/zephcore/app/ObserverMesh.h @@ -1,93 +1,93 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ObserverMesh — listen-only LoRa mesh node. - * - * Extends mesh::Dispatcher directly (no routing, no flooding, no ACL). - * Every received packet is forwarded to the MQTT publisher queue. - * CLI handles WiFi/MQTT/radio configuration. - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include "RepeaterDataStore.h" -#include "observer_creds.h" - -#ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.15.1-zephyr" -#endif - -#ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE __DATE__ -#endif - -namespace mesh { - -class ObserverMesh : public Dispatcher { - StaticPoolPacketManager _pkt_mgr; - - /* Cached values set by logRxRaw / logRx before onRecvPacket */ - float _last_rssi; - float _last_score; - uint8_t _last_raw[MAX_TRANS_UNIT + 1]; - int _last_raw_len; - - /* Identity and config */ - LocalIdentity _self_id; - NodePrefs _prefs; - RepeaterDataStore *_store; - struct ObserverCreds *_creds; - RNG *_rng; - RTCClock *_rtc; - - /* Pre-built MQTT topic strings (set in begin()) */ - char _pubkey_hex[PUB_KEY_SIZE * 2 + 1]; /* 64 hex chars + NUL */ - char _packets_topic[160]; - char _status_topic[160]; - - /* Private helpers */ - void buildTopics(); - void enqueuePacket(Packet *pkt); - void buildStatusJson(const char *status, char *out, size_t out_size); - uint32_t _start_uptime_secs; - -protected: - /* Capture RSSI + raw bytes before packet is parsed */ - void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; - /* Capture score (called between logRxRaw and onRecvPacket) */ - void logRx(Packet *packet, int len, float score) override; - /* Build JSON and enqueue to MQTT publisher */ - DispatcherAction onRecvPacket(Packet *pkt) override; - -public: - ObserverMesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc); - - /* Initialize: load/generate identity, load/init prefs, start radio RX. */ - void begin(RepeaterDataStore *store, struct ObserverCreds *creds); - - /* Handle a single serial CLI command. - * reply is filled with the response string (CLI_REPLY_SIZE bytes). - * Returns true if the command was 'help' and the caller should print - * the full banner (too long for the reply buffer). */ - bool handleCLI(const char *command, char *reply, int reply_size); - - /* Publish a synthetic zero-hop advert for this observer to the packets - * topic so that CoreScope can place it on the map. No-op if lat/lon - * are not configured in the creds struct. */ - void publishSelfAdvert(); - void publishStatus(const char *status); - - /* Accessors used by main_observer.cpp */ - NodePrefs *getNodePrefs() { return &_prefs; } - const LocalIdentity &getSelfId() const { return _self_id; } - const char *getPacketsTopic() const { return _packets_topic; } - const char *getStatusTopic() const { return _status_topic; } - const char *getPubkeyHex() const { return _pubkey_hex; } -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ObserverMesh — listen-only LoRa mesh node. + * + * Extends mesh::Dispatcher directly (no routing, no flooding, no ACL). + * Every received packet is forwarded to the MQTT publisher queue. + * CLI handles WiFi/MQTT/radio configuration. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "RepeaterDataStore.h" +#include "observer_creds.h" + +#ifndef FIRMWARE_VERSION + #define FIRMWARE_VERSION "v1.15.1-zephyr" +#endif + +#ifndef FIRMWARE_BUILD_DATE + #define FIRMWARE_BUILD_DATE __DATE__ +#endif + +namespace mesh { + +class ObserverMesh : public Dispatcher { + StaticPoolPacketManager _pkt_mgr; + + /* Cached values set by logRxRaw / logRx before onRecvPacket */ + float _last_rssi; + float _last_score; + uint8_t _last_raw[MAX_TRANS_UNIT + 1]; + int _last_raw_len; + + /* Identity and config */ + LocalIdentity _self_id; + NodePrefs _prefs; + RepeaterDataStore *_store; + struct ObserverCreds *_creds; + RNG *_rng; + RTCClock *_rtc; + + /* Pre-built MQTT topic strings (set in begin()) */ + char _pubkey_hex[PUB_KEY_SIZE * 2 + 1]; /* 64 hex chars + NUL */ + char _packets_topic[160]; + char _status_topic[160]; + + /* Private helpers */ + void buildTopics(); + void enqueuePacket(Packet *pkt); + void buildStatusJson(const char *status, char *out, size_t out_size); + uint32_t _start_uptime_secs; + +protected: + /* Capture RSSI + raw bytes before packet is parsed */ + void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + /* Capture score (called between logRxRaw and onRecvPacket) */ + void logRx(Packet *packet, int len, float score) override; + /* Build JSON and enqueue to MQTT publisher */ + DispatcherAction onRecvPacket(Packet *pkt) override; + +public: + ObserverMesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc); + + /* Initialize: load/generate identity, load/init prefs, start radio RX. */ + void begin(RepeaterDataStore *store, struct ObserverCreds *creds); + + /* Handle a single serial CLI command. + * reply is filled with the response string (CLI_REPLY_SIZE bytes). + * Returns true if the command was 'help' and the caller should print + * the full banner (too long for the reply buffer). */ + bool handleCLI(const char *command, char *reply, int reply_size); + + /* Publish a synthetic zero-hop advert for this observer to the packets + * topic so that CoreScope can place it on the map. No-op if lat/lon + * are not configured in the creds struct. */ + void publishSelfAdvert(); + void publishStatus(const char *status); + + /* Accessors used by main_observer.cpp */ + NodePrefs *getNodePrefs() { return &_prefs; } + const LocalIdentity &getSelfId() const { return _self_id; } + const char *getPacketsTopic() const { return _packets_topic; } + const char *getStatusTopic() const { return _status_topic; } + const char *getPubkeyHex() const { return _pubkey_hex; } +}; + +} /* namespace mesh */ diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index e4d9abc..1e266a4 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -1,358 +1,358 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * RepeaterDataStore - Filesystem storage for repeater - */ - -#include "RepeaterDataStore.h" -#include -#include -#include -#include - -LOG_MODULE_REGISTER(zephcore_repeater_store, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); - -RepeaterDataStore::RepeaterDataStore() : _initialized(false) { -} - -bool RepeaterDataStore::begin() { - if (_initialized) return true; - - /* Create repeater directory if it doesn't exist */ - struct fs_dirent entry; - int ret = fs_stat(BASE_PATH, &entry); - if (ret < 0) { - ret = fs_mkdir(BASE_PATH); - if (ret < 0 && ret != -EEXIST) { - LOG_ERR("Failed to create %s: %d", BASE_PATH, ret); - return false; - } - LOG_INF("Created %s directory", BASE_PATH); - } - - _initialized = true; - LOG_INF("RepeaterDataStore initialized at %s", BASE_PATH); - return true; -} - -const char* RepeaterDataStore::getBasePath() const { return BASE_PATH; } - -const char* RepeaterDataStore::getAclPath() const { - static char buf[48]; - snprintf(buf, sizeof(buf), "%s/acl", BASE_PATH); - return buf; -} - -const char* RepeaterDataStore::getRegionsPath() const { - static char buf[48]; - snprintf(buf, sizeof(buf), "%s/regions2", BASE_PATH); - return buf; -} - -bool RepeaterDataStore::loadIdentity(mesh::LocalIdentity& id) { - char path[48]; - snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH); - - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, path, FS_O_READ); - if (ret < 0) { - LOG_DBG("No identity file at %s", path); - return false; - } - - uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE]; - ssize_t n = fs_read(&file, buf, sizeof(buf)); - fs_close(&file); - - LOG_DBG("loadIdentity: read %d bytes from %s", (int)n, path); - - if (n >= PRV_KEY_SIZE) { - if (id.readFrom(buf, n)) { - LOG_INF("Loaded identity from %s", path); - return true; - } - LOG_ERR("loadIdentity: readFrom failed"); - } - - LOG_ERR("Identity file corrupt"); - return false; -} - -bool RepeaterDataStore::saveIdentity(const mesh::LocalIdentity& id) { - if (!_initialized) begin(); - - char path[48]; - char tmp_path[56]; - snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH); - if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) { - return false; - } - - fs_unlink(tmp_path); - - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); - if (ret < 0) { - LOG_ERR("Failed to open %s for write: %d", tmp_path, ret); - return false; - } - - uint8_t buf[PRV_KEY_SIZE]; - int len = id.writeTo(buf, sizeof(buf)); - ssize_t n = fs_write(&file, buf, len); - ret = fs_sync(&file); - fs_close(&file); - - if (n != len || ret < 0) { - LOG_ERR("Failed to write identity: wrote %d of %d sync=%d", (int)n, len, ret); - fs_unlink(tmp_path); - return false; - } - - if (fs_rename(tmp_path, path) < 0) { - LOG_ERR("saveIdentity: rename failed"); - fs_unlink(tmp_path); - return false; - } - LOG_INF("Saved identity to %s", path); - return true; -} - -bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { - char path[48]; - snprintf(path, sizeof(path), "%s/prefs", BASE_PATH); - - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, path, FS_O_READ); - if (ret < 0) { - LOG_DBG("No prefs file at %s, using defaults", path); - initNodePrefs(&prefs); - strcpy(prefs.node_name, "Repeater"); - prefs.advert_loc_policy = ADVERT_LOC_PREFS; - prefs.flood_advert_interval = 25; - prefs.loop_detect = LOOP_DETECT_MINIMAL; - prefs.path_hash_mode = 1; -#if IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE) - prefs.rx_duty_cycle = 1; -#endif - /* Persist defaults so flash always has a prefs file from boot 1. - * Lets later code (e.g. tempradio revert) trust that flash is - * authoritative without a "first run" special case. */ - savePrefs(prefs); - return true; - } - - struct fs_dirent entry; - ret = fs_stat(path, &entry); - LOG_DBG("loadPrefs: file size = %d bytes", ret < 0 ? 0 : (int)entry.size); - - uint8_t pad[25]; - - /* Read prefs in same format as Arduino CommonCLI for compatibility */ - fs_read(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor)); - fs_read(&file, &prefs.node_name, sizeof(prefs.node_name)); - fs_read(&file, pad, 4); - fs_read(&file, &prefs.node_lat, sizeof(prefs.node_lat)); - fs_read(&file, &prefs.node_lon, sizeof(prefs.node_lon)); - fs_read(&file, &prefs.password, sizeof(prefs.password)); - fs_read(&file, &prefs.freq, sizeof(prefs.freq)); - fs_read(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); - fs_read(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd)); - fs_read(&file, &prefs.advert_interval, sizeof(prefs.advert_interval)); - fs_read(&file, pad, 1); - fs_read(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); - fs_read(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); - fs_read(&file, &prefs.guest_password, sizeof(prefs.guest_password)); - fs_read(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); - fs_read(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); - fs_read(&file, &prefs.sf, sizeof(prefs.sf)); - fs_read(&file, &prefs.cr, sizeof(prefs.cr)); - fs_read(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); - fs_read(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); - fs_read(&file, &prefs.bw, sizeof(prefs.bw)); - fs_read(&file, &prefs.agc_reset_interval, sizeof(prefs.agc_reset_interval)); - fs_read(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); - fs_read(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); - fs_read(&file, pad, 1); - fs_read(&file, &prefs.flood_max, sizeof(prefs.flood_max)); - fs_read(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); - fs_read(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold)); - fs_read(&file, pad, 25); // skip bridge settings - fs_read(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); - fs_read(&file, pad, 3); - fs_read(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled)); - fs_read(&file, &prefs.gps_interval, sizeof(prefs.gps_interval)); - fs_read(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); - fs_read(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); - fs_read(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); - fs_read(&file, prefs.owner_info, sizeof(prefs.owner_info)); - /* ZephCore extensions — absent in old 290-byte files; fs_read past EOF is a - * no-op so these fields keep the initNodePrefs() defaults the caller passed - * in (rx_boost=1, rx_duty_cycle=0, apc_enabled=0, apc_margin=16). The - * upgrade block below forces repeater-specific values for old files. */ - fs_read(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); - fs_read(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); - fs_read(&file, &prefs.apc_enabled, sizeof(prefs.apc_enabled)); - fs_read(&file, &prefs.apc_margin, sizeof(prefs.apc_margin)); - - fs_close(&file); - - /* Migrate uninitialized backoff_multiplier (0.0 or NaN) to default */ - if (prefs.backoff_multiplier == 0.0f || prefs.backoff_multiplier != prefs.backoff_multiplier) { - prefs.backoff_multiplier = 0.2f; - } - - LOG_INF("Loaded prefs from %s", path); - LOG_DBG(" name='%s' freq=%.3f sf=%u bw=%.1f tx_pwr=%d", - prefs.node_name, (double)prefs.freq, prefs.sf, (double)prefs.bw, prefs.tx_power_dbm); - - /* Validate radio params - use defaults if garbage */ - if (prefs.freq < 300.0f || prefs.freq > 1000.0f || - prefs.sf < 5 || prefs.sf > 12 || - prefs.bw < 7.0f || prefs.bw > 500.0f) { - LOG_WRN("Invalid radio params in prefs, using defaults: freq=%.3f sf=%u bw=%.1f", - (double)prefs.freq, prefs.sf, (double)prefs.bw); - prefs.freq = 869.618f; - prefs.bw = 62.5f; - prefs.sf = 8; - prefs.cr = 8; - prefs.tx_power_dbm = 22; - } - if (prefs.path_hash_mode > 2) prefs.path_hash_mode = 0; - if (prefs.loop_detect > LOOP_DETECT_STRICT) prefs.loop_detect = LOOP_DETECT_MINIMAL; - if (prefs.rx_boost > 1) prefs.rx_boost = 0; - if (prefs.rx_duty_cycle > 1) prefs.rx_duty_cycle = 0; - if (prefs.apc_enabled > 1) prefs.apc_enabled = 0; - if (prefs.apc_margin < 6 || prefs.apc_margin > 30) prefs.apc_margin = 16; - - /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore - * extension fields, and stored path_hash_mode/loop_detect as zero padding. - * Apply repeater defaults and re-save so values survive subsequent reboots. */ - if (ret >= 0 && entry.size < 294) { - prefs.rx_boost = 1; - prefs.path_hash_mode = 1; - prefs.loop_detect = LOOP_DETECT_MINIMAL; -#if IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE) - prefs.rx_duty_cycle = 1; -#endif - savePrefs(prefs); - LOG_INF("loadPrefs: upgraded prefs format (%d -> 294 bytes)", (int)entry.size); - } - return true; -} - -bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { - if (!_initialized) begin(); - - char path[48]; - char tmp_path[56]; - snprintf(path, sizeof(path), "%s/prefs", BASE_PATH); - if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) { - return false; - } - - fs_unlink(tmp_path); - - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); - if (ret < 0) { - LOG_ERR("Failed to open %s for write: %d", tmp_path, ret); - return false; - } - - uint8_t pad[25]; - memset(pad, 0, sizeof(pad)); - - /* Write prefs in same format as Arduino CommonCLI for compatibility */ - fs_write(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor)); - fs_write(&file, &prefs.node_name, sizeof(prefs.node_name)); - fs_write(&file, pad, 4); - fs_write(&file, &prefs.node_lat, sizeof(prefs.node_lat)); - fs_write(&file, &prefs.node_lon, sizeof(prefs.node_lon)); - fs_write(&file, &prefs.password, sizeof(prefs.password)); - fs_write(&file, &prefs.freq, sizeof(prefs.freq)); - fs_write(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); - fs_write(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd)); - fs_write(&file, &prefs.advert_interval, sizeof(prefs.advert_interval)); - fs_write(&file, pad, 1); - fs_write(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); - fs_write(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); - fs_write(&file, &prefs.guest_password, sizeof(prefs.guest_password)); - fs_write(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); - fs_write(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); - fs_write(&file, &prefs.sf, sizeof(prefs.sf)); - fs_write(&file, &prefs.cr, sizeof(prefs.cr)); - fs_write(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); - fs_write(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); - fs_write(&file, &prefs.bw, sizeof(prefs.bw)); - fs_write(&file, &prefs.agc_reset_interval, sizeof(prefs.agc_reset_interval)); - fs_write(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); - fs_write(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); - fs_write(&file, pad, 1); - fs_write(&file, &prefs.flood_max, sizeof(prefs.flood_max)); - fs_write(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); - fs_write(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold)); - fs_write(&file, pad, 25); // skip bridge settings - fs_write(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); - fs_write(&file, pad, 3); - fs_write(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled)); - fs_write(&file, &prefs.gps_interval, sizeof(prefs.gps_interval)); - fs_write(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); - fs_write(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); - fs_write(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); - fs_write(&file, prefs.owner_info, sizeof(prefs.owner_info)); - /* ZephCore extensions */ - fs_write(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); - fs_write(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); - fs_write(&file, &prefs.apc_enabled, sizeof(prefs.apc_enabled)); - fs_write(&file, &prefs.apc_margin, sizeof(prefs.apc_margin)); - - ret = fs_sync(&file); - fs_close(&file); - if (ret < 0) { - LOG_ERR("savePrefs: sync failed: %d", ret); - fs_unlink(tmp_path); - return false; - } - - if (fs_rename(tmp_path, path) < 0) { - LOG_ERR("savePrefs: rename failed"); - fs_unlink(tmp_path); - return false; - } - LOG_INF("Saved prefs to %s", path); - return true; -} - -bool RepeaterDataStore::formatFileSystem() { - LOG_WRN("Factory reset: erasing repeater data at %s", BASE_PATH); - - struct fs_dir_t dir; - fs_dir_t_init(&dir); - - int ret = fs_opendir(&dir, BASE_PATH); - if (ret < 0) { - LOG_WRN("No repeater directory to erase"); - return true; - } - - struct fs_dirent entry; - char path[280]; - - while (fs_readdir(&dir, &entry) == 0 && entry.name[0] != '\0') { - snprintf(path, sizeof(path), "%s/%s", BASE_PATH, entry.name); - LOG_INF("Deleting %s", path); - fs_unlink(path); - } - fs_closedir(&dir); - - LOG_INF("Repeater data erased"); - return true; -} +/* + * SPDX-License-Identifier: Apache-2.0 + * RepeaterDataStore - Filesystem storage for repeater + */ + +#include "RepeaterDataStore.h" +#include +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_repeater_store, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); + +RepeaterDataStore::RepeaterDataStore() : _initialized(false) { +} + +bool RepeaterDataStore::begin() { + if (_initialized) return true; + + /* Create repeater directory if it doesn't exist */ + struct fs_dirent entry; + int ret = fs_stat(BASE_PATH, &entry); + if (ret < 0) { + ret = fs_mkdir(BASE_PATH); + if (ret < 0 && ret != -EEXIST) { + LOG_ERR("Failed to create %s: %d", BASE_PATH, ret); + return false; + } + LOG_INF("Created %s directory", BASE_PATH); + } + + _initialized = true; + LOG_INF("RepeaterDataStore initialized at %s", BASE_PATH); + return true; +} + +const char* RepeaterDataStore::getBasePath() const { return BASE_PATH; } + +const char* RepeaterDataStore::getAclPath() const { + static char buf[48]; + snprintf(buf, sizeof(buf), "%s/acl", BASE_PATH); + return buf; +} + +const char* RepeaterDataStore::getRegionsPath() const { + static char buf[48]; + snprintf(buf, sizeof(buf), "%s/regions2", BASE_PATH); + return buf; +} + +bool RepeaterDataStore::loadIdentity(mesh::LocalIdentity& id) { + char path[48]; + snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH); + + struct fs_file_t file; + fs_file_t_init(&file); + + int ret = fs_open(&file, path, FS_O_READ); + if (ret < 0) { + LOG_DBG("No identity file at %s", path); + return false; + } + + uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE]; + ssize_t n = fs_read(&file, buf, sizeof(buf)); + fs_close(&file); + + LOG_DBG("loadIdentity: read %d bytes from %s", (int)n, path); + + if (n >= PRV_KEY_SIZE) { + if (id.readFrom(buf, n)) { + LOG_INF("Loaded identity from %s", path); + return true; + } + LOG_ERR("loadIdentity: readFrom failed"); + } + + LOG_ERR("Identity file corrupt"); + return false; +} + +bool RepeaterDataStore::saveIdentity(const mesh::LocalIdentity& id) { + if (!_initialized) begin(); + + char path[48]; + char tmp_path[56]; + snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH); + if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) { + return false; + } + + fs_unlink(tmp_path); + + struct fs_file_t file; + fs_file_t_init(&file); + + int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); + if (ret < 0) { + LOG_ERR("Failed to open %s for write: %d", tmp_path, ret); + return false; + } + + uint8_t buf[PRV_KEY_SIZE]; + int len = id.writeTo(buf, sizeof(buf)); + ssize_t n = fs_write(&file, buf, len); + ret = fs_sync(&file); + fs_close(&file); + + if (n != len || ret < 0) { + LOG_ERR("Failed to write identity: wrote %d of %d sync=%d", (int)n, len, ret); + fs_unlink(tmp_path); + return false; + } + + if (fs_rename(tmp_path, path) < 0) { + LOG_ERR("saveIdentity: rename failed"); + fs_unlink(tmp_path); + return false; + } + LOG_INF("Saved identity to %s", path); + return true; +} + +bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { + char path[48]; + snprintf(path, sizeof(path), "%s/prefs", BASE_PATH); + + struct fs_file_t file; + fs_file_t_init(&file); + + int ret = fs_open(&file, path, FS_O_READ); + if (ret < 0) { + LOG_DBG("No prefs file at %s, using defaults", path); + initNodePrefs(&prefs); + strcpy(prefs.node_name, "Repeater"); + prefs.advert_loc_policy = ADVERT_LOC_PREFS; + prefs.flood_advert_interval = 25; + prefs.loop_detect = LOOP_DETECT_MINIMAL; + prefs.path_hash_mode = 1; +#if IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE) + prefs.rx_duty_cycle = 1; +#endif + /* Persist defaults so flash always has a prefs file from boot 1. + * Lets later code (e.g. tempradio revert) trust that flash is + * authoritative without a "first run" special case. */ + savePrefs(prefs); + return true; + } + + struct fs_dirent entry; + ret = fs_stat(path, &entry); + LOG_DBG("loadPrefs: file size = %d bytes", ret < 0 ? 0 : (int)entry.size); + + uint8_t pad[25]; + + /* Read prefs in same format as Arduino CommonCLI for compatibility */ + fs_read(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor)); + fs_read(&file, &prefs.node_name, sizeof(prefs.node_name)); + fs_read(&file, pad, 4); + fs_read(&file, &prefs.node_lat, sizeof(prefs.node_lat)); + fs_read(&file, &prefs.node_lon, sizeof(prefs.node_lon)); + fs_read(&file, &prefs.password, sizeof(prefs.password)); + fs_read(&file, &prefs.freq, sizeof(prefs.freq)); + fs_read(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); + fs_read(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd)); + fs_read(&file, &prefs.advert_interval, sizeof(prefs.advert_interval)); + fs_read(&file, pad, 1); + fs_read(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); + fs_read(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); + fs_read(&file, &prefs.guest_password, sizeof(prefs.guest_password)); + fs_read(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); + fs_read(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); + fs_read(&file, &prefs.sf, sizeof(prefs.sf)); + fs_read(&file, &prefs.cr, sizeof(prefs.cr)); + fs_read(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); + fs_read(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); + fs_read(&file, &prefs.bw, sizeof(prefs.bw)); + fs_read(&file, &prefs.agc_reset_interval, sizeof(prefs.agc_reset_interval)); + fs_read(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); + fs_read(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); + fs_read(&file, pad, 1); + fs_read(&file, &prefs.flood_max, sizeof(prefs.flood_max)); + fs_read(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); + fs_read(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold)); + fs_read(&file, pad, 25); // skip bridge settings + fs_read(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); + fs_read(&file, pad, 3); + fs_read(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled)); + fs_read(&file, &prefs.gps_interval, sizeof(prefs.gps_interval)); + fs_read(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); + fs_read(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); + fs_read(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); + fs_read(&file, prefs.owner_info, sizeof(prefs.owner_info)); + /* ZephCore extensions — absent in old 290-byte files; fs_read past EOF is a + * no-op so these fields keep the initNodePrefs() defaults the caller passed + * in (rx_boost=1, rx_duty_cycle=0, apc_enabled=0, apc_margin=16). The + * upgrade block below forces repeater-specific values for old files. */ + fs_read(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); + fs_read(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); + fs_read(&file, &prefs.apc_enabled, sizeof(prefs.apc_enabled)); + fs_read(&file, &prefs.apc_margin, sizeof(prefs.apc_margin)); + + fs_close(&file); + + /* Migrate uninitialized backoff_multiplier (0.0 or NaN) to default */ + if (prefs.backoff_multiplier == 0.0f || prefs.backoff_multiplier != prefs.backoff_multiplier) { + prefs.backoff_multiplier = 0.2f; + } + + LOG_INF("Loaded prefs from %s", path); + LOG_DBG(" name='%s' freq=%.3f sf=%u bw=%.1f tx_pwr=%d", + prefs.node_name, (double)prefs.freq, prefs.sf, (double)prefs.bw, prefs.tx_power_dbm); + + /* Validate radio params - use defaults if garbage */ + if (prefs.freq < 300.0f || prefs.freq > 1000.0f || + prefs.sf < 5 || prefs.sf > 12 || + prefs.bw < 7.0f || prefs.bw > 500.0f) { + LOG_WRN("Invalid radio params in prefs, using defaults: freq=%.3f sf=%u bw=%.1f", + (double)prefs.freq, prefs.sf, (double)prefs.bw); + prefs.freq = 869.618f; + prefs.bw = 62.5f; + prefs.sf = 8; + prefs.cr = 8; + prefs.tx_power_dbm = 22; + } + if (prefs.path_hash_mode > 2) prefs.path_hash_mode = 0; + if (prefs.loop_detect > LOOP_DETECT_STRICT) prefs.loop_detect = LOOP_DETECT_MINIMAL; + if (prefs.rx_boost > 1) prefs.rx_boost = 0; + if (prefs.rx_duty_cycle > 1) prefs.rx_duty_cycle = 0; + if (prefs.apc_enabled > 1) prefs.apc_enabled = 0; + if (prefs.apc_margin < 6 || prefs.apc_margin > 30) prefs.apc_margin = 16; + + /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore + * extension fields, and stored path_hash_mode/loop_detect as zero padding. + * Apply repeater defaults and re-save so values survive subsequent reboots. */ + if (ret >= 0 && entry.size < 294) { + prefs.rx_boost = 1; + prefs.path_hash_mode = 1; + prefs.loop_detect = LOOP_DETECT_MINIMAL; +#if IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE) + prefs.rx_duty_cycle = 1; +#endif + savePrefs(prefs); + LOG_INF("loadPrefs: upgraded prefs format (%d -> 294 bytes)", (int)entry.size); + } + return true; +} + +bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { + if (!_initialized) begin(); + + char path[48]; + char tmp_path[56]; + snprintf(path, sizeof(path), "%s/prefs", BASE_PATH); + if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) { + return false; + } + + fs_unlink(tmp_path); + + struct fs_file_t file; + fs_file_t_init(&file); + + int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); + if (ret < 0) { + LOG_ERR("Failed to open %s for write: %d", tmp_path, ret); + return false; + } + + uint8_t pad[25]; + memset(pad, 0, sizeof(pad)); + + /* Write prefs in same format as Arduino CommonCLI for compatibility */ + fs_write(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor)); + fs_write(&file, &prefs.node_name, sizeof(prefs.node_name)); + fs_write(&file, pad, 4); + fs_write(&file, &prefs.node_lat, sizeof(prefs.node_lat)); + fs_write(&file, &prefs.node_lon, sizeof(prefs.node_lon)); + fs_write(&file, &prefs.password, sizeof(prefs.password)); + fs_write(&file, &prefs.freq, sizeof(prefs.freq)); + fs_write(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); + fs_write(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd)); + fs_write(&file, &prefs.advert_interval, sizeof(prefs.advert_interval)); + fs_write(&file, pad, 1); + fs_write(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); + fs_write(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); + fs_write(&file, &prefs.guest_password, sizeof(prefs.guest_password)); + fs_write(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); + fs_write(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); + fs_write(&file, &prefs.sf, sizeof(prefs.sf)); + fs_write(&file, &prefs.cr, sizeof(prefs.cr)); + fs_write(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); + fs_write(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); + fs_write(&file, &prefs.bw, sizeof(prefs.bw)); + fs_write(&file, &prefs.agc_reset_interval, sizeof(prefs.agc_reset_interval)); + fs_write(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); + fs_write(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); + fs_write(&file, pad, 1); + fs_write(&file, &prefs.flood_max, sizeof(prefs.flood_max)); + fs_write(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); + fs_write(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold)); + fs_write(&file, pad, 25); // skip bridge settings + fs_write(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); + fs_write(&file, pad, 3); + fs_write(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled)); + fs_write(&file, &prefs.gps_interval, sizeof(prefs.gps_interval)); + fs_write(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); + fs_write(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); + fs_write(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); + fs_write(&file, prefs.owner_info, sizeof(prefs.owner_info)); + /* ZephCore extensions */ + fs_write(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); + fs_write(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); + fs_write(&file, &prefs.apc_enabled, sizeof(prefs.apc_enabled)); + fs_write(&file, &prefs.apc_margin, sizeof(prefs.apc_margin)); + + ret = fs_sync(&file); + fs_close(&file); + if (ret < 0) { + LOG_ERR("savePrefs: sync failed: %d", ret); + fs_unlink(tmp_path); + return false; + } + + if (fs_rename(tmp_path, path) < 0) { + LOG_ERR("savePrefs: rename failed"); + fs_unlink(tmp_path); + return false; + } + LOG_INF("Saved prefs to %s", path); + return true; +} + +bool RepeaterDataStore::formatFileSystem() { + LOG_WRN("Factory reset: erasing repeater data at %s", BASE_PATH); + + struct fs_dir_t dir; + fs_dir_t_init(&dir); + + int ret = fs_opendir(&dir, BASE_PATH); + if (ret < 0) { + LOG_WRN("No repeater directory to erase"); + return true; + } + + struct fs_dirent entry; + char path[280]; + + while (fs_readdir(&dir, &entry) == 0 && entry.name[0] != '\0') { + snprintf(path, sizeof(path), "%s/%s", BASE_PATH, entry.name); + LOG_INF("Deleting %s", path); + fs_unlink(path); + } + fs_closedir(&dir); + + LOG_INF("Repeater data erased"); + return true; +} diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 4dfda3c..c788ff4 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -1,127 +1,127 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * NodePrefs - persisted node configuration (unified for all roles) - * - * Serialized field-by-field, not raw memcpy; struct layout does - * not affect on-disk compatibility. - */ - -#pragma once - -#include -#include - -#define TELEM_MODE_DENY 0 -#define TELEM_MODE_ALLOW_FLAGS 1 -#define TELEM_MODE_ALLOW_ALL 2 - -#define ADVERT_LOC_NONE 0 -#define ADVERT_LOC_SHARE 1 -#define ADVERT_LOC_PREFS 2 - -#define LOOP_DETECT_OFF 0 -#define LOOP_DETECT_MINIMAL 1 -#define LOOP_DETECT_MODERATE 2 -#define LOOP_DETECT_STRICT 3 - -struct NodePrefs { - /* ---- Common fields (both roles) ---- */ - float airtime_factor; - char node_name[32]; - double node_lat, node_lon; - char password[16]; - float freq; - int8_t tx_power_dbm; - uint8_t disable_fwd; // repeater: disable forwarding - uint8_t advert_interval; // stored as minutes / 2 - uint8_t flood_advert_interval; // hours - float rx_delay_base; - float tx_delay_factor; - char guest_password[16]; - float direct_tx_delay_factor; - float backoff_multiplier; // per-dupe reactive backoff (0.0 = disabled) - uint32_t guard; - uint8_t sf; - uint8_t cr; - uint8_t allow_read_only; - uint8_t multi_acks; - float bw; - uint8_t flood_max; - uint8_t interference_threshold; - uint8_t agc_reset_interval; // stored as secs / 4 - // Power saving - uint8_t powersaving_enabled; - // GPS settings - uint8_t gps_enabled; - uint32_t gps_interval; // in seconds - uint8_t advert_loc_policy; - uint32_t discovery_mod_timestamp; - float adc_multiplier; - char owner_info[120]; - uint8_t rx_boost; // 1 = boosted RX gain (+3dB), 0 = power save - uint8_t rx_duty_cycle; // 1 = RX duty cycle, 0 = continuous RX - uint8_t apc_enabled; // 1 = APC on, 0 = fixed TX power - uint8_t apc_margin; // APC target link margin dB (6-30) - - /* ---- Companion-only fields ---- */ - uint8_t manual_add_contacts; - uint8_t telemetry_mode_base; - uint8_t telemetry_mode_loc; - uint8_t telemetry_mode_env; - uint32_t ble_pin; - uint8_t buzzer_quiet; - uint8_t autoadd_config; - uint8_t client_repeat; // 1 = offgrid mode (forward packets) - uint8_t path_hash_mode; // path mode 0-2 - uint8_t autoadd_max_hops; // 0 = no limit, N = up to N-1 hops - uint8_t loop_detect; // LOOP_DETECT_{OFF,MINIMAL,MODERATE,STRICT} - uint8_t leds_disabled; // 1 = LEDs off - char default_scope_name[31]; // companion: default flood scope region name ("" = null) - uint8_t default_scope_key[16]; // companion: default flood scope TransportKey -}; - -/* Default prefs -- must match LoRaConfig.h defaults for radio interop. */ -static inline void initNodePrefs(NodePrefs* prefs) { - memset(prefs, 0, sizeof(NodePrefs)); - prefs->airtime_factor = 9.0f; /* Arduino formula: duty% = 100 / (af + 1) → 10% */ - prefs->node_lat = 0.0; - prefs->node_lon = 0.0; -#ifdef CONFIG_ZEPHCORE_ADMIN_PASSWORD - strncpy(prefs->password, CONFIG_ZEPHCORE_ADMIN_PASSWORD, sizeof(prefs->password) - 1); -#else - strcpy(prefs->password, "password"); -#endif -#ifdef CONFIG_ZEPHCORE_GUEST_PASSWORD - strncpy(prefs->guest_password, CONFIG_ZEPHCORE_GUEST_PASSWORD, sizeof(prefs->guest_password) - 1); -#endif - /* Radio params - MUST match LoRaConfig.h for interop with companion nodes */ - prefs->freq = 869.618f; // LoRaConfig::FREQ_HZ / 1000000.0 - prefs->bw = 62.5f; // LoRaConfig::BANDWIDTH - prefs->sf = 8; // LoRaConfig::SPREADING_FACTOR - prefs->cr = 8; // CR 4/8 (MeshCore uses 5-8 for CR 4/5 through 4/8) -#ifdef CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM - prefs->tx_power_dbm = CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM; -#else - prefs->tx_power_dbm = 22; // LoRaConfig::TX_POWER_DBM -#endif - prefs->disable_fwd = 0; - prefs->advert_interval = 60; // 2 minutes (value / 2) - prefs->flood_advert_interval = 12; // 12 hours - prefs->rx_delay_base = 0.0f; - prefs->tx_delay_factor = 0.5f; - prefs->direct_tx_delay_factor = 0.3f; - prefs->allow_read_only = 0; - prefs->multi_acks = 0; - prefs->flood_max = 64; // max hops for flood packets (0 = blocking all!) - prefs->interference_threshold = 0; - prefs->agc_reset_interval = 0; - prefs->powersaving_enabled = 0; - prefs->gps_enabled = 0; - prefs->gps_interval = 300; // 5 minutes - prefs->advert_loc_policy = ADVERT_LOC_NONE; - prefs->adc_multiplier = 0.0f; - prefs->rx_boost = 1; // Default to boosted RX for better sensitivity - prefs->rx_duty_cycle = 0; // Default OFF — continuous RX for best reliability - prefs->apc_enabled = 0; // Default OFF — fixed TX power - prefs->apc_margin = 16; // Default 16 dB target link margin -} +/* + * SPDX-License-Identifier: Apache-2.0 + * NodePrefs - persisted node configuration (unified for all roles) + * + * Serialized field-by-field, not raw memcpy; struct layout does + * not affect on-disk compatibility. + */ + +#pragma once + +#include +#include + +#define TELEM_MODE_DENY 0 +#define TELEM_MODE_ALLOW_FLAGS 1 +#define TELEM_MODE_ALLOW_ALL 2 + +#define ADVERT_LOC_NONE 0 +#define ADVERT_LOC_SHARE 1 +#define ADVERT_LOC_PREFS 2 + +#define LOOP_DETECT_OFF 0 +#define LOOP_DETECT_MINIMAL 1 +#define LOOP_DETECT_MODERATE 2 +#define LOOP_DETECT_STRICT 3 + +struct NodePrefs { + /* ---- Common fields (both roles) ---- */ + float airtime_factor; + char node_name[32]; + double node_lat, node_lon; + char password[16]; + float freq; + int8_t tx_power_dbm; + uint8_t disable_fwd; // repeater: disable forwarding + uint8_t advert_interval; // stored as minutes / 2 + uint8_t flood_advert_interval; // hours + float rx_delay_base; + float tx_delay_factor; + char guest_password[16]; + float direct_tx_delay_factor; + float backoff_multiplier; // per-dupe reactive backoff (0.0 = disabled) + uint32_t guard; + uint8_t sf; + uint8_t cr; + uint8_t allow_read_only; + uint8_t multi_acks; + float bw; + uint8_t flood_max; + uint8_t interference_threshold; + uint8_t agc_reset_interval; // stored as secs / 4 + // Power saving + uint8_t powersaving_enabled; + // GPS settings + uint8_t gps_enabled; + uint32_t gps_interval; // in seconds + uint8_t advert_loc_policy; + uint32_t discovery_mod_timestamp; + float adc_multiplier; + char owner_info[120]; + uint8_t rx_boost; // 1 = boosted RX gain (+3dB), 0 = power save + uint8_t rx_duty_cycle; // 1 = RX duty cycle, 0 = continuous RX + uint8_t apc_enabled; // 1 = APC on, 0 = fixed TX power + uint8_t apc_margin; // APC target link margin dB (6-30) + + /* ---- Companion-only fields ---- */ + uint8_t manual_add_contacts; + uint8_t telemetry_mode_base; + uint8_t telemetry_mode_loc; + uint8_t telemetry_mode_env; + uint32_t ble_pin; + uint8_t buzzer_quiet; + uint8_t autoadd_config; + uint8_t client_repeat; // 1 = offgrid mode (forward packets) + uint8_t path_hash_mode; // path mode 0-2 + uint8_t autoadd_max_hops; // 0 = no limit, N = up to N-1 hops + uint8_t loop_detect; // LOOP_DETECT_{OFF,MINIMAL,MODERATE,STRICT} + uint8_t leds_disabled; // 1 = LEDs off + char default_scope_name[31]; // companion: default flood scope region name ("" = null) + uint8_t default_scope_key[16]; // companion: default flood scope TransportKey +}; + +/* Default prefs -- must match LoRaConfig.h defaults for radio interop. */ +static inline void initNodePrefs(NodePrefs* prefs) { + memset(prefs, 0, sizeof(NodePrefs)); + prefs->airtime_factor = 9.0f; /* Arduino formula: duty% = 100 / (af + 1) → 10% */ + prefs->node_lat = 0.0; + prefs->node_lon = 0.0; +#ifdef CONFIG_ZEPHCORE_ADMIN_PASSWORD + strncpy(prefs->password, CONFIG_ZEPHCORE_ADMIN_PASSWORD, sizeof(prefs->password) - 1); +#else + strcpy(prefs->password, "password"); +#endif +#ifdef CONFIG_ZEPHCORE_GUEST_PASSWORD + strncpy(prefs->guest_password, CONFIG_ZEPHCORE_GUEST_PASSWORD, sizeof(prefs->guest_password) - 1); +#endif + /* Radio params - MUST match LoRaConfig.h for interop with companion nodes */ + prefs->freq = 869.618f; // LoRaConfig::FREQ_HZ / 1000000.0 + prefs->bw = 62.5f; // LoRaConfig::BANDWIDTH + prefs->sf = 8; // LoRaConfig::SPREADING_FACTOR + prefs->cr = 8; // CR 4/8 (MeshCore uses 5-8 for CR 4/5 through 4/8) +#ifdef CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM + prefs->tx_power_dbm = CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM; +#else + prefs->tx_power_dbm = 22; // LoRaConfig::TX_POWER_DBM +#endif + prefs->disable_fwd = 0; + prefs->advert_interval = 60; // 2 minutes (value / 2) + prefs->flood_advert_interval = 12; // 12 hours + prefs->rx_delay_base = 0.0f; + prefs->tx_delay_factor = 0.5f; + prefs->direct_tx_delay_factor = 0.3f; + prefs->allow_read_only = 0; + prefs->multi_acks = 0; + prefs->flood_max = 64; // max hops for flood packets (0 = blocking all!) + prefs->interference_threshold = 0; + prefs->agc_reset_interval = 0; + prefs->powersaving_enabled = 0; + prefs->gps_enabled = 0; + prefs->gps_interval = 300; // 5 minutes + prefs->advert_loc_policy = ADVERT_LOC_NONE; + prefs->adc_multiplier = 0.0f; + prefs->rx_boost = 1; // Default to boosted RX for better sensitivity + prefs->rx_duty_cycle = 0; // Default OFF — continuous RX for best reliability + prefs->apc_enabled = 0; // Default OFF — fixed TX power + prefs->apc_margin = 16; // Default 16 dB target link margin +} diff --git a/zephcore/helpers/RegionMap.cpp b/zephcore/helpers/RegionMap.cpp index 0a6e194..274c54d 100644 --- a/zephcore/helpers/RegionMap.cpp +++ b/zephcore/helpers/RegionMap.cpp @@ -1,326 +1,326 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * RegionMap - Region-based flood filtering for repeaters - */ - -#include "RegionMap.h" -#include -#include -#include -#include -#include - -LOG_MODULE_REGISTER(zephcore_regions, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); - -static const char* skip_hash(const char* name) { - return *name == '#' ? name + 1 : name; -} - -RegionMap::RegionMap(TransportKeyStore& store) : _store(&store) { - next_id = 1; - num_regions = 0; - default_id = home_id = 0; - wildcard.id = 0; - wildcard.parent = 0; - wildcard.flags = 0; // default behaviour, allow flood and direct - strcpy(wildcard.name, "*"); -} - -bool RegionMap::is_name_char(uint8_t c) { - // accept all alpha-num or accented characters, but exclude most punctuation chars - return c == '-' || c == '$' || c == '#' || (c >= '0' && c <= '9') || c >= 'A'; -} - -bool RegionMap::load(const char* path) { - const char* filepath = path; - - struct fs_file_t file; - fs_file_t_init(&file); - - if (fs_open(&file, filepath, FS_O_READ) < 0) { - LOG_DBG("No regions file at %s", filepath); - return false; - } - - uint8_t pad[128]; - num_regions = 0; - next_id = 1; - default_id = home_id = 0; - - bool success = fs_read(&file, pad, 3) == 3; // reserved header - success = success && fs_read(&file, &default_id, sizeof(default_id)) == sizeof(default_id); - success = success && fs_read(&file, &home_id, sizeof(home_id)) == sizeof(home_id); - success = success && fs_read(&file, &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); - success = success && fs_read(&file, &next_id, sizeof(next_id)) == sizeof(next_id); - - if (success) { - while (num_regions < MAX_REGION_ENTRIES) { - auto r = ®ions[num_regions]; - - success = fs_read(&file, &r->id, sizeof(r->id)) == sizeof(r->id); - success = success && fs_read(&file, &r->parent, sizeof(r->parent)) == sizeof(r->parent); - success = success && fs_read(&file, r->name, sizeof(r->name)) == sizeof(r->name); - success = success && fs_read(&file, &r->flags, sizeof(r->flags)) == sizeof(r->flags); - success = success && fs_read(&file, pad, sizeof(pad)) == sizeof(pad); - - if (!success) break; // EOF - - if (r->id >= next_id) { // make sure next_id is valid - next_id = r->id + 1; - } - num_regions++; - } - } - fs_close(&file); - LOG_INF("Loaded %d regions from %s", num_regions, filepath); - return true; -} - -bool RegionMap::save(const char* path) { - const char* filepath = path; - - // Remove old file first - fs_unlink(filepath); - - struct fs_file_t file; - fs_file_t_init(&file); - - if (fs_open(&file, filepath, FS_O_CREATE | FS_O_WRITE) < 0) { - LOG_ERR("Failed to open %s for write", filepath); - return false; - } - - uint8_t pad[128]; - memset(pad, 0, sizeof(pad)); - - bool success = fs_write(&file, pad, 3) == 3; // reserved header - success = success && fs_write(&file, &default_id, sizeof(default_id)) == sizeof(default_id); - success = success && fs_write(&file, &home_id, sizeof(home_id)) == sizeof(home_id); - success = success && fs_write(&file, &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); - success = success && fs_write(&file, &next_id, sizeof(next_id)) == sizeof(next_id); - - if (success) { - for (int i = 0; i < num_regions; i++) { - auto r = ®ions[i]; - - success = fs_write(&file, &r->id, sizeof(r->id)) == sizeof(r->id); - success = success && fs_write(&file, &r->parent, sizeof(r->parent)) == sizeof(r->parent); - success = success && fs_write(&file, r->name, sizeof(r->name)) == sizeof(r->name); - success = success && fs_write(&file, &r->flags, sizeof(r->flags)) == sizeof(r->flags); - success = success && fs_write(&file, pad, sizeof(pad)) == sizeof(pad); - - if (!success) break; // write failed - } - } - fs_close(&file); - LOG_INF("Saved %d regions to %s", num_regions, filepath); - return true; -} - -RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t id) { - const char* sp = name; // check for illegal name chars - while (*sp) { - if (!is_name_char(*sp)) return nullptr; // error - sp++; - } - - auto region = findByName(name); - if (region) { - if (region->id == parent_id) return nullptr; // ERROR: invalid parent! - region->parent = parent_id; // re-parent / move this region in the hierarchy - } else { - if (id == 0 && num_regions >= MAX_REGION_ENTRIES) return nullptr; // full! - - region = ®ions[num_regions++]; // alloc new RegionEntry - region->flags = REGION_DENY_FLOOD; // DENY by default - region->id = id == 0 ? next_id++ : id; - StrHelper::strncpy(region->name, name, sizeof(region->name)); - region->parent = parent_id; - } - return region; -} - -int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num) { - int num; - if (src.name[0] == '$') { // private region - num = _store->loadKeysFor(src.id, dest, max_num); - } else if (src.name[0] == '#') { // auto hashtag region - _store->getAutoKeyFor(src.id, src.name, dest[0]); - num = 1; - } else { // new: implicit auto hashtag region - char tmp[sizeof(src.name) + 1]; - tmp[0] = '#'; - memcpy(&tmp[1], src.name, sizeof(src.name) - 1); - tmp[sizeof(src.name)] = '\0'; - _store->getAutoKeyFor(src.id, tmp, dest[0]); - num = 1; - } - return num; -} - -RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) - TransportKey keys[4]; - int num = getTransportKeysFor(*region, keys, 4); - for (int j = 0; j < num; j++) { - uint16_t code = keys[j].calcTransportCode(packet); - if (packet->transport_codes[0] == code) { // a match!! - return region; - } - } - } - } - return nullptr; // no matches -} - -RegionEntry* RegionMap::findByName(const char* name) { - if (strcmp(name, "*") == 0) return &wildcard; - - if (*name == '#') { name++; } // ignore the '#' when matching by name - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if (strcmp(name, skip_hash(region->name)) == 0) return region; - } - return nullptr; // not found -} - -RegionEntry* RegionMap::findByNamePrefix(const char* prefix) { - if (strcmp(prefix, "*") == 0) return &wildcard; - - if (*prefix == '#') { prefix++; } // ignore the '#' when matching by name - RegionEntry* partial = nullptr; - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if (strcmp(prefix, skip_hash(region->name)) == 0) return region; // complete match - if (memcmp(prefix, skip_hash(region->name), strlen(prefix)) == 0) { - partial = region; - } - } - return partial; -} - -RegionEntry* RegionMap::findById(uint16_t id) { - if (id == 0) return &wildcard; // special root Region - - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if (region->id == id) return region; - } - return nullptr; // not found -} - -RegionEntry* RegionMap::getHomeRegion() { - return findById(home_id); -} - -void RegionMap::setHomeRegion(const RegionEntry* home) { - home_id = home ? home->id : 0; -} - -RegionEntry* RegionMap::getDefaultRegion() { - return default_id == 0 ? nullptr : findById(default_id); -} - -void RegionMap::setDefaultRegion(const RegionEntry* def) { - default_id = def ? def->id : 0; -} - -bool RegionMap::removeRegion(const RegionEntry& region) { - if (region.id == 0) return false; // cannot remove wildcard - - // first check region has no child regions - for (int i = 0; i < num_regions; i++) { - if (regions[i].parent == region.id) return false; // must remove children first - } - - int i = 0; - while (i < num_regions) { - if (region.id == regions[i].id) break; - i++; - } - if (i >= num_regions) return false; // not found - - num_regions--; // remove from regions array - while (i < num_regions) { - regions[i] = regions[i + 1]; - i++; - } - return true; -} - -bool RegionMap::clear() { - num_regions = 0; - return true; -} - -void RegionMap::printChildRegions(int indent, const RegionEntry* parent, char* buf, int& pos, int max_len) const { - // Print indentation - for (int i = 0; i < indent && pos < max_len - 1; i++) { - buf[pos++] = ' '; - } - - // Print region info - int written; - if (parent->flags & REGION_DENY_FLOOD) { - written = snprintf(&buf[pos], max_len - pos, "%s%s\n", - skip_hash(parent->name), - parent->id == home_id ? "^" : ""); - } else { - written = snprintf(&buf[pos], max_len - pos, "%s%s F\n", - skip_hash(parent->name), - parent->id == home_id ? "^" : ""); - } - if (written > 0 && pos + written < max_len) { - pos += written; - } - - // Print children recursively - for (int i = 0; i < num_regions; i++) { - auto r = ®ions[i]; - if (r->parent == parent->id) { - printChildRegions(indent + 1, r, buf, pos, max_len); - } - } -} - -size_t RegionMap::exportTo(char* dest, size_t max_len) const { - if (!dest || max_len == 0) return 0; - - int pos = 0; - printChildRegions(0, &wildcard, dest, pos, (int)max_len); - return (size_t)pos; -} - -int RegionMap::exportNamesTo(char* dest, int max_len, uint8_t mask, bool invert) { - char* dp = dest; - - // Check wildcard region - bool wildcard_matches = invert ? (wildcard.flags & mask) : !(wildcard.flags & mask); - if (wildcard_matches) { - *dp++ = '*'; - *dp++ = ','; - } - - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - - // Check if region matches the filter criteria - bool region_matches = invert ? (region->flags & mask) : !(region->flags & mask); - - if (region_matches) { - int len = strlen(skip_hash(region->name)); - if ((dp - dest) + len + 2 < max_len) { // only append if name will fit - memcpy(dp, skip_hash(region->name), len); - dp += len; - *dp++ = ','; - } - } - } - - if (dp > dest) { dp--; } // don't include trailing comma - - *dp = 0; // set null terminator - return dp - dest; -} +/* + * SPDX-License-Identifier: Apache-2.0 + * RegionMap - Region-based flood filtering for repeaters + */ + +#include "RegionMap.h" +#include +#include +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_regions, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); + +static const char* skip_hash(const char* name) { + return *name == '#' ? name + 1 : name; +} + +RegionMap::RegionMap(TransportKeyStore& store) : _store(&store) { + next_id = 1; + num_regions = 0; + default_id = home_id = 0; + wildcard.id = 0; + wildcard.parent = 0; + wildcard.flags = 0; // default behaviour, allow flood and direct + strcpy(wildcard.name, "*"); +} + +bool RegionMap::is_name_char(uint8_t c) { + // accept all alpha-num or accented characters, but exclude most punctuation chars + return c == '-' || c == '$' || c == '#' || (c >= '0' && c <= '9') || c >= 'A'; +} + +bool RegionMap::load(const char* path) { + const char* filepath = path; + + struct fs_file_t file; + fs_file_t_init(&file); + + if (fs_open(&file, filepath, FS_O_READ) < 0) { + LOG_DBG("No regions file at %s", filepath); + return false; + } + + uint8_t pad[128]; + num_regions = 0; + next_id = 1; + default_id = home_id = 0; + + bool success = fs_read(&file, pad, 3) == 3; // reserved header + success = success && fs_read(&file, &default_id, sizeof(default_id)) == sizeof(default_id); + success = success && fs_read(&file, &home_id, sizeof(home_id)) == sizeof(home_id); + success = success && fs_read(&file, &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); + success = success && fs_read(&file, &next_id, sizeof(next_id)) == sizeof(next_id); + + if (success) { + while (num_regions < MAX_REGION_ENTRIES) { + auto r = ®ions[num_regions]; + + success = fs_read(&file, &r->id, sizeof(r->id)) == sizeof(r->id); + success = success && fs_read(&file, &r->parent, sizeof(r->parent)) == sizeof(r->parent); + success = success && fs_read(&file, r->name, sizeof(r->name)) == sizeof(r->name); + success = success && fs_read(&file, &r->flags, sizeof(r->flags)) == sizeof(r->flags); + success = success && fs_read(&file, pad, sizeof(pad)) == sizeof(pad); + + if (!success) break; // EOF + + if (r->id >= next_id) { // make sure next_id is valid + next_id = r->id + 1; + } + num_regions++; + } + } + fs_close(&file); + LOG_INF("Loaded %d regions from %s", num_regions, filepath); + return true; +} + +bool RegionMap::save(const char* path) { + const char* filepath = path; + + // Remove old file first + fs_unlink(filepath); + + struct fs_file_t file; + fs_file_t_init(&file); + + if (fs_open(&file, filepath, FS_O_CREATE | FS_O_WRITE) < 0) { + LOG_ERR("Failed to open %s for write", filepath); + return false; + } + + uint8_t pad[128]; + memset(pad, 0, sizeof(pad)); + + bool success = fs_write(&file, pad, 3) == 3; // reserved header + success = success && fs_write(&file, &default_id, sizeof(default_id)) == sizeof(default_id); + success = success && fs_write(&file, &home_id, sizeof(home_id)) == sizeof(home_id); + success = success && fs_write(&file, &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); + success = success && fs_write(&file, &next_id, sizeof(next_id)) == sizeof(next_id); + + if (success) { + for (int i = 0; i < num_regions; i++) { + auto r = ®ions[i]; + + success = fs_write(&file, &r->id, sizeof(r->id)) == sizeof(r->id); + success = success && fs_write(&file, &r->parent, sizeof(r->parent)) == sizeof(r->parent); + success = success && fs_write(&file, r->name, sizeof(r->name)) == sizeof(r->name); + success = success && fs_write(&file, &r->flags, sizeof(r->flags)) == sizeof(r->flags); + success = success && fs_write(&file, pad, sizeof(pad)) == sizeof(pad); + + if (!success) break; // write failed + } + } + fs_close(&file); + LOG_INF("Saved %d regions to %s", num_regions, filepath); + return true; +} + +RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t id) { + const char* sp = name; // check for illegal name chars + while (*sp) { + if (!is_name_char(*sp)) return nullptr; // error + sp++; + } + + auto region = findByName(name); + if (region) { + if (region->id == parent_id) return nullptr; // ERROR: invalid parent! + region->parent = parent_id; // re-parent / move this region in the hierarchy + } else { + if (id == 0 && num_regions >= MAX_REGION_ENTRIES) return nullptr; // full! + + region = ®ions[num_regions++]; // alloc new RegionEntry + region->flags = REGION_DENY_FLOOD; // DENY by default + region->id = id == 0 ? next_id++ : id; + StrHelper::strncpy(region->name, name, sizeof(region->name)); + region->parent = parent_id; + } + return region; +} + +int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num) { + int num; + if (src.name[0] == '$') { // private region + num = _store->loadKeysFor(src.id, dest, max_num); + } else if (src.name[0] == '#') { // auto hashtag region + _store->getAutoKeyFor(src.id, src.name, dest[0]); + num = 1; + } else { // new: implicit auto hashtag region + char tmp[sizeof(src.name) + 1]; + tmp[0] = '#'; + memcpy(&tmp[1], src.name, sizeof(src.name) - 1); + tmp[sizeof(src.name)] = '\0'; + _store->getAutoKeyFor(src.id, tmp, dest[0]); + num = 1; + } + return num; +} + +RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) + TransportKey keys[4]; + int num = getTransportKeysFor(*region, keys, 4); + for (int j = 0; j < num; j++) { + uint16_t code = keys[j].calcTransportCode(packet); + if (packet->transport_codes[0] == code) { // a match!! + return region; + } + } + } + } + return nullptr; // no matches +} + +RegionEntry* RegionMap::findByName(const char* name) { + if (strcmp(name, "*") == 0) return &wildcard; + + if (*name == '#') { name++; } // ignore the '#' when matching by name + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if (strcmp(name, skip_hash(region->name)) == 0) return region; + } + return nullptr; // not found +} + +RegionEntry* RegionMap::findByNamePrefix(const char* prefix) { + if (strcmp(prefix, "*") == 0) return &wildcard; + + if (*prefix == '#') { prefix++; } // ignore the '#' when matching by name + RegionEntry* partial = nullptr; + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if (strcmp(prefix, skip_hash(region->name)) == 0) return region; // complete match + if (memcmp(prefix, skip_hash(region->name), strlen(prefix)) == 0) { + partial = region; + } + } + return partial; +} + +RegionEntry* RegionMap::findById(uint16_t id) { + if (id == 0) return &wildcard; // special root Region + + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if (region->id == id) return region; + } + return nullptr; // not found +} + +RegionEntry* RegionMap::getHomeRegion() { + return findById(home_id); +} + +void RegionMap::setHomeRegion(const RegionEntry* home) { + home_id = home ? home->id : 0; +} + +RegionEntry* RegionMap::getDefaultRegion() { + return default_id == 0 ? nullptr : findById(default_id); +} + +void RegionMap::setDefaultRegion(const RegionEntry* def) { + default_id = def ? def->id : 0; +} + +bool RegionMap::removeRegion(const RegionEntry& region) { + if (region.id == 0) return false; // cannot remove wildcard + + // first check region has no child regions + for (int i = 0; i < num_regions; i++) { + if (regions[i].parent == region.id) return false; // must remove children first + } + + int i = 0; + while (i < num_regions) { + if (region.id == regions[i].id) break; + i++; + } + if (i >= num_regions) return false; // not found + + num_regions--; // remove from regions array + while (i < num_regions) { + regions[i] = regions[i + 1]; + i++; + } + return true; +} + +bool RegionMap::clear() { + num_regions = 0; + return true; +} + +void RegionMap::printChildRegions(int indent, const RegionEntry* parent, char* buf, int& pos, int max_len) const { + // Print indentation + for (int i = 0; i < indent && pos < max_len - 1; i++) { + buf[pos++] = ' '; + } + + // Print region info + int written; + if (parent->flags & REGION_DENY_FLOOD) { + written = snprintf(&buf[pos], max_len - pos, "%s%s\n", + skip_hash(parent->name), + parent->id == home_id ? "^" : ""); + } else { + written = snprintf(&buf[pos], max_len - pos, "%s%s F\n", + skip_hash(parent->name), + parent->id == home_id ? "^" : ""); + } + if (written > 0 && pos + written < max_len) { + pos += written; + } + + // Print children recursively + for (int i = 0; i < num_regions; i++) { + auto r = ®ions[i]; + if (r->parent == parent->id) { + printChildRegions(indent + 1, r, buf, pos, max_len); + } + } +} + +size_t RegionMap::exportTo(char* dest, size_t max_len) const { + if (!dest || max_len == 0) return 0; + + int pos = 0; + printChildRegions(0, &wildcard, dest, pos, (int)max_len); + return (size_t)pos; +} + +int RegionMap::exportNamesTo(char* dest, int max_len, uint8_t mask, bool invert) { + char* dp = dest; + + // Check wildcard region + bool wildcard_matches = invert ? (wildcard.flags & mask) : !(wildcard.flags & mask); + if (wildcard_matches) { + *dp++ = '*'; + *dp++ = ','; + } + + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + + // Check if region matches the filter criteria + bool region_matches = invert ? (region->flags & mask) : !(region->flags & mask); + + if (region_matches) { + int len = strlen(skip_hash(region->name)); + if ((dp - dest) + len + 2 < max_len) { // only append if name will fit + memcpy(dp, skip_hash(region->name), len); + dp += len; + *dp++ = ','; + } + } + } + + if (dp > dest) { dp--; } // don't include trailing comma + + *dp = 0; // set null terminator + return dp - dest; +} diff --git a/zephcore/helpers/RegionMap.h b/zephcore/helpers/RegionMap.h index c9ac0d0..953fe5b 100644 --- a/zephcore/helpers/RegionMap.h +++ b/zephcore/helpers/RegionMap.h @@ -1,72 +1,72 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * RegionMap - Region-based flood filtering for repeaters - */ - -#pragma once - -#include -#include "TransportKeyStore.h" -#include -#include - -#ifndef MAX_REGION_ENTRIES - #ifdef CONFIG_ZEPHCORE_MAX_REGION_ENTRIES - #define MAX_REGION_ENTRIES CONFIG_ZEPHCORE_MAX_REGION_ENTRIES - #else - #define MAX_REGION_ENTRIES 32 - #endif -#endif - -#define REGION_DENY_FLOOD 0x01 -#define REGION_DENY_DIRECT 0x02 // reserved for future - -struct RegionEntry { - uint16_t id; - uint16_t parent; - uint8_t flags; - char name[31]; - - bool isWildcard() const { return id == 0; } -}; - -class RegionMap { - TransportKeyStore* _store; - uint16_t next_id; - uint16_t home_id; - uint16_t default_id; - uint16_t num_regions; - RegionEntry regions[MAX_REGION_ENTRIES]; - RegionEntry wildcard; - - void printChildRegions(int indent, const RegionEntry* parent, char* buf, int& pos, int max_len) const; - -public: - RegionMap(TransportKeyStore& store); - - static bool is_name_char(uint8_t c); - - bool load(const char* path = nullptr); - bool save(const char* path = nullptr); - - RegionEntry* putRegion(const char* name, uint16_t parent_id, uint16_t id = 0); - RegionEntry* findMatch(mesh::Packet* packet, uint8_t mask); - RegionEntry& getWildcard() { return wildcard; } - RegionEntry* findByName(const char* name); - RegionEntry* findByNamePrefix(const char* prefix); - RegionEntry* findById(uint16_t id); - RegionEntry* getHomeRegion(); // NOTE: can be NULL - void setHomeRegion(const RegionEntry* home); - RegionEntry* getDefaultRegion(); // NOTE: can be NULL - void setDefaultRegion(const RegionEntry* def); - bool removeRegion(const RegionEntry& region); - bool clear(); - void resetFrom(const RegionMap& src) { num_regions = 0; next_id = src.next_id; } - int getCount() const { return num_regions; } - const RegionEntry* getByIdx(int i) const { return ®ions[i]; } - const RegionEntry* getRoot() const { return &wildcard; } - int exportNamesTo(char* dest, int max_len, uint8_t mask, bool invert = false); - int getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num); - - size_t exportTo(char* dest, size_t max_len) const; -}; +/* + * SPDX-License-Identifier: Apache-2.0 + * RegionMap - Region-based flood filtering for repeaters + */ + +#pragma once + +#include +#include "TransportKeyStore.h" +#include +#include + +#ifndef MAX_REGION_ENTRIES + #ifdef CONFIG_ZEPHCORE_MAX_REGION_ENTRIES + #define MAX_REGION_ENTRIES CONFIG_ZEPHCORE_MAX_REGION_ENTRIES + #else + #define MAX_REGION_ENTRIES 32 + #endif +#endif + +#define REGION_DENY_FLOOD 0x01 +#define REGION_DENY_DIRECT 0x02 // reserved for future + +struct RegionEntry { + uint16_t id; + uint16_t parent; + uint8_t flags; + char name[31]; + + bool isWildcard() const { return id == 0; } +}; + +class RegionMap { + TransportKeyStore* _store; + uint16_t next_id; + uint16_t home_id; + uint16_t default_id; + uint16_t num_regions; + RegionEntry regions[MAX_REGION_ENTRIES]; + RegionEntry wildcard; + + void printChildRegions(int indent, const RegionEntry* parent, char* buf, int& pos, int max_len) const; + +public: + RegionMap(TransportKeyStore& store); + + static bool is_name_char(uint8_t c); + + bool load(const char* path = nullptr); + bool save(const char* path = nullptr); + + RegionEntry* putRegion(const char* name, uint16_t parent_id, uint16_t id = 0); + RegionEntry* findMatch(mesh::Packet* packet, uint8_t mask); + RegionEntry& getWildcard() { return wildcard; } + RegionEntry* findByName(const char* name); + RegionEntry* findByNamePrefix(const char* prefix); + RegionEntry* findById(uint16_t id); + RegionEntry* getHomeRegion(); // NOTE: can be NULL + void setHomeRegion(const RegionEntry* home); + RegionEntry* getDefaultRegion(); // NOTE: can be NULL + void setDefaultRegion(const RegionEntry* def); + bool removeRegion(const RegionEntry& region); + bool clear(); + void resetFrom(const RegionMap& src) { num_regions = 0; next_id = src.next_id; } + int getCount() const { return num_regions; } + const RegionEntry* getByIdx(int i) const { return ®ions[i]; } + const RegionEntry* getRoot() const { return &wildcard; } + int exportNamesTo(char* dest, int max_len, uint8_t mask, bool invert = false); + int getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num); + + size_t exportTo(char* dest, size_t max_len) const; +}; diff --git a/zephcore/helpers/ui/display.c b/zephcore/helpers/ui/display.c index 63ddc6d..807a8c2 100644 --- a/zephcore/helpers/ui/display.c +++ b/zephcore/helpers/ui/display.c @@ -1,525 +1,525 @@ -/* - * ZephCore - Display Abstraction (CFB) - * Copyright (c) 2025 ZephCore - * SPDX-License-Identifier: Apache-2.0 - * - * Wraps Zephyr's Character Framebuffer (CFB) subsystem. - * Auto-detects any Zephyr-supported display from devicetree: - * 1. "zephyr,display" chosen node (standard — works for any display) - * 2. Legacy nodelabels: sh1106, ssd1306 (backwards compat) - * - * Resolution is queried from the driver at runtime — no hardcoded - * dimensions. Layout code should use mc_display_width/height(). - * - * Auto-off timer turns display off after CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS. - */ - -#include "display.h" -#include "doom_game.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_display, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); - -/* ========== State ========== */ - -static const struct device *disp_dev; -static bool disp_on; -static bool disp_initialized; - -/* Runtime display geometry (queried from driver) */ -static uint16_t disp_width; -static uint16_t disp_height; -static uint8_t font_w; -static uint8_t font_h; -static bool is_epd; /* true for e-paper displays */ - -/* Optional symmetric inset (pixels). Shrinks reported width/height and - * offsets all draw primitives so panels with edge artefacts can hide them - * behind a clean background margin. */ -#define DISP_INSET ((int)CONFIG_ZEPHCORE_DISPLAY_INSET) - -/* Optional display backlight regulator (e.g. e-paper frontlight). - * Boards define a "disp_pwr_enable" regulator-fixed node to gate the - * backlight circuit. When present, backlight follows display on/off. */ -#if DT_NODE_EXISTS(DT_NODELABEL(disp_pwr_enable)) -static const struct device *backlight_reg = - DEVICE_DT_GET_OR_NULL(DT_NODELABEL(disp_pwr_enable)); -#else -static const struct device *backlight_reg; -#endif - -static bool backlight_on; - -/* EPD frame change detection (Arduino-style): - * hash draw calls across a frame and skip hardware flush if unchanged. */ -static uint32_t epd_frame_hash; -static uint32_t epd_last_frame_hash = UINT_MAX; - -static inline void epd_hash_bytes(const void *data, size_t len) -{ - if (!is_epd || !data || len == 0) { - return; - } - - const uint8_t *p = (const uint8_t *)data; - - for (size_t i = 0; i < len; i++) { - /* FNV-1a */ - epd_frame_hash ^= p[i]; - epd_frame_hash *= 16777619u; - } -} - -static inline void epd_hash_u32(uint32_t v) -{ - epd_hash_bytes(&v, sizeof(v)); -} - -static inline void backlight_set(bool on) -{ - if (backlight_reg && device_is_ready(backlight_reg) && on != backlight_on) { - if (on) { - regulator_enable(backlight_reg); - } else { - regulator_disable(backlight_reg); - } - backlight_on = on; - } -} - -/* Auto-off work */ -static struct k_work_delayable auto_off_work; - -static void auto_off_handler(struct k_work *work) -{ - ARG_UNUSED(work); - /* Don't blank display while Doom easter egg is playing */ - if (doom_game_is_running()) { - return; - } - /* E-paper content persists without power — blanking wastes a full - * refresh cycle (~2s) for no benefit. Just turn off the backlight - * and mark display "off" so the next button press triggers - * mc_display_on() → backlight restore. */ - if (is_epd) { - backlight_set(false); - disp_on = false; - return; - } - if (disp_on) { - mc_display_off(); - } -} - -/* ========== Early blanking ========== - * OLED controllers (SSD1306, SH1106) turn the display ON during driver init, - * showing stale VRAM from before reset. Our mc_display_init() runs much later - * (after BLE, LoRa, etc.), so there's a visible garbage flash. - * - * Fix: SYS_INIT hook runs right after the driver, sending "Display OFF" before - * main() starts. This is harmless for non-OLED displays (blanking is a no-op - * or already blanked). */ -static int display_early_blank(void) -{ - const struct device *dev = NULL; - - /* Try standard chosen node first */ -#if DT_HAS_CHOSEN(zephyr_display) - dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); -#endif - /* Legacy nodelabel fallback */ - if (!dev) { - dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); - } - if (!dev) { - dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); - } - if (dev && device_is_ready(dev)) { - /* EPD displays are bistable and already show clean white after - * driver init's full refresh. Calling blanking_on here would - * leave blanking_on=true so the subsequent blanking_off in - * mc_display_init() triggers an extra unnecessary full refresh. - * Skip blanking for EPD; OLED still needs it to hide stale VRAM. */ - struct display_capabilities caps; - - display_get_capabilities(dev, &caps); - if (!(caps.screen_info & SCREEN_INFO_EPD)) { - display_blanking_on(dev); - } - } - return 0; -} -SYS_INIT(display_early_blank, APPLICATION, 99); - -/* ========== Public API ========== */ - -int mc_display_init(void) -{ - /* Find display device from devicetree. - * Priority: zephyr,display chosen > sh1106 nodelabel > ssd1306 nodelabel. - * This supports any Zephyr display driver (SSD1306, SH1106, ST7735, - * ILI9341, SSD1681 e-ink, etc.) via the standard chosen mechanism. */ -#if DT_HAS_CHOSEN(zephyr_display) - disp_dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); -#endif - if (!disp_dev) { - disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); - } - if (!disp_dev) { - disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); - } - - if (!disp_dev || !device_is_ready(disp_dev)) { - LOG_INF("no display found - display disabled"); - return -ENODEV; - } - - /* Query actual resolution from display driver */ - struct display_capabilities caps; - - display_get_capabilities(disp_dev, &caps); - disp_width = caps.x_resolution; - disp_height = caps.y_resolution; - is_epd = (caps.screen_info & SCREEN_INFO_EPD) != 0; - - LOG_INF("display: %ux%u%s", disp_width, disp_height, - is_epd ? " (e-paper)" : ""); - - /* OLED: blank before CFB init so stale VRAM isn't visible while we - * build the first frame. EPD: driver init already performed a clean - * full refresh — the panel shows white. Skip blanking to avoid the - * extra full refresh that blanking_off would trigger. */ - if (!is_epd) { - display_blanking_on(disp_dev); - } - - /* Initialize CFB */ - int ret = cfb_framebuffer_init(disp_dev); - - if (ret) { - LOG_ERR("CFB init failed: %d", ret); - return ret; - } - - /* Font selection. - * Default: smallest height for best text density — our custom 6x8 - * Latin-1 font typically wins on OLEDs. - * LARGE_FONT: smallest font whose height is >= 16 — picks Zephyr's - * built-in 10x16 (cfb_fonts.c) for larger e-paper panels where 6x8 - * is too small to read. Falls back to smallest-overall if no tall - * font is compiled in. */ - const bool want_large = IS_ENABLED(CONFIG_ZEPHCORE_DISPLAY_LARGE_FONT); - int num_fonts = cfb_get_numof_fonts(disp_dev); - - LOG_DBG("display: %d fonts available", num_fonts); - - int best_idx = -1; - uint8_t best_h = 255; - - for (int i = 0; i < num_fonts; i++) { - uint8_t fw = 0, fh = 0; - - cfb_get_font_size(disp_dev, i, &fw, &fh); - LOG_DBG(" font[%d]: %ux%u", i, fw, fh); - if (want_large) { - if (fh >= 16 && fh < best_h) { - best_h = fh; - best_idx = i; - } - } else { - if (fh < best_h) { - best_h = fh; - best_idx = i; - } - } - } - if (best_idx < 0) { - /* No font satisfied the LARGE_FONT threshold — fall back to - * the smallest so we still render something. */ - best_idx = 0; - for (int i = 0; i < num_fonts; i++) { - uint8_t fw = 0, fh = 0; - - cfb_get_font_size(disp_dev, i, &fw, &fh); - if (fh < best_h) { - best_h = fh; - best_idx = i; - } - } - } - - cfb_framebuffer_set_font(disp_dev, best_idx); - cfb_get_font_size(disp_dev, best_idx, &font_w, &font_h); - LOG_INF("display: selected font[%d] (%ux%u)", best_idx, font_w, font_h); - - /* CFB inversion no longer needed — Zephyr commit 2374ef62f97 fixed - * the MONO10/MONO01 polarity logic in cfb_framebuffer_finalize(). - * SSD1306 OLED reports MONO01 by default, which CFB now handles - * correctly (white pixels on black background) without manual invert. */ - - /* Clear CPU-side framebuffer (zeroes the RAM buffer — no SPI transfer). */ - cfb_framebuffer_clear(disp_dev, false); - - /* Unblank the display so the driver uses partial refresh for all - * subsequent renders (ssd16xx: partial_refresh = !blanking_on). - * OLED: also push a blank frame first to clear stale VRAM. - * EPD: skip the frame push — partial refresh will write real content. */ - if (!is_epd) { - cfb_framebuffer_finalize(disp_dev); - } - display_blanking_off(disp_dev); - backlight_set(true); - disp_on = true; - disp_initialized = true; - - /* Set up auto-off timer and schedule initial timeout */ - k_work_init_delayable(&auto_off_work, auto_off_handler); - mc_display_reset_auto_off(); - - LOG_INF("display initialized (%ux%u, font %ux%u)", - disp_width, disp_height, font_w, font_h); - return 0; -} - -uint16_t mc_display_width(void) -{ - int w = (int)disp_width - 2 * DISP_INSET; - - return (w > 0) ? (uint16_t)w : 0; -} - -uint16_t mc_display_height(void) -{ - int h = (int)disp_height - 2 * DISP_INSET; - - return (h > 0) ? (uint16_t)h : 0; -} - -uint8_t mc_display_font_width(void) -{ - return font_w; -} - -uint8_t mc_display_font_height(void) -{ - return font_h; -} - -void mc_display_on(void) -{ - if (!disp_initialized) { - return; - } - - if (!disp_on) { - /* EPD: content persists (bistable) — no need to unblank, - * just restore backlight. OLED: actually unblank. */ - if (!is_epd) { - display_blanking_off(disp_dev); - } - disp_on = true; - } - backlight_set(true); - - mc_display_reset_auto_off(); -} - -void mc_display_off(void) -{ - if (!disp_initialized) { - return; - } - - if (disp_on) { - display_blanking_on(disp_dev); - backlight_set(false); - disp_on = false; - } -} - -bool mc_display_is_on(void) -{ - return disp_on; -} - -bool mc_display_is_epd(void) -{ - return is_epd; -} - -void mc_display_clear(void) -{ - if (!disp_initialized) { - return; - } - - if (is_epd) { - epd_frame_hash = 2166136261u; - } - cfb_framebuffer_clear(disp_dev, false); -} - -void mc_display_text(int x, int y, const char *text, bool invert) -{ - if (!disp_initialized || !text) { - return; - } - - if (invert) { - cfb_framebuffer_invert(disp_dev); - } - - if (is_epd) { - epd_hash_u32((uint32_t)x); - epd_hash_u32((uint32_t)y); - epd_hash_u32(invert ? 1u : 0u); - epd_hash_bytes(text, strlen(text)); - } - cfb_print(disp_dev, text, x + DISP_INSET, y + DISP_INSET); - - if (invert) { - cfb_framebuffer_invert(disp_dev); - } -} - -void mc_display_fill_rect(int x, int y, int w, int h) -{ - if (!disp_initialized) { - return; - } - - /* CFB doesn't have a native fill_rect, so we draw line by line */ - const int row_clamp = (int)disp_height - DISP_INSET; - - if (is_epd) { - epd_hash_u32((uint32_t)x); - epd_hash_u32((uint32_t)y); - epd_hash_u32((uint32_t)w); - epd_hash_u32((uint32_t)h); - } - for (int row = y + DISP_INSET; row < y + h + DISP_INSET && row < row_clamp; row++) { - struct cfb_position start = { .x = x + DISP_INSET, .y = row }; - struct cfb_position end = { .x = x + w - 1 + DISP_INSET, .y = row }; - cfb_draw_line(disp_dev, &start, &end); - } -} - -void mc_display_hline(int x, int y, int w) -{ - if (!disp_initialized) { - return; - } - - struct cfb_position start = { .x = x + DISP_INSET, .y = y + DISP_INSET }; - struct cfb_position end = { .x = x + w - 1 + DISP_INSET, .y = y + DISP_INSET }; - - if (is_epd) { - epd_hash_u32((uint32_t)x); - epd_hash_u32((uint32_t)y); - epd_hash_u32((uint32_t)w); - } - cfb_draw_line(disp_dev, &start, &end); -} - -void mc_display_xbm(int x, int y, const uint8_t *data, int w, int h) -{ - if (!disp_initialized || !data) { - return; - } - - /* Adafruit drawBitmap format (MSB first): row-major, bit 7 = leftmost. - * This matches the Arduino MeshCore logo data from icons.h. - * Each row is padded to byte boundary: bytes_per_row = (w+7)/8 */ - int bytes_per_row = (w + 7) / 8; - size_t bitmap_len = (size_t)bytes_per_row * (size_t)h; - - if (is_epd) { - epd_hash_u32((uint32_t)x); - epd_hash_u32((uint32_t)y); - epd_hash_u32((uint32_t)w); - epd_hash_u32((uint32_t)h); - epd_hash_bytes(data, bitmap_len); - } - - for (int row = 0; row < h; row++) { - for (int col = 0; col < w; col++) { - int byte_idx = row * bytes_per_row + col / 8; - int bit_idx = 7 - (col % 8); /* MSB first */ - - if (data[byte_idx] & (1 << bit_idx)) { - struct cfb_position pos = { - .x = (int16_t)(x + col + DISP_INSET), - .y = (int16_t)(y + row + DISP_INSET) - }; - cfb_draw_point(disp_dev, &pos); - } - } - } -} - -void mc_display_finalize(void) -{ - if (!disp_initialized) { - return; - } - - /* Don't let CFB overwrite display while Doom is rendering directly */ - if (doom_game_is_running()) { - return; - } - - if (is_epd && epd_frame_hash == epd_last_frame_hash) { - return; - } - cfb_framebuffer_finalize(disp_dev); - if (is_epd) { - epd_last_frame_hash = epd_frame_hash; - } -} - -void mc_display_reset_auto_off(void) -{ - if (!disp_initialized) { - return; - } - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS - uint32_t timeout = CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS; - - if (timeout > 0) { - k_work_reschedule(&auto_off_work, K_MSEC(timeout)); - } -#endif -} - -void mc_display_epd_full_reset(void) -{ - if (!disp_initialized || !is_epd) { - return; - } - - /* Force the SSD16xx path back through a full-refresh cycle before - * entering steady-state partial updates for page rendering. */ - display_blanking_on(disp_dev); - display_blanking_off(disp_dev); - epd_last_frame_hash = UINT_MAX; - - /* After splash handoff, keep frontlight off; next user interaction - * wakes it via mc_display_on(). */ - backlight_set(false); - disp_on = false; -} - -const struct device *mc_display_get_device(void) -{ - return disp_initialized ? disp_dev : NULL; -} +/* + * ZephCore - Display Abstraction (CFB) + * Copyright (c) 2025 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Wraps Zephyr's Character Framebuffer (CFB) subsystem. + * Auto-detects any Zephyr-supported display from devicetree: + * 1. "zephyr,display" chosen node (standard — works for any display) + * 2. Legacy nodelabels: sh1106, ssd1306 (backwards compat) + * + * Resolution is queried from the driver at runtime — no hardcoded + * dimensions. Layout code should use mc_display_width/height(). + * + * Auto-off timer turns display off after CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS. + */ + +#include "display.h" +#include "doom_game.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +LOG_MODULE_REGISTER(zephcore_display, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); + +/* ========== State ========== */ + +static const struct device *disp_dev; +static bool disp_on; +static bool disp_initialized; + +/* Runtime display geometry (queried from driver) */ +static uint16_t disp_width; +static uint16_t disp_height; +static uint8_t font_w; +static uint8_t font_h; +static bool is_epd; /* true for e-paper displays */ + +/* Optional symmetric inset (pixels). Shrinks reported width/height and + * offsets all draw primitives so panels with edge artefacts can hide them + * behind a clean background margin. */ +#define DISP_INSET ((int)CONFIG_ZEPHCORE_DISPLAY_INSET) + +/* Optional display backlight regulator (e.g. e-paper frontlight). + * Boards define a "disp_pwr_enable" regulator-fixed node to gate the + * backlight circuit. When present, backlight follows display on/off. */ +#if DT_NODE_EXISTS(DT_NODELABEL(disp_pwr_enable)) +static const struct device *backlight_reg = + DEVICE_DT_GET_OR_NULL(DT_NODELABEL(disp_pwr_enable)); +#else +static const struct device *backlight_reg; +#endif + +static bool backlight_on; + +/* EPD frame change detection (Arduino-style): + * hash draw calls across a frame and skip hardware flush if unchanged. */ +static uint32_t epd_frame_hash; +static uint32_t epd_last_frame_hash = UINT_MAX; + +static inline void epd_hash_bytes(const void *data, size_t len) +{ + if (!is_epd || !data || len == 0) { + return; + } + + const uint8_t *p = (const uint8_t *)data; + + for (size_t i = 0; i < len; i++) { + /* FNV-1a */ + epd_frame_hash ^= p[i]; + epd_frame_hash *= 16777619u; + } +} + +static inline void epd_hash_u32(uint32_t v) +{ + epd_hash_bytes(&v, sizeof(v)); +} + +static inline void backlight_set(bool on) +{ + if (backlight_reg && device_is_ready(backlight_reg) && on != backlight_on) { + if (on) { + regulator_enable(backlight_reg); + } else { + regulator_disable(backlight_reg); + } + backlight_on = on; + } +} + +/* Auto-off work */ +static struct k_work_delayable auto_off_work; + +static void auto_off_handler(struct k_work *work) +{ + ARG_UNUSED(work); + /* Don't blank display while Doom easter egg is playing */ + if (doom_game_is_running()) { + return; + } + /* E-paper content persists without power — blanking wastes a full + * refresh cycle (~2s) for no benefit. Just turn off the backlight + * and mark display "off" so the next button press triggers + * mc_display_on() → backlight restore. */ + if (is_epd) { + backlight_set(false); + disp_on = false; + return; + } + if (disp_on) { + mc_display_off(); + } +} + +/* ========== Early blanking ========== + * OLED controllers (SSD1306, SH1106) turn the display ON during driver init, + * showing stale VRAM from before reset. Our mc_display_init() runs much later + * (after BLE, LoRa, etc.), so there's a visible garbage flash. + * + * Fix: SYS_INIT hook runs right after the driver, sending "Display OFF" before + * main() starts. This is harmless for non-OLED displays (blanking is a no-op + * or already blanked). */ +static int display_early_blank(void) +{ + const struct device *dev = NULL; + + /* Try standard chosen node first */ +#if DT_HAS_CHOSEN(zephyr_display) + dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); +#endif + /* Legacy nodelabel fallback */ + if (!dev) { + dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); + } + if (!dev) { + dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); + } + if (dev && device_is_ready(dev)) { + /* EPD displays are bistable and already show clean white after + * driver init's full refresh. Calling blanking_on here would + * leave blanking_on=true so the subsequent blanking_off in + * mc_display_init() triggers an extra unnecessary full refresh. + * Skip blanking for EPD; OLED still needs it to hide stale VRAM. */ + struct display_capabilities caps; + + display_get_capabilities(dev, &caps); + if (!(caps.screen_info & SCREEN_INFO_EPD)) { + display_blanking_on(dev); + } + } + return 0; +} +SYS_INIT(display_early_blank, APPLICATION, 99); + +/* ========== Public API ========== */ + +int mc_display_init(void) +{ + /* Find display device from devicetree. + * Priority: zephyr,display chosen > sh1106 nodelabel > ssd1306 nodelabel. + * This supports any Zephyr display driver (SSD1306, SH1106, ST7735, + * ILI9341, SSD1681 e-ink, etc.) via the standard chosen mechanism. */ +#if DT_HAS_CHOSEN(zephyr_display) + disp_dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); +#endif + if (!disp_dev) { + disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); + } + if (!disp_dev) { + disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); + } + + if (!disp_dev || !device_is_ready(disp_dev)) { + LOG_INF("no display found - display disabled"); + return -ENODEV; + } + + /* Query actual resolution from display driver */ + struct display_capabilities caps; + + display_get_capabilities(disp_dev, &caps); + disp_width = caps.x_resolution; + disp_height = caps.y_resolution; + is_epd = (caps.screen_info & SCREEN_INFO_EPD) != 0; + + LOG_INF("display: %ux%u%s", disp_width, disp_height, + is_epd ? " (e-paper)" : ""); + + /* OLED: blank before CFB init so stale VRAM isn't visible while we + * build the first frame. EPD: driver init already performed a clean + * full refresh — the panel shows white. Skip blanking to avoid the + * extra full refresh that blanking_off would trigger. */ + if (!is_epd) { + display_blanking_on(disp_dev); + } + + /* Initialize CFB */ + int ret = cfb_framebuffer_init(disp_dev); + + if (ret) { + LOG_ERR("CFB init failed: %d", ret); + return ret; + } + + /* Font selection. + * Default: smallest height for best text density — our custom 6x8 + * Latin-1 font typically wins on OLEDs. + * LARGE_FONT: smallest font whose height is >= 16 — picks Zephyr's + * built-in 10x16 (cfb_fonts.c) for larger e-paper panels where 6x8 + * is too small to read. Falls back to smallest-overall if no tall + * font is compiled in. */ + const bool want_large = IS_ENABLED(CONFIG_ZEPHCORE_DISPLAY_LARGE_FONT); + int num_fonts = cfb_get_numof_fonts(disp_dev); + + LOG_DBG("display: %d fonts available", num_fonts); + + int best_idx = -1; + uint8_t best_h = 255; + + for (int i = 0; i < num_fonts; i++) { + uint8_t fw = 0, fh = 0; + + cfb_get_font_size(disp_dev, i, &fw, &fh); + LOG_DBG(" font[%d]: %ux%u", i, fw, fh); + if (want_large) { + if (fh >= 16 && fh < best_h) { + best_h = fh; + best_idx = i; + } + } else { + if (fh < best_h) { + best_h = fh; + best_idx = i; + } + } + } + if (best_idx < 0) { + /* No font satisfied the LARGE_FONT threshold — fall back to + * the smallest so we still render something. */ + best_idx = 0; + for (int i = 0; i < num_fonts; i++) { + uint8_t fw = 0, fh = 0; + + cfb_get_font_size(disp_dev, i, &fw, &fh); + if (fh < best_h) { + best_h = fh; + best_idx = i; + } + } + } + + cfb_framebuffer_set_font(disp_dev, best_idx); + cfb_get_font_size(disp_dev, best_idx, &font_w, &font_h); + LOG_INF("display: selected font[%d] (%ux%u)", best_idx, font_w, font_h); + + /* CFB inversion no longer needed — Zephyr commit 2374ef62f97 fixed + * the MONO10/MONO01 polarity logic in cfb_framebuffer_finalize(). + * SSD1306 OLED reports MONO01 by default, which CFB now handles + * correctly (white pixels on black background) without manual invert. */ + + /* Clear CPU-side framebuffer (zeroes the RAM buffer — no SPI transfer). */ + cfb_framebuffer_clear(disp_dev, false); + + /* Unblank the display so the driver uses partial refresh for all + * subsequent renders (ssd16xx: partial_refresh = !blanking_on). + * OLED: also push a blank frame first to clear stale VRAM. + * EPD: skip the frame push — partial refresh will write real content. */ + if (!is_epd) { + cfb_framebuffer_finalize(disp_dev); + } + display_blanking_off(disp_dev); + backlight_set(true); + disp_on = true; + disp_initialized = true; + + /* Set up auto-off timer and schedule initial timeout */ + k_work_init_delayable(&auto_off_work, auto_off_handler); + mc_display_reset_auto_off(); + + LOG_INF("display initialized (%ux%u, font %ux%u)", + disp_width, disp_height, font_w, font_h); + return 0; +} + +uint16_t mc_display_width(void) +{ + int w = (int)disp_width - 2 * DISP_INSET; + + return (w > 0) ? (uint16_t)w : 0; +} + +uint16_t mc_display_height(void) +{ + int h = (int)disp_height - 2 * DISP_INSET; + + return (h > 0) ? (uint16_t)h : 0; +} + +uint8_t mc_display_font_width(void) +{ + return font_w; +} + +uint8_t mc_display_font_height(void) +{ + return font_h; +} + +void mc_display_on(void) +{ + if (!disp_initialized) { + return; + } + + if (!disp_on) { + /* EPD: content persists (bistable) — no need to unblank, + * just restore backlight. OLED: actually unblank. */ + if (!is_epd) { + display_blanking_off(disp_dev); + } + disp_on = true; + } + backlight_set(true); + + mc_display_reset_auto_off(); +} + +void mc_display_off(void) +{ + if (!disp_initialized) { + return; + } + + if (disp_on) { + display_blanking_on(disp_dev); + backlight_set(false); + disp_on = false; + } +} + +bool mc_display_is_on(void) +{ + return disp_on; +} + +bool mc_display_is_epd(void) +{ + return is_epd; +} + +void mc_display_clear(void) +{ + if (!disp_initialized) { + return; + } + + if (is_epd) { + epd_frame_hash = 2166136261u; + } + cfb_framebuffer_clear(disp_dev, false); +} + +void mc_display_text(int x, int y, const char *text, bool invert) +{ + if (!disp_initialized || !text) { + return; + } + + if (invert) { + cfb_framebuffer_invert(disp_dev); + } + + if (is_epd) { + epd_hash_u32((uint32_t)x); + epd_hash_u32((uint32_t)y); + epd_hash_u32(invert ? 1u : 0u); + epd_hash_bytes(text, strlen(text)); + } + cfb_print(disp_dev, text, x + DISP_INSET, y + DISP_INSET); + + if (invert) { + cfb_framebuffer_invert(disp_dev); + } +} + +void mc_display_fill_rect(int x, int y, int w, int h) +{ + if (!disp_initialized) { + return; + } + + /* CFB doesn't have a native fill_rect, so we draw line by line */ + const int row_clamp = (int)disp_height - DISP_INSET; + + if (is_epd) { + epd_hash_u32((uint32_t)x); + epd_hash_u32((uint32_t)y); + epd_hash_u32((uint32_t)w); + epd_hash_u32((uint32_t)h); + } + for (int row = y + DISP_INSET; row < y + h + DISP_INSET && row < row_clamp; row++) { + struct cfb_position start = { .x = x + DISP_INSET, .y = row }; + struct cfb_position end = { .x = x + w - 1 + DISP_INSET, .y = row }; + cfb_draw_line(disp_dev, &start, &end); + } +} + +void mc_display_hline(int x, int y, int w) +{ + if (!disp_initialized) { + return; + } + + struct cfb_position start = { .x = x + DISP_INSET, .y = y + DISP_INSET }; + struct cfb_position end = { .x = x + w - 1 + DISP_INSET, .y = y + DISP_INSET }; + + if (is_epd) { + epd_hash_u32((uint32_t)x); + epd_hash_u32((uint32_t)y); + epd_hash_u32((uint32_t)w); + } + cfb_draw_line(disp_dev, &start, &end); +} + +void mc_display_xbm(int x, int y, const uint8_t *data, int w, int h) +{ + if (!disp_initialized || !data) { + return; + } + + /* Adafruit drawBitmap format (MSB first): row-major, bit 7 = leftmost. + * This matches the Arduino MeshCore logo data from icons.h. + * Each row is padded to byte boundary: bytes_per_row = (w+7)/8 */ + int bytes_per_row = (w + 7) / 8; + size_t bitmap_len = (size_t)bytes_per_row * (size_t)h; + + if (is_epd) { + epd_hash_u32((uint32_t)x); + epd_hash_u32((uint32_t)y); + epd_hash_u32((uint32_t)w); + epd_hash_u32((uint32_t)h); + epd_hash_bytes(data, bitmap_len); + } + + for (int row = 0; row < h; row++) { + for (int col = 0; col < w; col++) { + int byte_idx = row * bytes_per_row + col / 8; + int bit_idx = 7 - (col % 8); /* MSB first */ + + if (data[byte_idx] & (1 << bit_idx)) { + struct cfb_position pos = { + .x = (int16_t)(x + col + DISP_INSET), + .y = (int16_t)(y + row + DISP_INSET) + }; + cfb_draw_point(disp_dev, &pos); + } + } + } +} + +void mc_display_finalize(void) +{ + if (!disp_initialized) { + return; + } + + /* Don't let CFB overwrite display while Doom is rendering directly */ + if (doom_game_is_running()) { + return; + } + + if (is_epd && epd_frame_hash == epd_last_frame_hash) { + return; + } + cfb_framebuffer_finalize(disp_dev); + if (is_epd) { + epd_last_frame_hash = epd_frame_hash; + } +} + +void mc_display_reset_auto_off(void) +{ + if (!disp_initialized) { + return; + } + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS + uint32_t timeout = CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS; + + if (timeout > 0) { + k_work_reschedule(&auto_off_work, K_MSEC(timeout)); + } +#endif +} + +void mc_display_epd_full_reset(void) +{ + if (!disp_initialized || !is_epd) { + return; + } + + /* Force the SSD16xx path back through a full-refresh cycle before + * entering steady-state partial updates for page rendering. */ + display_blanking_on(disp_dev); + display_blanking_off(disp_dev); + epd_last_frame_hash = UINT_MAX; + + /* After splash handoff, keep frontlight off; next user interaction + * wakes it via mc_display_on(). */ + backlight_set(false); + disp_on = false; +} + +const struct device *mc_display_get_device(void) +{ + return disp_initialized ? disp_dev : NULL; +} diff --git a/zephcore/helpers/ui/display.h b/zephcore/helpers/ui/display.h index 8fce1e2..f4ae73e 100644 --- a/zephcore/helpers/ui/display.h +++ b/zephcore/helpers/ui/display.h @@ -1,157 +1,157 @@ -/* - * ZephCore - Display Abstraction (CFB) - * Copyright (c) 2025 ZephCore - * SPDX-License-Identifier: Apache-2.0 - * - * Wraps Zephyr's Character Framebuffer (CFB) subsystem with: - * - Auto-detection from devicetree (any Zephyr-supported display) - * - Runtime resolution query (supports any size, not just 128x64) - * - Auto-off timer via k_work_delayable - * - Simple text/rect drawing API for UI pages - * - * All functions prefixed mc_display_ to avoid collision with - * Zephyr's display_* namespace in . - */ - -#ifndef ZEPHCORE_DISPLAY_H -#define ZEPHCORE_DISPLAY_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Initialize the display from devicetree. - * Detects any Zephyr-supported display via: - * 1. "zephyr,display" chosen node (standard) - * 2. Legacy nodelabels: sh1106, ssd1306 (backwards compat) - * - * Queries actual resolution from driver — no hardcoded dimensions. - * - * @return 0 on success, negative errno on failure, -ENODEV if no display - */ -int mc_display_init(void); - -/** - * Get display width in pixels (queried from hardware at init). - * Returns 0 if display not initialized. - */ -uint16_t mc_display_width(void); - -/** - * Get display height in pixels (queried from hardware at init). - * Returns 0 if display not initialized. - */ -uint16_t mc_display_height(void); - -/** - * Get active font width in pixels. - * Returns 0 if display not initialized. - */ -uint8_t mc_display_font_width(void); - -/** - * Get active font height in pixels. - * Returns 0 if display not initialized. - */ -uint8_t mc_display_font_height(void); - -/** - * Turn display on (wake from blanking). - * Resets the auto-off timer. - */ -void mc_display_on(void); - -/** - * Turn display off (blanking). - */ -void mc_display_off(void); - -/** - * @return true if the display is currently on - */ -bool mc_display_is_on(void); - -/** - * @return true if the display is an e-paper (EPD) type. - * EPD displays have slow refresh (~2s) and use zero power when static, - * so callers should use longer update intervals and skip blanking. - */ -bool mc_display_is_epd(void); - -/** - * Clear the framebuffer (fill with black). - * Call before rendering a new frame. - */ -void mc_display_clear(void); - -/** - * Draw text at position. - * - * @param x X position in pixels - * @param y Y position in pixels - * @param text Null-terminated string - * @param invert If true, draw black text on white background - */ -void mc_display_text(int x, int y, const char *text, bool invert); - -/** - * Draw a filled rectangle. - * - * @param x Top-left X - * @param y Top-left Y - * @param w Width - * @param h Height - */ -void mc_display_fill_rect(int x, int y, int w, int h); - -/** - * Draw a horizontal line. - */ -void mc_display_hline(int x, int y, int w); - -/** - * Draw a monochrome bitmap (Adafruit/Arduino format). - * MSB first, row-major, 1=foreground. - * Compatible with Arduino's drawBitmap() and MeshCore icons.h data. - * - * @param x Top-left X position - * @param y Top-left Y position - * @param data Bitmap data (MSB first, row-major) - * @param w Width in pixels - * @param h Height in pixels - */ -void mc_display_xbm(int x, int y, const uint8_t *data, int w, int h); - -/** - * Flush the framebuffer to the display hardware. - * Call after all drawing operations for a frame are complete. - */ -void mc_display_finalize(void); - -/** - * Reset the auto-off timer (called on user interaction). - */ -void mc_display_reset_auto_off(void); - -/** - * EPD-only: force a full panel reset cycle before normal page rendering. - * No-op on non-EPD displays or when display is not initialized. - */ -void mc_display_epd_full_reset(void); - -/** - * Get the raw display device pointer. - * Used by easter egg (Doom) to bypass CFB and write directly. - * Returns NULL if display not initialized. - */ -const struct device *mc_display_get_device(void); - -#ifdef __cplusplus -} -#endif - -#endif /* ZEPHCORE_DISPLAY_H */ +/* + * ZephCore - Display Abstraction (CFB) + * Copyright (c) 2025 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Wraps Zephyr's Character Framebuffer (CFB) subsystem with: + * - Auto-detection from devicetree (any Zephyr-supported display) + * - Runtime resolution query (supports any size, not just 128x64) + * - Auto-off timer via k_work_delayable + * - Simple text/rect drawing API for UI pages + * + * All functions prefixed mc_display_ to avoid collision with + * Zephyr's display_* namespace in . + */ + +#ifndef ZEPHCORE_DISPLAY_H +#define ZEPHCORE_DISPLAY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Initialize the display from devicetree. + * Detects any Zephyr-supported display via: + * 1. "zephyr,display" chosen node (standard) + * 2. Legacy nodelabels: sh1106, ssd1306 (backwards compat) + * + * Queries actual resolution from driver — no hardcoded dimensions. + * + * @return 0 on success, negative errno on failure, -ENODEV if no display + */ +int mc_display_init(void); + +/** + * Get display width in pixels (queried from hardware at init). + * Returns 0 if display not initialized. + */ +uint16_t mc_display_width(void); + +/** + * Get display height in pixels (queried from hardware at init). + * Returns 0 if display not initialized. + */ +uint16_t mc_display_height(void); + +/** + * Get active font width in pixels. + * Returns 0 if display not initialized. + */ +uint8_t mc_display_font_width(void); + +/** + * Get active font height in pixels. + * Returns 0 if display not initialized. + */ +uint8_t mc_display_font_height(void); + +/** + * Turn display on (wake from blanking). + * Resets the auto-off timer. + */ +void mc_display_on(void); + +/** + * Turn display off (blanking). + */ +void mc_display_off(void); + +/** + * @return true if the display is currently on + */ +bool mc_display_is_on(void); + +/** + * @return true if the display is an e-paper (EPD) type. + * EPD displays have slow refresh (~2s) and use zero power when static, + * so callers should use longer update intervals and skip blanking. + */ +bool mc_display_is_epd(void); + +/** + * Clear the framebuffer (fill with black). + * Call before rendering a new frame. + */ +void mc_display_clear(void); + +/** + * Draw text at position. + * + * @param x X position in pixels + * @param y Y position in pixels + * @param text Null-terminated string + * @param invert If true, draw black text on white background + */ +void mc_display_text(int x, int y, const char *text, bool invert); + +/** + * Draw a filled rectangle. + * + * @param x Top-left X + * @param y Top-left Y + * @param w Width + * @param h Height + */ +void mc_display_fill_rect(int x, int y, int w, int h); + +/** + * Draw a horizontal line. + */ +void mc_display_hline(int x, int y, int w); + +/** + * Draw a monochrome bitmap (Adafruit/Arduino format). + * MSB first, row-major, 1=foreground. + * Compatible with Arduino's drawBitmap() and MeshCore icons.h data. + * + * @param x Top-left X position + * @param y Top-left Y position + * @param data Bitmap data (MSB first, row-major) + * @param w Width in pixels + * @param h Height in pixels + */ +void mc_display_xbm(int x, int y, const uint8_t *data, int w, int h); + +/** + * Flush the framebuffer to the display hardware. + * Call after all drawing operations for a frame are complete. + */ +void mc_display_finalize(void); + +/** + * Reset the auto-off timer (called on user interaction). + */ +void mc_display_reset_auto_off(void); + +/** + * EPD-only: force a full panel reset cycle before normal page rendering. + * No-op on non-EPD displays or when display is not initialized. + */ +void mc_display_epd_full_reset(void); + +/** + * Get the raw display device pointer. + * Used by easter egg (Doom) to bypass CFB and write directly. + * Returns NULL if display not initialized. + */ +const struct device *mc_display_get_device(void); + +#ifdef __cplusplus +} +#endif + +#endif /* ZEPHCORE_DISPLAY_H */ diff --git a/zephcore/helpers/ui/ui_task.c b/zephcore/helpers/ui/ui_task.c index 718366a..058037f 100644 --- a/zephcore/helpers/ui/ui_task.c +++ b/zephcore/helpers/ui/ui_task.c @@ -1,1226 +1,1226 @@ -/* - * ZephCore - UI Task - * Copyright (c) 2025 ZephCore - * SPDX-License-Identifier: Apache-2.0 - * - * Integration layer that wires buttons, buzzer, and display together. - * All event-driven via Zephyr input subsystem + k_work. - * - * Input flow (after longpress + multi-tap filter chain): - * KEY_1 → action_page_next() (1 tap, 400ms delayed) - * KEY_LEFT → action_page_prev() (2 taps — RAK4631 / Pocket / Heltec V3–V4.3) - * KEY_B → action_flood_advert() (2 taps on extended multitap overlays) - * KEY_D → action_buzzer_toggle() (3 taps) - * KEY_C → action_gps_toggle() (4 taps, immediate) - * KEY_G → GPS switch on/off (hardware toggle, ThinkNode M1) - * KEY_POWER / KEY_F → action_deep_sleep() (long press — boards that emit these) - * KEY_ENTER → action_page_enter() (long press — Pocket / Heltec; joystick center Wio) - * KEY_RIGHT → action_page_next() (joystick, Wio Tracker) - * - * Notification flow: - * LoRa RX → CompanionMesh → ui_notify(UI_EVENT_CONTACT_MSG) - * → buzzer_play(MELODY_MSG_CONTACT) - * → mc_display_on() + render - */ - -#include "ui_task.h" -#include "ui_pages.h" - -#ifdef CONFIG_ZEPHCORE_UI_BUZZER -#include "buzzer.h" -#endif - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY -#include "display.h" -#endif - -#include - -#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM -#include "doom_game.h" -#endif -#include -#include -#include -#include - -#ifdef CONFIG_POWEROFF -#include -#endif - -#if defined(CONFIG_SOC_FAMILY_NORDIC_NRF) -#include -#endif - -#include - -/* GPS control (extern "C" in ZephyrSensorManager.h) */ -#include - -/* Mesh action wrappers (deferred to mesh event loop thread) */ -#include "ui_mesh_actions.h" - -#include -LOG_MODULE_REGISTER(ui_task, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); - -/* Tap-count feedback melodies. - * b=200, d=16 → each chirp ~75ms, rest ~75ms = clear separation. - * - * 2 taps (flood advert): chirp-chirp - * 3 taps (buzzer toggle): chirp×3 + high(ON) or low(OFF) - * 4 taps (GPS toggle): chirp×4 + high(ON) or low(OFF) - * - * ON tail: high E7 (~2637Hz) = "enabled" - * OFF tail: low G5 (~784Hz) = "disabled" */ -#define MELODY_BEEP_2 "b2:d=16,o=7,b=200:c,p,c" - -#define MELODY_BUZZER_ON "bon:d=16,o=7,b=200:c,p,c,p,c,p,p,8e" -#define MELODY_BUZZER_OFF "bof:d=16,o=7,b=200:c,p,c,p,c,p,p,8g5" - -#define MELODY_GPS_ON "gon:d=16,o=7,b=200:c,p,c,p,c,p,c,p,p,8e" -#define MELODY_GPS_OFF "gof:d=16,o=7,b=200:c,p,c,p,c,p,c,p,p,8g5" - -#define MELODY_LED_ON "lon:d=16,o=7,b=200:c,p,c,p,c,p,c,p,c,p,p,8e" -#define MELODY_LED_OFF "lof:d=16,o=7,b=200:c,p,c,p,c,p,c,p,c,p,p,8g5" - -/* ========== LED Heartbeat ========== */ -/* Match Arduino: 4s cycle, 20ms pulse (normal) or 200ms (unread messages). - * Uses led0 or led1 alias — whichever exists in the board's DTS. - * Disabled when OLED display is present (display makes LED redundant). */ -/* Heartbeat LED — subtle pulse every 4s on led0 (or led1 fallback). - * Works alongside displays; boards that want to disable it can - * remove the led0 alias or override this with a Kconfig guard. */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) -static const struct gpio_dt_spec heartbeat_led = - GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios); -#define HAS_HEARTBEAT_LED 1 -#elif DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) -static const struct gpio_dt_spec heartbeat_led = - GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); -#define HAS_HEARTBEAT_LED 1 -#else -#define HAS_HEARTBEAT_LED 0 -#endif - -/* Second LED: unread pulse (companion only). Repeaters use led1 for LoRa TX via - * lora-tx-led alias — no offline queue, so ui_task leaves led1 alone. */ -#if HAS_HEARTBEAT_LED && DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && \ - DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) && !defined(ZEPHCORE_REPEATER) -static const struct gpio_dt_spec msg_led = - GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); -#define HAS_MSG_LED 1 -#else -#define HAS_MSG_LED 0 -#endif - -#define LED_CYCLE_MS 4000 /* Total heartbeat period */ -#define LED_ON_MS 20 /* Normal pulse width */ -#define LED_ON_MSG_MS 200 /* Pulse width when unread messages */ - -/* ========== Deep Sleep / System OFF ========== */ -/* On nRF52840, sys_poweroff() = System OFF (~1µA). - * Wake via reset button → full chip reset → boots fresh. */ - -/* When display is not available, maintain a local ui_state for setters - * (data can still be used for logging or future features). */ -#ifndef CONFIG_ZEPHCORE_UI_DISPLAY -static struct ui_state local_ui_state; -#endif - -/* ========== Constants ========== */ -#define SPLASH_DURATION_MS 3000 -#define RENDER_DEBOUNCE_MS 50 -#define RENDER_DEBOUNCE_EPD_MS 200 /* e-paper: coalesce rapid state updates, then refresh */ - -/* ========== State ========== */ -static bool ui_initialized; -static bool splash_active; - -/* ========== Doom Easter Egg Activation ========== */ -#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM -/* - * Activation sequence: double-click + single-click INPUT_KEY_ENTER on page 0. - * That's 3 presses total: press-press (double) then press (single). - * Deactivation: double-click INPUT_KEY_ENTER while Doom is running. - * - * State machine: - * IDLE → (1st press) → FIRST_PRESS - * FIRST_PRESS → (2nd press within 500ms) → DCLICK_DONE - * DCLICK_DONE → (3rd press within 500ms) → activate Doom - * Any state → (timeout) → IDLE - */ -enum doom_activate_state { - DOOM_ACT_IDLE, - DOOM_ACT_FIRST_PRESS, - DOOM_ACT_DCLICK_DONE, -}; - -static enum doom_activate_state doom_act_state; -static uint32_t doom_act_time; -#define DOOM_ACT_TIMEOUT_MS 500 -#endif - -static inline struct ui_state *get_state(void) -{ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - return ui_pages_get_state(); -#else - return &local_ui_state; -#endif -} - -/* Render work - debounced display update */ -static struct k_work_delayable render_work; -/* Splash timeout work */ -static struct k_work_delayable splash_work; -/* Deferred zero-hop advert — waits for possible double-press upgrade to flood */ -static struct k_work_delayable advert_defer_work; - -/* LED heartbeat only on boards with LED but no OLED (display makes it redundant) */ -#if HAS_HEARTBEAT_LED -/* LED heartbeat: two one-shot works form a self-rescheduling cycle. - * led_on_work turns LED on and schedules led_off_work after pulse width. - * led_off_work turns LED off and schedules led_on_work after remainder. */ -static struct k_work_delayable led_on_work; -static struct k_work_delayable led_off_work; -#endif - -/* ========== Work Handlers ========== */ - -#if HAS_HEARTBEAT_LED -static void led_off_work_handler(struct k_work *work) -{ - ARG_UNUSED(work); - gpio_pin_set_dt(&heartbeat_led, 0); -#if HAS_MSG_LED - gpio_pin_set_dt(&msg_led, 0); -#endif - - /* Schedule next ON after remainder of cycle */ - struct ui_state *s = get_state(); - uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; - - k_work_reschedule(&led_on_work, K_MSEC(LED_CYCLE_MS - on_ms)); -} - -static void led_on_work_handler(struct k_work *work) -{ - ARG_UNUSED(work); - gpio_pin_set_dt(&heartbeat_led, 1); -#if HAS_MSG_LED - struct ui_state *s = get_state(); - if (s->msg_count > 0) { - gpio_pin_set_dt(&msg_led, 1); - } - uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; -#else - struct ui_state *s = get_state(); - uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; -#endif - - k_work_reschedule(&led_off_work, K_MSEC(on_ms)); -} -#endif - -static void render_work_handler(struct k_work *work) -{ - ARG_UNUSED(work); - - if (!ui_initialized) { - return; - } - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - /* EPD: render even when backlight is off — content is always visible. - * OLED: only render when display is on (screen is black when off). */ - if ((mc_display_is_on() || mc_display_is_epd()) && !splash_active) { - ui_pages_render(); - } -#endif -} - -static void splash_work_handler(struct k_work *work) -{ - ARG_UNUSED(work); - splash_active = false; - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - mc_display_epd_full_reset(); - - /* Transition from splash to home page (first active page for this role) */ -#ifdef ZEPHCORE_REPEATER - ui_pages_set(UI_PAGE_STATUS); -#else - ui_pages_set(UI_PAGE_MESSAGES); -#endif - k_work_reschedule(&render_work, K_NO_WAIT); -#endif -} - -static void schedule_render(void); - -static void advert_defer_handler(struct k_work *work) -{ - ARG_UNUSED(work); - /* Timer expired without second press — send zero-hop */ - LOG_INF("zero-hop advert requested (deferred)"); -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(MELODY_BEEP_2); -#endif - mesh_send_zerohop_advert(); -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - ui_pages_advert_sent(false); -#endif - schedule_render(); -} - -static void schedule_render(void) -{ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - uint32_t debounce = mc_display_is_epd() ? RENDER_DEBOUNCE_EPD_MS - : RENDER_DEBOUNCE_MS; - k_work_reschedule(&render_work, K_MSEC(debounce)); -#endif -} - -/* ========== Button Action Functions ========== */ -/* Each action checks capabilities internally — no #ifdef in the switch. */ - -static void action_page_next(void) -{ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - ui_pages_next(); -#endif - schedule_render(); -} - -static void action_page_prev(void) -{ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - ui_pages_prev(); -#endif - schedule_render(); -} - -/* Forward declarations for page-enter dispatch */ -static void action_flood_advert(void); -static void action_gps_toggle(void); -static void action_buzzer_toggle(void); -static void action_leds_toggle(void); -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY -static void action_ble_toggle(void); -static void action_enter_dfu(void); -#endif -static void action_deep_sleep(void); - -static void action_page_enter(void) -{ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - enum ui_page page = ui_pages_current(); - - LOG_DBG("ENTER on page %d", page); - - switch (page) { - case UI_PAGE_BLUETOOTH: - /* Toggle BLE on/off */ - action_ble_toggle(); - break; - - case UI_PAGE_ADVERT: - /* Single press: defer 500ms then send zero-hop. - * Double press within 500ms: cancel deferred, send flood. */ - if (k_work_delayable_is_pending(&advert_defer_work)) { - /* Second press — cancel deferred zero-hop, send flood */ - k_work_cancel_delayable(&advert_defer_work); - action_flood_advert(); - } else { - /* First press — start deferred zero-hop */ - k_work_reschedule(&advert_defer_work, K_MSEC(500)); - } - break; - - case UI_PAGE_GPS: - /* Toggle GPS (same as quad-tap) */ - action_gps_toggle(); - break; - - case UI_PAGE_BUZZER: - /* Toggle buzzer mute (same as triple-tap) */ - action_buzzer_toggle(); - break; - - case UI_PAGE_LEDS: - /* Toggle LED on/off */ - action_leds_toggle(); - break; - - case UI_PAGE_OFFGRID: { - /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ - struct ui_state *st_og = get_state(); - uint32_t now_og = k_uptime_get_32(); - - if (st_og->offgrid_confirm_time != 0 && - (now_og - st_og->offgrid_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { - /* Confirmed — toggle offgrid mode */ - bool new_state = !st_og->offgrid_enabled; - st_og->offgrid_enabled = new_state; - st_og->offgrid_confirm_time = 0; - mesh_set_offgrid_mode(new_state); - LOG_INF("offgrid mode %s (button)", new_state ? "on" : "off"); - } else { - /* First press — enter confirmation state */ - st_og->offgrid_confirm_time = now_og; - } - schedule_render(); - break; - } - - case UI_PAGE_DFU: { - /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ - struct ui_state *st_dfu = get_state(); - uint32_t now_dfu = k_uptime_get_32(); - - if (st_dfu->dfu_confirm_time != 0 && - (now_dfu - st_dfu->dfu_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { - /* Confirmed — reboot into BLE DFU */ - action_enter_dfu(); - } else { - /* First press — enter confirmation state */ - st_dfu->dfu_confirm_time = now_dfu; - schedule_render(); - } - break; - } - - case UI_PAGE_SHUTDOWN: { - /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ - struct ui_state *st = get_state(); - uint32_t now = k_uptime_get_32(); - - if (st->shutdown_confirm_time != 0 && - (now - st->shutdown_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { - /* Confirmed — shut down */ - action_deep_sleep(); - } else { - /* First press — enter confirmation state */ - st->shutdown_confirm_time = now; - schedule_render(); - } - break; - } - - case UI_PAGE_MESSAGES: -#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM - { - /* Doom activation: 3 presses — double-click + single-click. - * Press 1 → FIRST_PRESS - * Press 2 (within 500ms) → DCLICK_DONE - * Press 3 (within 500ms) → activate Doom */ - uint32_t now_doom = k_uptime_get_32(); - - switch (doom_act_state) { - case DOOM_ACT_IDLE: - doom_act_time = now_doom; - doom_act_state = DOOM_ACT_FIRST_PRESS; - break; - case DOOM_ACT_FIRST_PRESS: - if ((now_doom - doom_act_time) <= DOOM_ACT_TIMEOUT_MS) { - /* Double-click detected, wait for single */ - doom_act_time = now_doom; - doom_act_state = DOOM_ACT_DCLICK_DONE; - } else { - /* Timeout — restart as new first press */ - doom_act_time = now_doom; - doom_act_state = DOOM_ACT_FIRST_PRESS; - } - break; - case DOOM_ACT_DCLICK_DONE: - if ((now_doom - doom_act_time) <= DOOM_ACT_TIMEOUT_MS) { - /* Third press — activate Doom! */ - doom_act_state = DOOM_ACT_IDLE; - LOG_DBG("Doom easter egg activated!"); - doom_game_start(); - } else { - /* Timeout — restart */ - doom_act_time = now_doom; - doom_act_state = DOOM_ACT_FIRST_PRESS; - } - break; - } - break; - } -#endif - /* fall through if doom not enabled */ - - default: - /* Other pages: no action on ENTER */ - break; - } -#endif -} - -static void action_flood_advert(void) -{ - LOG_INF("flood advert requested"); -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(MELODY_BEEP_2); -#endif - mesh_send_flood_advert(); -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - ui_pages_advert_sent(true); -#endif - schedule_render(); -} - -static void action_buzzer_toggle(void) -{ -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - bool was_quiet = buzzer_is_quiet(); - - if (was_quiet) { - /* Unmuting: enable first, then play ascending confirmation */ - buzzer_set_quiet(false); - buzzer_play(MELODY_BUZZER_ON); - } else { - /* Muting: play descending confirmation while still enabled. - * Use deferred mute so the "off" melody plays out fully - * before the quiet flag suppresses future sounds. */ - buzzer_play(MELODY_BUZZER_OFF); - buzzer_set_quiet_deferred(true); - } - /* Persist mute state across reboots */ - mesh_set_buzzer_quiet(!was_quiet); - get_state()->buzzer_quiet = !was_quiet; - LOG_INF("buzzer %s", buzzer_is_quiet() ? "muted" : "unmuted"); -#endif - schedule_render(); -} - -static void action_leds_toggle(void) -{ - struct ui_state *s = get_state(); - bool new_disabled = !s->leds_disabled; - - s->leds_disabled = new_disabled; - ui_set_heartbeat_led(!new_disabled); - mesh_set_leds_disabled(new_disabled); -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(new_disabled ? MELODY_LED_OFF : MELODY_LED_ON); -#endif - LOG_INF("LEDs %s (user toggle)", new_disabled ? "disabled" : "enabled"); - schedule_render(); -} - -static void action_gps_toggle(void) -{ - if (!gps_is_available()) { - LOG_WRN("GPS toggle ignored — no GPS hardware"); - return; - } - - bool now_enabled = !gps_is_enabled(); - - LOG_INF("GPS toggle → %s", now_enabled ? "on" : "off"); -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(now_enabled ? MELODY_GPS_ON : MELODY_GPS_OFF); -#endif - /* Use persistent wrapper — same path as BLE CMD_SET_CUSTOM_VAR "gps" */ - mesh_gps_set_enabled(now_enabled); - schedule_render(); -} - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY -static void action_ble_toggle(void) -{ - struct ui_state *s = get_state(); - bool now_enabled = !s->ble_enabled; - - LOG_INF("BLE toggle → %s", now_enabled ? "on" : "off"); - s->ble_enabled = now_enabled; - mesh_ble_set_enabled(now_enabled); - schedule_render(); -} - -static void action_enter_dfu(void) -{ - LOG_INF("entering BLE DFU bootloader"); - -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(MELODY_SHUTDOWN); - while (buzzer_is_playing()) { - k_sleep(K_MSEC(50)); - } - buzzer_stop(); -#endif - - mc_display_clear(); - mc_display_text(16, 28, "BLE DFU...", false); - mc_display_finalize(); - k_sleep(K_MSEC(500)); - mc_display_off(); - - /* Delegate to board adapter — handles GPREGRET + reset for any platform */ - mesh_reboot_to_ota_dfu(); - CODE_UNREACHABLE; -} -#endif /* CONFIG_ZEPHCORE_UI_DISPLAY */ - -static void action_deep_sleep(void) -{ -#ifdef CONFIG_POWEROFF - LOG_INF("deep sleep: shutting down..."); - - /* 1. Stop LED heartbeat and msg indicator */ -#if HAS_HEARTBEAT_LED - k_work_cancel_delayable(&led_on_work); - k_work_cancel_delayable(&led_off_work); - gpio_pin_set_dt(&heartbeat_led, 0); -#endif -#if HAS_MSG_LED - gpio_pin_set_dt(&msg_led, 0); -#endif - - /* 2. Play shutdown melody (blocking wait) */ -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(MELODY_SHUTDOWN); - while (buzzer_is_playing()) { - k_sleep(K_MSEC(50)); - } - buzzer_stop(); -#endif - - /* 3. Turn display off */ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - mc_display_off(); -#endif - - /* 4. Drive power-hungry enable pins LOW. - * - * nRF52840 GPIO output latches persist across System OFF and reset. - * If GPS_EN is HIGH, the GPS module stays powered during "sleep." - * Drive known power-enable GPIOs LOW via Zephyr's safe GPIO API. - * - * DO NOT touch BLE (bt_conn_disconnect / bt_le_adv_stop) — that - * corrupts BLE controller state and prevents clean reboot. - * DO NOT blank all GPIOs — that bricked the device previously. */ - gps_power_off_for_shutdown(); - mesh_disable_power_regulators(); - - /* 5. Hold the LoRa radio in hardware reset. - * - * sys_poweroff() bypasses device PM — the SX126x hardware duty cycle - * would otherwise keep cycling autonomously (drawing mA) while the - * SoC is in System OFF (~1µA). Drive RESET low; nRF52 GPIO output - * latches persist across System OFF so the chip stays in reset (0µA). - * On wakeup, the driver's sx126x_init() re-asserts then releases reset. */ -#if DT_NODE_EXISTS(DT_ALIAS(lora0)) && DT_NODE_HAS_PROP(DT_ALIAS(lora0), reset_gpios) - { - static const struct gpio_dt_spec lora_reset = - GPIO_DT_SPEC_GET(DT_ALIAS(lora0), reset_gpios); - gpio_pin_configure_dt(&lora_reset, GPIO_OUTPUT_ACTIVE); - } -#endif - - /* 6. Configure GPIO SENSE for button wakeup, then enter System OFF. - * - * The nRF GPIO driver does not implement GPIO_INT_WAKEUP, so the - * wakeup-source DTS property has no effect on nRF52. We must set - * SENSE bits directly via the nRF HAL. sys_poweroff() goes straight - * to nrf_power_system_off() with no device PM suspend, so these bits - * persist into System OFF and trigger a reset on next button press. - * - * Wait for button release first: if we enter System OFF while the - * button is still held (long-press shutdown), DETECT is already - * asserted and the chip cannot enter System OFF cleanly. */ -#if defined(CONFIG_SOC_FAMILY_NORDIC_NRF) && DT_NODE_EXISTS(DT_ALIAS(sw0)) - { -#define _SW0_NODE DT_ALIAS(sw0) -#define _SW0_PORT DT_PROP(DT_GPIO_CTLR(_SW0_NODE, gpios), port) -#define _SW0_PIN DT_GPIO_PIN(_SW0_NODE, gpios) -#define _SW0_FLAGS DT_GPIO_FLAGS(_SW0_NODE, gpios) - - static const struct gpio_dt_spec sw0 = - GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios); - gpio_pin_configure_dt(&sw0, GPIO_INPUT); - - int64_t deadline = k_uptime_get() + 5000; - while (gpio_pin_get_dt(&sw0) && k_uptime_get() < deadline) { - k_sleep(K_MSEC(10)); - } - - nrf_gpio_cfg_sense_input( - NRF_GPIO_PIN_MAP(_SW0_PORT, _SW0_PIN), - (_SW0_FLAGS & GPIO_PULL_UP) ? NRF_GPIO_PIN_PULLUP : - (_SW0_FLAGS & GPIO_PULL_DOWN) ? NRF_GPIO_PIN_PULLDOWN : - NRF_GPIO_PIN_NOPULL, - (_SW0_FLAGS & GPIO_ACTIVE_LOW) ? NRF_GPIO_PIN_SENSE_LOW - : NRF_GPIO_PIN_SENSE_HIGH); -#undef _SW0_NODE -#undef _SW0_PORT -#undef _SW0_PIN -#undef _SW0_FLAGS - } -#endif /* CONFIG_SOC_FAMILY_NORDIC_NRF && sw0 */ - - LOG_INF("deep sleep: entering System OFF"); - sys_poweroff(); - CODE_UNREACHABLE; -#else - LOG_WRN("deep sleep: CONFIG_POWEROFF not enabled"); -#endif -} - -/* ========== Input Event Handler ========== */ - -static void ui_input_cb(struct input_event *evt, void *user_data) -{ - ARG_UNUSED(user_data); - - if (evt->type != INPUT_EV_KEY) { - return; - } - -#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM - /* When Doom is running, intercept ALL input (presses AND releases) */ - if (doom_game_is_running()) { - /* Double-click ENTER to exit: detect two presses within 500ms */ - if (evt->code == INPUT_KEY_ENTER && evt->value) { - static uint32_t doom_last_enter; - uint32_t now = k_uptime_get_32(); - - if (doom_last_enter != 0 && - (now - doom_last_enter) <= 500) { - /* Double-click — exit Doom */ - doom_last_enter = 0; - doom_game_stop(); - schedule_render(); - return; - } - doom_last_enter = now; - } - - /* Forward all key events (press + release) to Doom */ - doom_game_input(evt->code, evt->value); - return; - } -#endif - - /* GPS hardware switch (toggle switch, not momentary button). - * Needs both press (ON) and release (OFF) events, - * so handle before the release-event filter below. */ - if (evt->code == INPUT_KEY_G) { - bool gps_on = (evt->value != 0); - LOG_INF("GPS switch → %s", gps_on ? "on" : "off"); - if (gps_is_available()) { - mesh_gps_set_enabled(gps_on); -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(gps_on ? MELODY_GPS_ON : MELODY_GPS_OFF); -#endif - schedule_render(); - } - return; - } - - /* Only handle key press events (value=1), not releases (value=0) */ - if (!evt->value) { - return; - } - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - /* If display is off, wake it and consume the event */ - if (!mc_display_is_on()) { - mc_display_on(); - schedule_render(); - return; - } - - /* Reset auto-off timer on any button press */ - mc_display_reset_auto_off(); -#endif - - /* Dismiss splash screen on any button press */ - if (splash_active) { - k_work_cancel_delayable(&splash_work); - splash_active = false; -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - mc_display_epd_full_reset(); - -#ifdef ZEPHCORE_REPEATER - ui_pages_set(UI_PAGE_STATUS); -#else - ui_pages_set(UI_PAGE_MESSAGES); -#endif -#endif - schedule_render(); - return; - } - - /* Map input key codes to UI actions. - * - * RAK4631 / WisMesh Pocket / Heltec V3–V4.3: 1 tap KEY_1, 2 taps KEY_LEFT; - * long press KEY_ENTER (page enter). Other boards: up to 4–5 tap codes - * (KEY_B/D/C/E) and KEY_POWER or KEY_F long → deep sleep. - * KEY_RIGHT/LEFT/ENTER/UP/DOWN: joystick (Wio Tracker) - * - * NOTE: KEY_A (raw short-press from longpress filter) is NOT handled - * here. It feeds into the multi-tap filter which emits the tap codes. - * Since INPUT_CALLBACK_DEFINE(NULL) sees events from all devices, - * the raw KEY_A events fall through to default: break. - */ - switch (evt->code) { - /* ===== Multi-tap outputs ===== */ - case INPUT_KEY_1: - /* Single tap (400ms delayed): page next */ - action_page_next(); - break; - - case INPUT_KEY_B: - /* Double tap (400ms delayed): flood advert */ - action_flood_advert(); - break; - - case INPUT_KEY_D: - /* Triple tap (400ms delayed): toggle buzzer mute */ - action_buzzer_toggle(); - break; - - case INPUT_KEY_C: - /* Quadruple tap (400ms delayed): toggle GPS */ - action_gps_toggle(); - break; - - case INPUT_KEY_E: - /* Quintuple tap (immediate): toggle LED heartbeat */ - action_leds_toggle(); - break; - - /* ===== Longpress output ===== */ - case INPUT_KEY_POWER: - case INPUT_KEY_F: - /* Long press (≥1s): deep sleep */ - action_deep_sleep(); - break; - - /* ===== Joystick (Wio Tracker) ===== */ - case INPUT_KEY_RIGHT: - action_page_next(); - break; - - case INPUT_KEY_LEFT: - action_page_prev(); - break; - - case INPUT_KEY_ENTER: - action_page_enter(); - break; - - case INPUT_KEY_UP: - case INPUT_KEY_DOWN: - /* Joystick up/down - scroll within page (future) */ - break; - - default: - break; - } -} - -/* Register input callback for ALL devices (no specific device filter) */ -INPUT_CALLBACK_DEFINE(NULL, ui_input_cb, NULL); - -/* ========== Public API ========== */ - -int ui_init(void) -{ - int ret; - - k_work_init_delayable(&render_work, render_work_handler); - k_work_init_delayable(&splash_work, splash_work_handler); - k_work_init_delayable(&advert_defer_work, advert_defer_handler); - - /* Initialize buzzer (optional - may not be present) */ -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - ret = buzzer_init(); - if (ret == 0) { - LOG_INF("buzzer ready"); - } else if (ret == -ENODEV) { - LOG_INF("no buzzer hardware"); - } else { - LOG_WRN("buzzer init failed: %d", ret); - } -#endif - - /* Initialize display (optional - may not be present) */ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - ret = mc_display_init(); - if (ret == 0) { - LOG_INF("display ready"); - - /* Show splash screen */ - splash_active = true; - ui_pages_render_splash(); - - /* Start auto-off timer so display sleeps after timeout */ - mc_display_reset_auto_off(); - - /* Schedule transition to home page */ - k_work_reschedule(&splash_work, K_MSEC(SPLASH_DURATION_MS)); - } else if (ret == -ENODEV) { - LOG_INF("no display hardware"); - } else { - LOG_WRN("display init failed: %d", ret); - } -#endif - - /* Initialize LED heartbeat — only on boards WITHOUT a display. - * If there's an OLED, the heartbeat LED is redundant and wastes power. */ -#if HAS_HEARTBEAT_LED - if (gpio_is_ready_dt(&heartbeat_led)) { - gpio_pin_configure_dt(&heartbeat_led, GPIO_OUTPUT_INACTIVE); - k_work_init_delayable(&led_on_work, led_on_work_handler); - k_work_init_delayable(&led_off_work, led_off_work_handler); - /* Start heartbeat cycle */ - k_work_reschedule(&led_on_work, K_NO_WAIT); - LOG_INF("LED heartbeat started"); - } -#endif -#if HAS_MSG_LED - if (gpio_is_ready_dt(&msg_led)) { - gpio_pin_configure_dt(&msg_led, GPIO_OUTPUT_INACTIVE); - LOG_INF("msg LED ready"); - } -#endif - - /* NOTE: startup chime is NOT played here. It's played from main() - * after loadPrefs() so we can respect the persisted buzzer_quiet setting. - * See ui_play_startup_chime(). */ - - ui_initialized = true; - LOG_INF("UI initialized"); - - /* Suppress unused variable warning when both display and buzzer are disabled */ - (void)ret; - - return 0; -} - -void ui_play_startup_chime(void) -{ -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - if (!buzzer_is_quiet()) { - buzzer_play(MELODY_STARTUP); - } -#endif -} - -void ui_notify(enum ui_event event) -{ - if (!ui_initialized) { - return; - } - - bool is_msg_event = false; - - switch (event) { - case UI_EVENT_CONTACT_MSG: - is_msg_event = true; -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - /* Only buzz if no phone is connected to receive the message */ - if (!get_state()->ble_connected) { - buzzer_play(MELODY_MSG_CONTACT); - } -#endif - break; - - case UI_EVENT_CHANNEL_MSG: - is_msg_event = true; -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - if (!get_state()->ble_connected) { - buzzer_play(MELODY_MSG_CHANNEL); - } -#endif - break; - - case UI_EVENT_ROOM_MSG: - is_msg_event = true; -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - if (!get_state()->ble_connected) { - buzzer_play(MELODY_MSG_CHANNEL); - } -#endif - break; - - case UI_EVENT_ACK: -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - buzzer_play(MELODY_ACK); -#endif - break; - - case UI_EVENT_BLE_CONNECTED: - ui_set_ble_status(true, NULL); - break; - - case UI_EVENT_BLE_DISCONNECTED: - ui_set_ble_status(false, NULL); - break; - - default: - break; - } - - /* On message events: flash heartbeat LED immediately (if not BLE connected - * and LEDs are enabled). Cancel the current cycle, turn on now, let - * led_off_work resume the normal heartbeat after LED_ON_MSG_MS. */ -#if HAS_HEARTBEAT_LED - if (is_msg_event && !get_state()->ble_connected && !get_state()->leds_disabled - && gpio_is_ready_dt(&heartbeat_led)) { - k_work_cancel_delayable(&led_on_work); - k_work_cancel_delayable(&led_off_work); - gpio_pin_set_dt(&heartbeat_led, 1); - k_work_reschedule(&led_off_work, K_MSEC(LED_ON_MSG_MS)); - } -#endif - - /* Wake display on non-message notifications (BLE connect/disconnect etc). - * Message notifications use buzzer + LED flash instead of waking the display. */ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY -#ifndef ZEPHCORE_REPEATER - if (!is_msg_event) { - mc_display_on(); - schedule_render(); - } -#endif -#endif -} - -void ui_set_msg_count(uint16_t count) -{ - struct ui_state *s = get_state(); - - if (s->msg_count == count) { - return; - } - - s->msg_count = count; - - if (!ui_initialized) { - return; - } - - /* Always render when the display is already on (user is looking). */ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - if (mc_display_is_on()) { - schedule_render(); - return; - } -#endif - - /* EPD is bistable and readable without backlight. If the user is parked - * on the messages page, update the count silently via partial refresh - * without waking the backlight. Any other page: leave it for the next - * button press to avoid a pointless flash. */ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - if (mc_display_is_epd() && ui_pages_current() == UI_PAGE_MESSAGES) { - schedule_render(); - } -#endif -} - -void ui_set_ble_status(bool connected, const char *name) -{ - struct ui_state *s = get_state(); - - s->ble_connected = connected; - if (name) { - strncpy(s->device_name, name, sizeof(s->device_name) - 1); - s->device_name[sizeof(s->device_name) - 1] = '\0'; - } - - if (ui_initialized) { - schedule_render(); - } -} - -void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, - uint8_t cr, int8_t tx_power, int16_t noise_floor) -{ - struct ui_state *s = get_state(); - - s->lora_freq_hz = freq_hz; - s->lora_sf = sf; - s->lora_bw_khz_x10 = bw_khz_x10; - s->lora_cr = cr; - s->lora_tx_power = tx_power; - s->lora_noise_floor = noise_floor; -} - -void ui_set_gps_data(bool has_fix, uint8_t sats, - int32_t lat_mdeg, int32_t lon_mdeg, int32_t alt_mm) -{ - struct ui_state *s = get_state(); - - s->gps_has_fix = has_fix; - s->gps_satellites = sats; - s->gps_lat_mdeg = lat_mdeg; - s->gps_lon_mdeg = lon_mdeg; - s->gps_alt_mm = alt_mm; -} - -void ui_set_battery(uint16_t mv, uint8_t pct) -{ - struct ui_state *s = get_state(); - - s->battery_mv = mv; - s->battery_pct = pct; -} - -void ui_set_clock(uint32_t epoch) -{ - struct ui_state *s = get_state(); - - s->rtc_epoch = epoch; -} - -void ui_add_recent(const char *name, int16_t rssi, uint32_t age_s) -{ - struct ui_state *s = get_state(); - - /* Shift entries down if full */ - if (s->recent_count >= 4) { - memmove(&s->recent[1], &s->recent[0], sizeof(s->recent[0]) * 3); - } else { - if (s->recent_count > 0) { - memmove(&s->recent[1], &s->recent[0], - sizeof(s->recent[0]) * s->recent_count); - } - s->recent_count++; - } - - /* Add new entry at top — sanitize UTF-8 to Latin-1 for display font */ -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - utf8_to_latin1(s->recent[0].name, name, sizeof(s->recent[0].name)); -#else - strncpy(s->recent[0].name, name, sizeof(s->recent[0].name) - 1); - s->recent[0].name[sizeof(s->recent[0].name) - 1] = '\0'; -#endif - s->recent[0].rssi = rssi; - s->recent[0].age_s = age_s; -} - -void ui_clear_recent(void) -{ - struct ui_state *s = get_state(); - - s->recent_count = 0; - memset(s->recent, 0, sizeof(s->recent)); -} - -void ui_set_node_name(const char *name) -{ - struct ui_state *s = get_state(); - - if (name) { - strncpy(s->node_name, name, sizeof(s->node_name) - 1); - s->node_name[sizeof(s->node_name) - 1] = '\0'; - } -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - ui_pages_set_node_name(name); -#endif -} - -void ui_set_sensor_data(int16_t temp_c10, uint32_t pressure_pa, - uint16_t humidity_rh10, uint16_t light_lux) -{ - struct ui_state *s = get_state(); - - s->temperature_c10 = temp_c10; - s->pressure_pa = pressure_pa; - s->humidity_rh10 = humidity_rh10; - s->light_lux = light_lux; -} - -void ui_set_gps_available(bool available) -{ - struct ui_state *s = get_state(); - - s->gps_available = available; -} - -void ui_set_gps_enabled(bool enabled) -{ - struct ui_state *s = get_state(); - - s->gps_enabled = enabled; -} - -void ui_set_gps_state(uint8_t state, uint32_t last_fix_age_s, uint32_t next_search_s) -{ - struct ui_state *s = get_state(); - - s->gps_state = state; - s->gps_last_fix_age_s = last_fix_age_s; - s->gps_next_search_s = next_search_s; -} - -void ui_set_ble_enabled(bool enabled) -{ - struct ui_state *s = get_state(); - - s->ble_enabled = enabled; -} - -void ui_set_buzzer_quiet(bool quiet) -{ - struct ui_state *s = get_state(); - - s->buzzer_quiet = quiet; -} - -void ui_set_offgrid_mode(bool enabled) -{ - struct ui_state *s = get_state(); - - s->offgrid_enabled = enabled; -} - -void ui_set_leds_disabled(bool disabled) -{ - struct ui_state *s = get_state(); - - s->leds_disabled = disabled; -} - -void ui_set_heartbeat_led(bool enabled) -{ -#if HAS_HEARTBEAT_LED - if (enabled) { - if (gpio_is_ready_dt(&heartbeat_led)) { - k_work_reschedule(&led_on_work, K_NO_WAIT); - } - } else { - k_work_cancel_delayable(&led_on_work); - k_work_cancel_delayable(&led_off_work); - gpio_pin_set_dt(&heartbeat_led, 0); - } -#endif -} - -void ui_refresh_display(void) -{ - if (!ui_initialized) { - return; - } - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - /* EPD displays: skip periodic housekeeping renders. - * Each full e-paper refresh takes ~2s and causes visible flashing. - * All meaningful events (messages, BLE, GPS fix, button presses) - * already trigger renders via their own ui_set_*() → schedule_render(). - * Housekeeping just updates slow-changing data (clock, contact ages) - * which will appear on the next event-driven render. */ - if (mc_display_is_epd()) { - return; - } - - schedule_render(); -#endif -} +/* + * ZephCore - UI Task + * Copyright (c) 2025 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Integration layer that wires buttons, buzzer, and display together. + * All event-driven via Zephyr input subsystem + k_work. + * + * Input flow (after longpress + multi-tap filter chain): + * KEY_1 → action_page_next() (1 tap, 400ms delayed) + * KEY_LEFT → action_page_prev() (2 taps — RAK4631 / Pocket / Heltec V3–V4.3) + * KEY_B → action_flood_advert() (2 taps on extended multitap overlays) + * KEY_D → action_buzzer_toggle() (3 taps) + * KEY_C → action_gps_toggle() (4 taps, immediate) + * KEY_G → GPS switch on/off (hardware toggle, ThinkNode M1) + * KEY_POWER / KEY_F → action_deep_sleep() (long press — boards that emit these) + * KEY_ENTER → action_page_enter() (long press — Pocket / Heltec; joystick center Wio) + * KEY_RIGHT → action_page_next() (joystick, Wio Tracker) + * + * Notification flow: + * LoRa RX → CompanionMesh → ui_notify(UI_EVENT_CONTACT_MSG) + * → buzzer_play(MELODY_MSG_CONTACT) + * → mc_display_on() + render + */ + +#include "ui_task.h" +#include "ui_pages.h" + +#ifdef CONFIG_ZEPHCORE_UI_BUZZER +#include "buzzer.h" +#endif + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY +#include "display.h" +#endif + +#include + +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +#include "doom_game.h" +#endif +#include +#include +#include +#include + +#ifdef CONFIG_POWEROFF +#include +#endif + +#if defined(CONFIG_SOC_FAMILY_NORDIC_NRF) +#include +#endif + +#include + +/* GPS control (extern "C" in ZephyrSensorManager.h) */ +#include + +/* Mesh action wrappers (deferred to mesh event loop thread) */ +#include "ui_mesh_actions.h" + +#include +LOG_MODULE_REGISTER(ui_task, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); + +/* Tap-count feedback melodies. + * b=200, d=16 → each chirp ~75ms, rest ~75ms = clear separation. + * + * 2 taps (flood advert): chirp-chirp + * 3 taps (buzzer toggle): chirp×3 + high(ON) or low(OFF) + * 4 taps (GPS toggle): chirp×4 + high(ON) or low(OFF) + * + * ON tail: high E7 (~2637Hz) = "enabled" + * OFF tail: low G5 (~784Hz) = "disabled" */ +#define MELODY_BEEP_2 "b2:d=16,o=7,b=200:c,p,c" + +#define MELODY_BUZZER_ON "bon:d=16,o=7,b=200:c,p,c,p,c,p,p,8e" +#define MELODY_BUZZER_OFF "bof:d=16,o=7,b=200:c,p,c,p,c,p,p,8g5" + +#define MELODY_GPS_ON "gon:d=16,o=7,b=200:c,p,c,p,c,p,c,p,p,8e" +#define MELODY_GPS_OFF "gof:d=16,o=7,b=200:c,p,c,p,c,p,c,p,p,8g5" + +#define MELODY_LED_ON "lon:d=16,o=7,b=200:c,p,c,p,c,p,c,p,c,p,p,8e" +#define MELODY_LED_OFF "lof:d=16,o=7,b=200:c,p,c,p,c,p,c,p,c,p,p,8g5" + +/* ========== LED Heartbeat ========== */ +/* Match Arduino: 4s cycle, 20ms pulse (normal) or 200ms (unread messages). + * Uses led0 or led1 alias — whichever exists in the board's DTS. + * Disabled when OLED display is present (display makes LED redundant). */ +/* Heartbeat LED — subtle pulse every 4s on led0 (or led1 fallback). + * Works alongside displays; boards that want to disable it can + * remove the led0 alias or override this with a Kconfig guard. */ +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +static const struct gpio_dt_spec heartbeat_led = + GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios); +#define HAS_HEARTBEAT_LED 1 +#elif DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) +static const struct gpio_dt_spec heartbeat_led = + GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); +#define HAS_HEARTBEAT_LED 1 +#else +#define HAS_HEARTBEAT_LED 0 +#endif + +/* Second LED: unread pulse (companion only). Repeaters use led1 for LoRa TX via + * lora-tx-led alias — no offline queue, so ui_task leaves led1 alone. */ +#if HAS_HEARTBEAT_LED && DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && \ + DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) && !defined(ZEPHCORE_REPEATER) +static const struct gpio_dt_spec msg_led = + GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); +#define HAS_MSG_LED 1 +#else +#define HAS_MSG_LED 0 +#endif + +#define LED_CYCLE_MS 4000 /* Total heartbeat period */ +#define LED_ON_MS 20 /* Normal pulse width */ +#define LED_ON_MSG_MS 200 /* Pulse width when unread messages */ + +/* ========== Deep Sleep / System OFF ========== */ +/* On nRF52840, sys_poweroff() = System OFF (~1µA). + * Wake via reset button → full chip reset → boots fresh. */ + +/* When display is not available, maintain a local ui_state for setters + * (data can still be used for logging or future features). */ +#ifndef CONFIG_ZEPHCORE_UI_DISPLAY +static struct ui_state local_ui_state; +#endif + +/* ========== Constants ========== */ +#define SPLASH_DURATION_MS 3000 +#define RENDER_DEBOUNCE_MS 50 +#define RENDER_DEBOUNCE_EPD_MS 200 /* e-paper: coalesce rapid state updates, then refresh */ + +/* ========== State ========== */ +static bool ui_initialized; +static bool splash_active; + +/* ========== Doom Easter Egg Activation ========== */ +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +/* + * Activation sequence: double-click + single-click INPUT_KEY_ENTER on page 0. + * That's 3 presses total: press-press (double) then press (single). + * Deactivation: double-click INPUT_KEY_ENTER while Doom is running. + * + * State machine: + * IDLE → (1st press) → FIRST_PRESS + * FIRST_PRESS → (2nd press within 500ms) → DCLICK_DONE + * DCLICK_DONE → (3rd press within 500ms) → activate Doom + * Any state → (timeout) → IDLE + */ +enum doom_activate_state { + DOOM_ACT_IDLE, + DOOM_ACT_FIRST_PRESS, + DOOM_ACT_DCLICK_DONE, +}; + +static enum doom_activate_state doom_act_state; +static uint32_t doom_act_time; +#define DOOM_ACT_TIMEOUT_MS 500 +#endif + +static inline struct ui_state *get_state(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + return ui_pages_get_state(); +#else + return &local_ui_state; +#endif +} + +/* Render work - debounced display update */ +static struct k_work_delayable render_work; +/* Splash timeout work */ +static struct k_work_delayable splash_work; +/* Deferred zero-hop advert — waits for possible double-press upgrade to flood */ +static struct k_work_delayable advert_defer_work; + +/* LED heartbeat only on boards with LED but no OLED (display makes it redundant) */ +#if HAS_HEARTBEAT_LED +/* LED heartbeat: two one-shot works form a self-rescheduling cycle. + * led_on_work turns LED on and schedules led_off_work after pulse width. + * led_off_work turns LED off and schedules led_on_work after remainder. */ +static struct k_work_delayable led_on_work; +static struct k_work_delayable led_off_work; +#endif + +/* ========== Work Handlers ========== */ + +#if HAS_HEARTBEAT_LED +static void led_off_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + gpio_pin_set_dt(&heartbeat_led, 0); +#if HAS_MSG_LED + gpio_pin_set_dt(&msg_led, 0); +#endif + + /* Schedule next ON after remainder of cycle */ + struct ui_state *s = get_state(); + uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; + + k_work_reschedule(&led_on_work, K_MSEC(LED_CYCLE_MS - on_ms)); +} + +static void led_on_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + gpio_pin_set_dt(&heartbeat_led, 1); +#if HAS_MSG_LED + struct ui_state *s = get_state(); + if (s->msg_count > 0) { + gpio_pin_set_dt(&msg_led, 1); + } + uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; +#else + struct ui_state *s = get_state(); + uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; +#endif + + k_work_reschedule(&led_off_work, K_MSEC(on_ms)); +} +#endif + +static void render_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + + if (!ui_initialized) { + return; + } + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + /* EPD: render even when backlight is off — content is always visible. + * OLED: only render when display is on (screen is black when off). */ + if ((mc_display_is_on() || mc_display_is_epd()) && !splash_active) { + ui_pages_render(); + } +#endif +} + +static void splash_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + splash_active = false; + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + mc_display_epd_full_reset(); + + /* Transition from splash to home page (first active page for this role) */ +#ifdef ZEPHCORE_REPEATER + ui_pages_set(UI_PAGE_STATUS); +#else + ui_pages_set(UI_PAGE_MESSAGES); +#endif + k_work_reschedule(&render_work, K_NO_WAIT); +#endif +} + +static void schedule_render(void); + +static void advert_defer_handler(struct k_work *work) +{ + ARG_UNUSED(work); + /* Timer expired without second press — send zero-hop */ + LOG_INF("zero-hop advert requested (deferred)"); +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(MELODY_BEEP_2); +#endif + mesh_send_zerohop_advert(); +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + ui_pages_advert_sent(false); +#endif + schedule_render(); +} + +static void schedule_render(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + uint32_t debounce = mc_display_is_epd() ? RENDER_DEBOUNCE_EPD_MS + : RENDER_DEBOUNCE_MS; + k_work_reschedule(&render_work, K_MSEC(debounce)); +#endif +} + +/* ========== Button Action Functions ========== */ +/* Each action checks capabilities internally — no #ifdef in the switch. */ + +static void action_page_next(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + ui_pages_next(); +#endif + schedule_render(); +} + +static void action_page_prev(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + ui_pages_prev(); +#endif + schedule_render(); +} + +/* Forward declarations for page-enter dispatch */ +static void action_flood_advert(void); +static void action_gps_toggle(void); +static void action_buzzer_toggle(void); +static void action_leds_toggle(void); +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY +static void action_ble_toggle(void); +static void action_enter_dfu(void); +#endif +static void action_deep_sleep(void); + +static void action_page_enter(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + enum ui_page page = ui_pages_current(); + + LOG_DBG("ENTER on page %d", page); + + switch (page) { + case UI_PAGE_BLUETOOTH: + /* Toggle BLE on/off */ + action_ble_toggle(); + break; + + case UI_PAGE_ADVERT: + /* Single press: defer 500ms then send zero-hop. + * Double press within 500ms: cancel deferred, send flood. */ + if (k_work_delayable_is_pending(&advert_defer_work)) { + /* Second press — cancel deferred zero-hop, send flood */ + k_work_cancel_delayable(&advert_defer_work); + action_flood_advert(); + } else { + /* First press — start deferred zero-hop */ + k_work_reschedule(&advert_defer_work, K_MSEC(500)); + } + break; + + case UI_PAGE_GPS: + /* Toggle GPS (same as quad-tap) */ + action_gps_toggle(); + break; + + case UI_PAGE_BUZZER: + /* Toggle buzzer mute (same as triple-tap) */ + action_buzzer_toggle(); + break; + + case UI_PAGE_LEDS: + /* Toggle LED on/off */ + action_leds_toggle(); + break; + + case UI_PAGE_OFFGRID: { + /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ + struct ui_state *st_og = get_state(); + uint32_t now_og = k_uptime_get_32(); + + if (st_og->offgrid_confirm_time != 0 && + (now_og - st_og->offgrid_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + /* Confirmed — toggle offgrid mode */ + bool new_state = !st_og->offgrid_enabled; + st_og->offgrid_enabled = new_state; + st_og->offgrid_confirm_time = 0; + mesh_set_offgrid_mode(new_state); + LOG_INF("offgrid mode %s (button)", new_state ? "on" : "off"); + } else { + /* First press — enter confirmation state */ + st_og->offgrid_confirm_time = now_og; + } + schedule_render(); + break; + } + + case UI_PAGE_DFU: { + /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ + struct ui_state *st_dfu = get_state(); + uint32_t now_dfu = k_uptime_get_32(); + + if (st_dfu->dfu_confirm_time != 0 && + (now_dfu - st_dfu->dfu_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + /* Confirmed — reboot into BLE DFU */ + action_enter_dfu(); + } else { + /* First press — enter confirmation state */ + st_dfu->dfu_confirm_time = now_dfu; + schedule_render(); + } + break; + } + + case UI_PAGE_SHUTDOWN: { + /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ + struct ui_state *st = get_state(); + uint32_t now = k_uptime_get_32(); + + if (st->shutdown_confirm_time != 0 && + (now - st->shutdown_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + /* Confirmed — shut down */ + action_deep_sleep(); + } else { + /* First press — enter confirmation state */ + st->shutdown_confirm_time = now; + schedule_render(); + } + break; + } + + case UI_PAGE_MESSAGES: +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + { + /* Doom activation: 3 presses — double-click + single-click. + * Press 1 → FIRST_PRESS + * Press 2 (within 500ms) → DCLICK_DONE + * Press 3 (within 500ms) → activate Doom */ + uint32_t now_doom = k_uptime_get_32(); + + switch (doom_act_state) { + case DOOM_ACT_IDLE: + doom_act_time = now_doom; + doom_act_state = DOOM_ACT_FIRST_PRESS; + break; + case DOOM_ACT_FIRST_PRESS: + if ((now_doom - doom_act_time) <= DOOM_ACT_TIMEOUT_MS) { + /* Double-click detected, wait for single */ + doom_act_time = now_doom; + doom_act_state = DOOM_ACT_DCLICK_DONE; + } else { + /* Timeout — restart as new first press */ + doom_act_time = now_doom; + doom_act_state = DOOM_ACT_FIRST_PRESS; + } + break; + case DOOM_ACT_DCLICK_DONE: + if ((now_doom - doom_act_time) <= DOOM_ACT_TIMEOUT_MS) { + /* Third press — activate Doom! */ + doom_act_state = DOOM_ACT_IDLE; + LOG_DBG("Doom easter egg activated!"); + doom_game_start(); + } else { + /* Timeout — restart */ + doom_act_time = now_doom; + doom_act_state = DOOM_ACT_FIRST_PRESS; + } + break; + } + break; + } +#endif + /* fall through if doom not enabled */ + + default: + /* Other pages: no action on ENTER */ + break; + } +#endif +} + +static void action_flood_advert(void) +{ + LOG_INF("flood advert requested"); +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(MELODY_BEEP_2); +#endif + mesh_send_flood_advert(); +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + ui_pages_advert_sent(true); +#endif + schedule_render(); +} + +static void action_buzzer_toggle(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + bool was_quiet = buzzer_is_quiet(); + + if (was_quiet) { + /* Unmuting: enable first, then play ascending confirmation */ + buzzer_set_quiet(false); + buzzer_play(MELODY_BUZZER_ON); + } else { + /* Muting: play descending confirmation while still enabled. + * Use deferred mute so the "off" melody plays out fully + * before the quiet flag suppresses future sounds. */ + buzzer_play(MELODY_BUZZER_OFF); + buzzer_set_quiet_deferred(true); + } + /* Persist mute state across reboots */ + mesh_set_buzzer_quiet(!was_quiet); + get_state()->buzzer_quiet = !was_quiet; + LOG_INF("buzzer %s", buzzer_is_quiet() ? "muted" : "unmuted"); +#endif + schedule_render(); +} + +static void action_leds_toggle(void) +{ + struct ui_state *s = get_state(); + bool new_disabled = !s->leds_disabled; + + s->leds_disabled = new_disabled; + ui_set_heartbeat_led(!new_disabled); + mesh_set_leds_disabled(new_disabled); +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(new_disabled ? MELODY_LED_OFF : MELODY_LED_ON); +#endif + LOG_INF("LEDs %s (user toggle)", new_disabled ? "disabled" : "enabled"); + schedule_render(); +} + +static void action_gps_toggle(void) +{ + if (!gps_is_available()) { + LOG_WRN("GPS toggle ignored — no GPS hardware"); + return; + } + + bool now_enabled = !gps_is_enabled(); + + LOG_INF("GPS toggle → %s", now_enabled ? "on" : "off"); +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(now_enabled ? MELODY_GPS_ON : MELODY_GPS_OFF); +#endif + /* Use persistent wrapper — same path as BLE CMD_SET_CUSTOM_VAR "gps" */ + mesh_gps_set_enabled(now_enabled); + schedule_render(); +} + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY +static void action_ble_toggle(void) +{ + struct ui_state *s = get_state(); + bool now_enabled = !s->ble_enabled; + + LOG_INF("BLE toggle → %s", now_enabled ? "on" : "off"); + s->ble_enabled = now_enabled; + mesh_ble_set_enabled(now_enabled); + schedule_render(); +} + +static void action_enter_dfu(void) +{ + LOG_INF("entering BLE DFU bootloader"); + +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(MELODY_SHUTDOWN); + while (buzzer_is_playing()) { + k_sleep(K_MSEC(50)); + } + buzzer_stop(); +#endif + + mc_display_clear(); + mc_display_text(16, 28, "BLE DFU...", false); + mc_display_finalize(); + k_sleep(K_MSEC(500)); + mc_display_off(); + + /* Delegate to board adapter — handles GPREGRET + reset for any platform */ + mesh_reboot_to_ota_dfu(); + CODE_UNREACHABLE; +} +#endif /* CONFIG_ZEPHCORE_UI_DISPLAY */ + +static void action_deep_sleep(void) +{ +#ifdef CONFIG_POWEROFF + LOG_INF("deep sleep: shutting down..."); + + /* 1. Stop LED heartbeat and msg indicator */ +#if HAS_HEARTBEAT_LED + k_work_cancel_delayable(&led_on_work); + k_work_cancel_delayable(&led_off_work); + gpio_pin_set_dt(&heartbeat_led, 0); +#endif +#if HAS_MSG_LED + gpio_pin_set_dt(&msg_led, 0); +#endif + + /* 2. Play shutdown melody (blocking wait) */ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(MELODY_SHUTDOWN); + while (buzzer_is_playing()) { + k_sleep(K_MSEC(50)); + } + buzzer_stop(); +#endif + + /* 3. Turn display off */ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + mc_display_off(); +#endif + + /* 4. Drive power-hungry enable pins LOW. + * + * nRF52840 GPIO output latches persist across System OFF and reset. + * If GPS_EN is HIGH, the GPS module stays powered during "sleep." + * Drive known power-enable GPIOs LOW via Zephyr's safe GPIO API. + * + * DO NOT touch BLE (bt_conn_disconnect / bt_le_adv_stop) — that + * corrupts BLE controller state and prevents clean reboot. + * DO NOT blank all GPIOs — that bricked the device previously. */ + gps_power_off_for_shutdown(); + mesh_disable_power_regulators(); + + /* 5. Hold the LoRa radio in hardware reset. + * + * sys_poweroff() bypasses device PM — the SX126x hardware duty cycle + * would otherwise keep cycling autonomously (drawing mA) while the + * SoC is in System OFF (~1µA). Drive RESET low; nRF52 GPIO output + * latches persist across System OFF so the chip stays in reset (0µA). + * On wakeup, the driver's sx126x_init() re-asserts then releases reset. */ +#if DT_NODE_EXISTS(DT_ALIAS(lora0)) && DT_NODE_HAS_PROP(DT_ALIAS(lora0), reset_gpios) + { + static const struct gpio_dt_spec lora_reset = + GPIO_DT_SPEC_GET(DT_ALIAS(lora0), reset_gpios); + gpio_pin_configure_dt(&lora_reset, GPIO_OUTPUT_ACTIVE); + } +#endif + + /* 6. Configure GPIO SENSE for button wakeup, then enter System OFF. + * + * The nRF GPIO driver does not implement GPIO_INT_WAKEUP, so the + * wakeup-source DTS property has no effect on nRF52. We must set + * SENSE bits directly via the nRF HAL. sys_poweroff() goes straight + * to nrf_power_system_off() with no device PM suspend, so these bits + * persist into System OFF and trigger a reset on next button press. + * + * Wait for button release first: if we enter System OFF while the + * button is still held (long-press shutdown), DETECT is already + * asserted and the chip cannot enter System OFF cleanly. */ +#if defined(CONFIG_SOC_FAMILY_NORDIC_NRF) && DT_NODE_EXISTS(DT_ALIAS(sw0)) + { +#define _SW0_NODE DT_ALIAS(sw0) +#define _SW0_PORT DT_PROP(DT_GPIO_CTLR(_SW0_NODE, gpios), port) +#define _SW0_PIN DT_GPIO_PIN(_SW0_NODE, gpios) +#define _SW0_FLAGS DT_GPIO_FLAGS(_SW0_NODE, gpios) + + static const struct gpio_dt_spec sw0 = + GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios); + gpio_pin_configure_dt(&sw0, GPIO_INPUT); + + int64_t deadline = k_uptime_get() + 5000; + while (gpio_pin_get_dt(&sw0) && k_uptime_get() < deadline) { + k_sleep(K_MSEC(10)); + } + + nrf_gpio_cfg_sense_input( + NRF_GPIO_PIN_MAP(_SW0_PORT, _SW0_PIN), + (_SW0_FLAGS & GPIO_PULL_UP) ? NRF_GPIO_PIN_PULLUP : + (_SW0_FLAGS & GPIO_PULL_DOWN) ? NRF_GPIO_PIN_PULLDOWN : + NRF_GPIO_PIN_NOPULL, + (_SW0_FLAGS & GPIO_ACTIVE_LOW) ? NRF_GPIO_PIN_SENSE_LOW + : NRF_GPIO_PIN_SENSE_HIGH); +#undef _SW0_NODE +#undef _SW0_PORT +#undef _SW0_PIN +#undef _SW0_FLAGS + } +#endif /* CONFIG_SOC_FAMILY_NORDIC_NRF && sw0 */ + + LOG_INF("deep sleep: entering System OFF"); + sys_poweroff(); + CODE_UNREACHABLE; +#else + LOG_WRN("deep sleep: CONFIG_POWEROFF not enabled"); +#endif +} + +/* ========== Input Event Handler ========== */ + +static void ui_input_cb(struct input_event *evt, void *user_data) +{ + ARG_UNUSED(user_data); + + if (evt->type != INPUT_EV_KEY) { + return; + } + +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + /* When Doom is running, intercept ALL input (presses AND releases) */ + if (doom_game_is_running()) { + /* Double-click ENTER to exit: detect two presses within 500ms */ + if (evt->code == INPUT_KEY_ENTER && evt->value) { + static uint32_t doom_last_enter; + uint32_t now = k_uptime_get_32(); + + if (doom_last_enter != 0 && + (now - doom_last_enter) <= 500) { + /* Double-click — exit Doom */ + doom_last_enter = 0; + doom_game_stop(); + schedule_render(); + return; + } + doom_last_enter = now; + } + + /* Forward all key events (press + release) to Doom */ + doom_game_input(evt->code, evt->value); + return; + } +#endif + + /* GPS hardware switch (toggle switch, not momentary button). + * Needs both press (ON) and release (OFF) events, + * so handle before the release-event filter below. */ + if (evt->code == INPUT_KEY_G) { + bool gps_on = (evt->value != 0); + LOG_INF("GPS switch → %s", gps_on ? "on" : "off"); + if (gps_is_available()) { + mesh_gps_set_enabled(gps_on); +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(gps_on ? MELODY_GPS_ON : MELODY_GPS_OFF); +#endif + schedule_render(); + } + return; + } + + /* Only handle key press events (value=1), not releases (value=0) */ + if (!evt->value) { + return; + } + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + /* If display is off, wake it and consume the event */ + if (!mc_display_is_on()) { + mc_display_on(); + schedule_render(); + return; + } + + /* Reset auto-off timer on any button press */ + mc_display_reset_auto_off(); +#endif + + /* Dismiss splash screen on any button press */ + if (splash_active) { + k_work_cancel_delayable(&splash_work); + splash_active = false; +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + mc_display_epd_full_reset(); + +#ifdef ZEPHCORE_REPEATER + ui_pages_set(UI_PAGE_STATUS); +#else + ui_pages_set(UI_PAGE_MESSAGES); +#endif +#endif + schedule_render(); + return; + } + + /* Map input key codes to UI actions. + * + * RAK4631 / WisMesh Pocket / Heltec V3–V4.3: 1 tap KEY_1, 2 taps KEY_LEFT; + * long press KEY_ENTER (page enter). Other boards: up to 4–5 tap codes + * (KEY_B/D/C/E) and KEY_POWER or KEY_F long → deep sleep. + * KEY_RIGHT/LEFT/ENTER/UP/DOWN: joystick (Wio Tracker) + * + * NOTE: KEY_A (raw short-press from longpress filter) is NOT handled + * here. It feeds into the multi-tap filter which emits the tap codes. + * Since INPUT_CALLBACK_DEFINE(NULL) sees events from all devices, + * the raw KEY_A events fall through to default: break. + */ + switch (evt->code) { + /* ===== Multi-tap outputs ===== */ + case INPUT_KEY_1: + /* Single tap (400ms delayed): page next */ + action_page_next(); + break; + + case INPUT_KEY_B: + /* Double tap (400ms delayed): flood advert */ + action_flood_advert(); + break; + + case INPUT_KEY_D: + /* Triple tap (400ms delayed): toggle buzzer mute */ + action_buzzer_toggle(); + break; + + case INPUT_KEY_C: + /* Quadruple tap (400ms delayed): toggle GPS */ + action_gps_toggle(); + break; + + case INPUT_KEY_E: + /* Quintuple tap (immediate): toggle LED heartbeat */ + action_leds_toggle(); + break; + + /* ===== Longpress output ===== */ + case INPUT_KEY_POWER: + case INPUT_KEY_F: + /* Long press (≥1s): deep sleep */ + action_deep_sleep(); + break; + + /* ===== Joystick (Wio Tracker) ===== */ + case INPUT_KEY_RIGHT: + action_page_next(); + break; + + case INPUT_KEY_LEFT: + action_page_prev(); + break; + + case INPUT_KEY_ENTER: + action_page_enter(); + break; + + case INPUT_KEY_UP: + case INPUT_KEY_DOWN: + /* Joystick up/down - scroll within page (future) */ + break; + + default: + break; + } +} + +/* Register input callback for ALL devices (no specific device filter) */ +INPUT_CALLBACK_DEFINE(NULL, ui_input_cb, NULL); + +/* ========== Public API ========== */ + +int ui_init(void) +{ + int ret; + + k_work_init_delayable(&render_work, render_work_handler); + k_work_init_delayable(&splash_work, splash_work_handler); + k_work_init_delayable(&advert_defer_work, advert_defer_handler); + + /* Initialize buzzer (optional - may not be present) */ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + ret = buzzer_init(); + if (ret == 0) { + LOG_INF("buzzer ready"); + } else if (ret == -ENODEV) { + LOG_INF("no buzzer hardware"); + } else { + LOG_WRN("buzzer init failed: %d", ret); + } +#endif + + /* Initialize display (optional - may not be present) */ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + ret = mc_display_init(); + if (ret == 0) { + LOG_INF("display ready"); + + /* Show splash screen */ + splash_active = true; + ui_pages_render_splash(); + + /* Start auto-off timer so display sleeps after timeout */ + mc_display_reset_auto_off(); + + /* Schedule transition to home page */ + k_work_reschedule(&splash_work, K_MSEC(SPLASH_DURATION_MS)); + } else if (ret == -ENODEV) { + LOG_INF("no display hardware"); + } else { + LOG_WRN("display init failed: %d", ret); + } +#endif + + /* Initialize LED heartbeat — only on boards WITHOUT a display. + * If there's an OLED, the heartbeat LED is redundant and wastes power. */ +#if HAS_HEARTBEAT_LED + if (gpio_is_ready_dt(&heartbeat_led)) { + gpio_pin_configure_dt(&heartbeat_led, GPIO_OUTPUT_INACTIVE); + k_work_init_delayable(&led_on_work, led_on_work_handler); + k_work_init_delayable(&led_off_work, led_off_work_handler); + /* Start heartbeat cycle */ + k_work_reschedule(&led_on_work, K_NO_WAIT); + LOG_INF("LED heartbeat started"); + } +#endif +#if HAS_MSG_LED + if (gpio_is_ready_dt(&msg_led)) { + gpio_pin_configure_dt(&msg_led, GPIO_OUTPUT_INACTIVE); + LOG_INF("msg LED ready"); + } +#endif + + /* NOTE: startup chime is NOT played here. It's played from main() + * after loadPrefs() so we can respect the persisted buzzer_quiet setting. + * See ui_play_startup_chime(). */ + + ui_initialized = true; + LOG_INF("UI initialized"); + + /* Suppress unused variable warning when both display and buzzer are disabled */ + (void)ret; + + return 0; +} + +void ui_play_startup_chime(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + if (!buzzer_is_quiet()) { + buzzer_play(MELODY_STARTUP); + } +#endif +} + +void ui_notify(enum ui_event event) +{ + if (!ui_initialized) { + return; + } + + bool is_msg_event = false; + + switch (event) { + case UI_EVENT_CONTACT_MSG: + is_msg_event = true; +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + /* Only buzz if no phone is connected to receive the message */ + if (!get_state()->ble_connected) { + buzzer_play(MELODY_MSG_CONTACT); + } +#endif + break; + + case UI_EVENT_CHANNEL_MSG: + is_msg_event = true; +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + if (!get_state()->ble_connected) { + buzzer_play(MELODY_MSG_CHANNEL); + } +#endif + break; + + case UI_EVENT_ROOM_MSG: + is_msg_event = true; +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + if (!get_state()->ble_connected) { + buzzer_play(MELODY_MSG_CHANNEL); + } +#endif + break; + + case UI_EVENT_ACK: +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play(MELODY_ACK); +#endif + break; + + case UI_EVENT_BLE_CONNECTED: + ui_set_ble_status(true, NULL); + break; + + case UI_EVENT_BLE_DISCONNECTED: + ui_set_ble_status(false, NULL); + break; + + default: + break; + } + + /* On message events: flash heartbeat LED immediately (if not BLE connected + * and LEDs are enabled). Cancel the current cycle, turn on now, let + * led_off_work resume the normal heartbeat after LED_ON_MSG_MS. */ +#if HAS_HEARTBEAT_LED + if (is_msg_event && !get_state()->ble_connected && !get_state()->leds_disabled + && gpio_is_ready_dt(&heartbeat_led)) { + k_work_cancel_delayable(&led_on_work); + k_work_cancel_delayable(&led_off_work); + gpio_pin_set_dt(&heartbeat_led, 1); + k_work_reschedule(&led_off_work, K_MSEC(LED_ON_MSG_MS)); + } +#endif + + /* Wake display on non-message notifications (BLE connect/disconnect etc). + * Message notifications use buzzer + LED flash instead of waking the display. */ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY +#ifndef ZEPHCORE_REPEATER + if (!is_msg_event) { + mc_display_on(); + schedule_render(); + } +#endif +#endif +} + +void ui_set_msg_count(uint16_t count) +{ + struct ui_state *s = get_state(); + + if (s->msg_count == count) { + return; + } + + s->msg_count = count; + + if (!ui_initialized) { + return; + } + + /* Always render when the display is already on (user is looking). */ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + if (mc_display_is_on()) { + schedule_render(); + return; + } +#endif + + /* EPD is bistable and readable without backlight. If the user is parked + * on the messages page, update the count silently via partial refresh + * without waking the backlight. Any other page: leave it for the next + * button press to avoid a pointless flash. */ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + if (mc_display_is_epd() && ui_pages_current() == UI_PAGE_MESSAGES) { + schedule_render(); + } +#endif +} + +void ui_set_ble_status(bool connected, const char *name) +{ + struct ui_state *s = get_state(); + + s->ble_connected = connected; + if (name) { + strncpy(s->device_name, name, sizeof(s->device_name) - 1); + s->device_name[sizeof(s->device_name) - 1] = '\0'; + } + + if (ui_initialized) { + schedule_render(); + } +} + +void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, + uint8_t cr, int8_t tx_power, int16_t noise_floor) +{ + struct ui_state *s = get_state(); + + s->lora_freq_hz = freq_hz; + s->lora_sf = sf; + s->lora_bw_khz_x10 = bw_khz_x10; + s->lora_cr = cr; + s->lora_tx_power = tx_power; + s->lora_noise_floor = noise_floor; +} + +void ui_set_gps_data(bool has_fix, uint8_t sats, + int32_t lat_mdeg, int32_t lon_mdeg, int32_t alt_mm) +{ + struct ui_state *s = get_state(); + + s->gps_has_fix = has_fix; + s->gps_satellites = sats; + s->gps_lat_mdeg = lat_mdeg; + s->gps_lon_mdeg = lon_mdeg; + s->gps_alt_mm = alt_mm; +} + +void ui_set_battery(uint16_t mv, uint8_t pct) +{ + struct ui_state *s = get_state(); + + s->battery_mv = mv; + s->battery_pct = pct; +} + +void ui_set_clock(uint32_t epoch) +{ + struct ui_state *s = get_state(); + + s->rtc_epoch = epoch; +} + +void ui_add_recent(const char *name, int16_t rssi, uint32_t age_s) +{ + struct ui_state *s = get_state(); + + /* Shift entries down if full */ + if (s->recent_count >= 4) { + memmove(&s->recent[1], &s->recent[0], sizeof(s->recent[0]) * 3); + } else { + if (s->recent_count > 0) { + memmove(&s->recent[1], &s->recent[0], + sizeof(s->recent[0]) * s->recent_count); + } + s->recent_count++; + } + + /* Add new entry at top — sanitize UTF-8 to Latin-1 for display font */ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + utf8_to_latin1(s->recent[0].name, name, sizeof(s->recent[0].name)); +#else + strncpy(s->recent[0].name, name, sizeof(s->recent[0].name) - 1); + s->recent[0].name[sizeof(s->recent[0].name) - 1] = '\0'; +#endif + s->recent[0].rssi = rssi; + s->recent[0].age_s = age_s; +} + +void ui_clear_recent(void) +{ + struct ui_state *s = get_state(); + + s->recent_count = 0; + memset(s->recent, 0, sizeof(s->recent)); +} + +void ui_set_node_name(const char *name) +{ + struct ui_state *s = get_state(); + + if (name) { + strncpy(s->node_name, name, sizeof(s->node_name) - 1); + s->node_name[sizeof(s->node_name) - 1] = '\0'; + } +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + ui_pages_set_node_name(name); +#endif +} + +void ui_set_sensor_data(int16_t temp_c10, uint32_t pressure_pa, + uint16_t humidity_rh10, uint16_t light_lux) +{ + struct ui_state *s = get_state(); + + s->temperature_c10 = temp_c10; + s->pressure_pa = pressure_pa; + s->humidity_rh10 = humidity_rh10; + s->light_lux = light_lux; +} + +void ui_set_gps_available(bool available) +{ + struct ui_state *s = get_state(); + + s->gps_available = available; +} + +void ui_set_gps_enabled(bool enabled) +{ + struct ui_state *s = get_state(); + + s->gps_enabled = enabled; +} + +void ui_set_gps_state(uint8_t state, uint32_t last_fix_age_s, uint32_t next_search_s) +{ + struct ui_state *s = get_state(); + + s->gps_state = state; + s->gps_last_fix_age_s = last_fix_age_s; + s->gps_next_search_s = next_search_s; +} + +void ui_set_ble_enabled(bool enabled) +{ + struct ui_state *s = get_state(); + + s->ble_enabled = enabled; +} + +void ui_set_buzzer_quiet(bool quiet) +{ + struct ui_state *s = get_state(); + + s->buzzer_quiet = quiet; +} + +void ui_set_offgrid_mode(bool enabled) +{ + struct ui_state *s = get_state(); + + s->offgrid_enabled = enabled; +} + +void ui_set_leds_disabled(bool disabled) +{ + struct ui_state *s = get_state(); + + s->leds_disabled = disabled; +} + +void ui_set_heartbeat_led(bool enabled) +{ +#if HAS_HEARTBEAT_LED + if (enabled) { + if (gpio_is_ready_dt(&heartbeat_led)) { + k_work_reschedule(&led_on_work, K_NO_WAIT); + } + } else { + k_work_cancel_delayable(&led_on_work); + k_work_cancel_delayable(&led_off_work); + gpio_pin_set_dt(&heartbeat_led, 0); + } +#endif +} + +void ui_refresh_display(void) +{ + if (!ui_initialized) { + return; + } + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + /* EPD displays: skip periodic housekeeping renders. + * Each full e-paper refresh takes ~2s and causes visible flashing. + * All meaningful events (messages, BLE, GPS fix, button presses) + * already trigger renders via their own ui_set_*() → schedule_render(). + * Housekeeping just updates slow-changing data (clock, contact ages) + * which will appear on the next event-driven render. */ + if (mc_display_is_epd()) { + return; + } + + schedule_render(); +#endif +} diff --git a/zephcore/include/mesh/ContentionTracker.h b/zephcore/include/mesh/ContentionTracker.h index 22d65ad..fe87a53 100644 --- a/zephcore/include/mesh/ContentionTracker.h +++ b/zephcore/include/mesh/ContentionTracker.h @@ -1,80 +1,80 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Adaptive Contention Window — EMA-based flood retransmit delay - * - * Counts neighbor retransmit dupes within a 10s window per packet. - * Dupe counts feed a rolling EMA that drives an adaptive delay factor. - */ - -#pragma once - -#include - -namespace mesh { - -class Packet; - -class ContentionTracker { -public: - ContentionTracker(); - - /* FNV-1a 32-bit hash for ring buffer correlation (not dedup SHA256). */ - static uint32_t computePacketHash32(const Packet *pkt); - - void trackRetransmit(uint32_t hash32, uint32_t now_ms); - - /* Returns true if packet matched a tracked retransmit (dupe recorded). */ - bool recordDupeIfTracked(uint32_t hash32, uint32_t now_ms); - - /* Returns backoff_multiplier * airtime, clamped by remaining headroom. - * Returns 0 when hard cap reached or backoff disabled. */ - uint16_t getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const; - - void addReactiveExtension(uint32_t hash32, uint16_t added_ms); - - /* Finalize expired entries into EMA. */ - void tick(uint32_t now_ms); - - float getContentionEstimate() const; - - /* Saturating curve: MIN + (MAX-MIN) * est/(est+HALFPOINT). - * Returns 0.5 during warmup. */ - float getFloodDelayFactor() const; - - bool isWarmedUp() const { return _finalized_count >= WARMUP_PACKETS; } - - void setBackoffMultiplier(float m) { _backoff_multiplier = m; } - float getBackoffMultiplier() const { return _backoff_multiplier; } - -private: - static constexpr int RING_SIZE = 24; /* max concurrent tracked retransmits */ - static constexpr uint32_t WINDOW_MS = 10000; /* dupe observation window; covers SF12 2-hop */ - static constexpr int EMA_SHIFT = 3; /* alpha = 1/8 */ - static constexpr int WARMUP_PACKETS = 4; /* min samples before EMA is trusted */ - static constexpr float MIN_FLOOD_FACTOR = 0.40f; /* sparse mesh baseline */ - static constexpr float MAX_FLOOD_FACTOR = 1.00f; /* dense mesh ceiling */ - static constexpr float FLOOD_EST_HALFPOINT = 4.0f; /* midpoint: factor=0.60 at est=4 */ - static constexpr float DEFAULT_BACKOFF_MULT = 0.2f; /* airtime*0.2 per dupe heard */ - static constexpr uint32_t REACTIVE_HARD_CAP_MS = 2000; /* max cumulative reactive extension */ - static constexpr uint32_t STALE_MS = 300000; /* 5 min: reset EMA if no traffic */ - - struct Entry { - uint32_t hash32; - uint32_t first_seen_ms; - uint8_t dupe_count; - uint16_t reactive_added_ms; - bool active; - }; - - Entry _ring[RING_SIZE]; - int _next_idx; - uint32_t _ema_x256; - int _finalized_count; - uint32_t _last_retransmit_ms; - float _backoff_multiplier; - - void finalizeEntry(int idx); - int findEntry(uint32_t hash32) const; -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * Adaptive Contention Window — EMA-based flood retransmit delay + * + * Counts neighbor retransmit dupes within a 10s window per packet. + * Dupe counts feed a rolling EMA that drives an adaptive delay factor. + */ + +#pragma once + +#include + +namespace mesh { + +class Packet; + +class ContentionTracker { +public: + ContentionTracker(); + + /* FNV-1a 32-bit hash for ring buffer correlation (not dedup SHA256). */ + static uint32_t computePacketHash32(const Packet *pkt); + + void trackRetransmit(uint32_t hash32, uint32_t now_ms); + + /* Returns true if packet matched a tracked retransmit (dupe recorded). */ + bool recordDupeIfTracked(uint32_t hash32, uint32_t now_ms); + + /* Returns backoff_multiplier * airtime, clamped by remaining headroom. + * Returns 0 when hard cap reached or backoff disabled. */ + uint16_t getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const; + + void addReactiveExtension(uint32_t hash32, uint16_t added_ms); + + /* Finalize expired entries into EMA. */ + void tick(uint32_t now_ms); + + float getContentionEstimate() const; + + /* Saturating curve: MIN + (MAX-MIN) * est/(est+HALFPOINT). + * Returns 0.5 during warmup. */ + float getFloodDelayFactor() const; + + bool isWarmedUp() const { return _finalized_count >= WARMUP_PACKETS; } + + void setBackoffMultiplier(float m) { _backoff_multiplier = m; } + float getBackoffMultiplier() const { return _backoff_multiplier; } + +private: + static constexpr int RING_SIZE = 24; /* max concurrent tracked retransmits */ + static constexpr uint32_t WINDOW_MS = 10000; /* dupe observation window; covers SF12 2-hop */ + static constexpr int EMA_SHIFT = 3; /* alpha = 1/8 */ + static constexpr int WARMUP_PACKETS = 4; /* min samples before EMA is trusted */ + static constexpr float MIN_FLOOD_FACTOR = 0.40f; /* sparse mesh baseline */ + static constexpr float MAX_FLOOD_FACTOR = 1.00f; /* dense mesh ceiling */ + static constexpr float FLOOD_EST_HALFPOINT = 4.0f; /* midpoint: factor=0.60 at est=4 */ + static constexpr float DEFAULT_BACKOFF_MULT = 0.2f; /* airtime*0.2 per dupe heard */ + static constexpr uint32_t REACTIVE_HARD_CAP_MS = 2000; /* max cumulative reactive extension */ + static constexpr uint32_t STALE_MS = 300000; /* 5 min: reset EMA if no traffic */ + + struct Entry { + uint32_t hash32; + uint32_t first_seen_ms; + uint8_t dupe_count; + uint16_t reactive_added_ms; + bool active; + }; + + Entry _ring[RING_SIZE]; + int _next_idx; + uint32_t _ema_x256; + int _finalized_count; + uint32_t _last_retransmit_ms; + float _backoff_multiplier; + + void finalizeEntry(int idx); + int findEntry(uint32_t hash32) const; +}; + +} /* namespace mesh */ diff --git a/zephcore/include/mesh/Dispatcher.h b/zephcore/include/mesh/Dispatcher.h index 479a83b..723de92 100644 --- a/zephcore/include/mesh/Dispatcher.h +++ b/zephcore/include/mesh/Dispatcher.h @@ -1,127 +1,127 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Dispatcher - packet queue and radio scheduling - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -namespace mesh { - -class PacketManager { -public: - virtual Packet *allocNew() = 0; - virtual void free(Packet *packet) = 0; - virtual void queueOutbound(Packet *packet, uint8_t priority, uint32_t scheduled_for) = 0; - virtual Packet *getNextOutbound(uint32_t now) = 0; - virtual int getOutboundCount(uint32_t now) const = 0; - virtual int getOutboundTotal() const = 0; - virtual int getFreeCount() const = 0; - virtual Packet *getOutboundByIdx(int i) = 0; - virtual Packet *removeOutboundByIdx(int i) = 0; - virtual uint32_t getOutboundSchedule(int i) const = 0; - virtual bool rescheduleOutbound(int i, uint32_t new_scheduled_for) = 0; - virtual void queueInbound(Packet *packet, uint32_t scheduled_for) = 0; - virtual Packet *getNextInbound(uint32_t now) = 0; -}; - -/* Notifies event loop of pending TX so it can schedule a wake. */ -typedef void (*tx_queued_callback_t)(uint32_t delay_ms, void *user_data); - -typedef uint32_t DispatcherAction; - -#define ACTION_RELEASE (0) -#define ACTION_MANUAL_HOLD (1) -#define ACTION_RETRANSMIT(pri) (((uint32_t)1 + (pri))<<24) -#define ACTION_RETRANSMIT_DELAYED(pri, _delay) ((((uint32_t)1 + (pri))<<24) | (_delay)) - -#define ERR_EVENT_FULL (1 << 0) -#define ERR_EVENT_CAD_TIMEOUT (1 << 1) -#define ERR_EVENT_STARTRX_TIMEOUT (1 << 2) - -class Dispatcher { - Packet *outbound; - uint32_t outbound_expiry, outbound_start, total_air_time, rx_air_time; - uint32_t next_tx_time; - uint32_t cad_busy_start; - uint32_t tx_budget_ms; - uint32_t last_budget_update; - uint32_t duty_cycle_window_ms; - uint32_t radio_nonrx_start; - uint32_t next_agc_reset_time; - bool prev_isrecv_mode; - uint32_t n_sent_flood, n_sent_direct; - uint32_t n_recv_flood, n_recv_direct; - tx_queued_callback_t _tx_queued_cb; - void *_tx_queued_user_data; - - void processRecvPacket(Packet *pkt); - -protected: - Radio *_radio; - MillisecondClock *_ms; - PacketManager *_mgr; - uint16_t _err_flags; - - Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr); - void notifyTxQueued(uint32_t delay_ms) { - if (_tx_queued_cb) _tx_queued_cb(delay_ms, _tx_queued_user_data); - } - virtual DispatcherAction onRecvPacket(Packet *pkt) = 0; - virtual void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { (void)snr; (void)rssi; (void)raw; (void)len; } - virtual void logRx(Packet *packet, int len, float score) { (void)packet; (void)len; (void)score; } - virtual void logTx(Packet *packet, int len) { (void)packet; (void)len; } - virtual void logTxFail(Packet *packet, int len) { (void)packet; (void)len; } - virtual const char *getLogDateTime() { return ""; } - virtual uint8_t getDutyCyclePercent() const; - static bool isAdminPacket(const Packet *pkt); - virtual int calcRxDelay(float score, uint32_t air_time) const; - virtual uint32_t getCADFailRetryDelay() const; - virtual uint32_t getCADFailMaxDuration() const; - virtual int getInterferenceThreshold() const { return 0; } - virtual int getAGCResetInterval() const { return 0; } - virtual uint32_t getDutyCycleWindowMs() const { return 3600000UL; } /* 1h default */ - -public: - void begin(); - void loop(); - void maintenanceLoop(); - Packet *obtainNewPacket(); - void releasePacket(Packet *packet); - void sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis = 0); - - uint32_t getTotalAirTime() const { return total_air_time; } - uint32_t getReceiveAirTime() const { return rx_air_time; } - uint32_t getNumSentFlood() const { return n_sent_flood; } - uint32_t getNumSentDirect() const { return n_sent_direct; } - uint32_t getNumRecvFlood() const { return n_recv_flood; } - uint32_t getNumRecvDirect() const { return n_recv_direct; } - uint16_t getErrFlags() const { return _err_flags; } - void resetStats() { - n_sent_flood = n_sent_direct = 0; - n_recv_flood = n_recv_direct = 0; - _err_flags = 0; - } - void setTxQueuedCallback(tx_queued_callback_t cb, void *user_data) { - _tx_queued_cb = cb; - _tx_queued_user_data = user_data; - } - bool millisHasNowPassed(uint32_t timestamp) const; - uint32_t futureMillis(int millis_from_now) const; - -private: - void updateTxBudget(); - uint32_t getMaxTxBudgetMs() const; - bool tryParsePacket(Packet *pkt, const uint8_t *raw, int len); - void checkRecv(); - void checkSend(); -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Dispatcher - packet queue and radio scheduling + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace mesh { + +class PacketManager { +public: + virtual Packet *allocNew() = 0; + virtual void free(Packet *packet) = 0; + virtual void queueOutbound(Packet *packet, uint8_t priority, uint32_t scheduled_for) = 0; + virtual Packet *getNextOutbound(uint32_t now) = 0; + virtual int getOutboundCount(uint32_t now) const = 0; + virtual int getOutboundTotal() const = 0; + virtual int getFreeCount() const = 0; + virtual Packet *getOutboundByIdx(int i) = 0; + virtual Packet *removeOutboundByIdx(int i) = 0; + virtual uint32_t getOutboundSchedule(int i) const = 0; + virtual bool rescheduleOutbound(int i, uint32_t new_scheduled_for) = 0; + virtual void queueInbound(Packet *packet, uint32_t scheduled_for) = 0; + virtual Packet *getNextInbound(uint32_t now) = 0; +}; + +/* Notifies event loop of pending TX so it can schedule a wake. */ +typedef void (*tx_queued_callback_t)(uint32_t delay_ms, void *user_data); + +typedef uint32_t DispatcherAction; + +#define ACTION_RELEASE (0) +#define ACTION_MANUAL_HOLD (1) +#define ACTION_RETRANSMIT(pri) (((uint32_t)1 + (pri))<<24) +#define ACTION_RETRANSMIT_DELAYED(pri, _delay) ((((uint32_t)1 + (pri))<<24) | (_delay)) + +#define ERR_EVENT_FULL (1 << 0) +#define ERR_EVENT_CAD_TIMEOUT (1 << 1) +#define ERR_EVENT_STARTRX_TIMEOUT (1 << 2) + +class Dispatcher { + Packet *outbound; + uint32_t outbound_expiry, outbound_start, total_air_time, rx_air_time; + uint32_t next_tx_time; + uint32_t cad_busy_start; + uint32_t tx_budget_ms; + uint32_t last_budget_update; + uint32_t duty_cycle_window_ms; + uint32_t radio_nonrx_start; + uint32_t next_agc_reset_time; + bool prev_isrecv_mode; + uint32_t n_sent_flood, n_sent_direct; + uint32_t n_recv_flood, n_recv_direct; + tx_queued_callback_t _tx_queued_cb; + void *_tx_queued_user_data; + + void processRecvPacket(Packet *pkt); + +protected: + Radio *_radio; + MillisecondClock *_ms; + PacketManager *_mgr; + uint16_t _err_flags; + + Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr); + void notifyTxQueued(uint32_t delay_ms) { + if (_tx_queued_cb) _tx_queued_cb(delay_ms, _tx_queued_user_data); + } + virtual DispatcherAction onRecvPacket(Packet *pkt) = 0; + virtual void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { (void)snr; (void)rssi; (void)raw; (void)len; } + virtual void logRx(Packet *packet, int len, float score) { (void)packet; (void)len; (void)score; } + virtual void logTx(Packet *packet, int len) { (void)packet; (void)len; } + virtual void logTxFail(Packet *packet, int len) { (void)packet; (void)len; } + virtual const char *getLogDateTime() { return ""; } + virtual uint8_t getDutyCyclePercent() const; + static bool isAdminPacket(const Packet *pkt); + virtual int calcRxDelay(float score, uint32_t air_time) const; + virtual uint32_t getCADFailRetryDelay() const; + virtual uint32_t getCADFailMaxDuration() const; + virtual int getInterferenceThreshold() const { return 0; } + virtual int getAGCResetInterval() const { return 0; } + virtual uint32_t getDutyCycleWindowMs() const { return 3600000UL; } /* 1h default */ + +public: + void begin(); + void loop(); + void maintenanceLoop(); + Packet *obtainNewPacket(); + void releasePacket(Packet *packet); + void sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis = 0); + + uint32_t getTotalAirTime() const { return total_air_time; } + uint32_t getReceiveAirTime() const { return rx_air_time; } + uint32_t getNumSentFlood() const { return n_sent_flood; } + uint32_t getNumSentDirect() const { return n_sent_direct; } + uint32_t getNumRecvFlood() const { return n_recv_flood; } + uint32_t getNumRecvDirect() const { return n_recv_direct; } + uint16_t getErrFlags() const { return _err_flags; } + void resetStats() { + n_sent_flood = n_sent_direct = 0; + n_recv_flood = n_recv_direct = 0; + _err_flags = 0; + } + void setTxQueuedCallback(tx_queued_callback_t cb, void *user_data) { + _tx_queued_cb = cb; + _tx_queued_user_data = user_data; + } + bool millisHasNowPassed(uint32_t timestamp) const; + uint32_t futureMillis(int millis_from_now) const; + +private: + void updateTxBudget(); + uint32_t getMaxTxBudgetMs() const; + bool tryParsePacket(Packet *pkt, const uint8_t *raw, int len); + void checkRecv(); + void checkSend(); +}; + +} /* namespace mesh */ diff --git a/zephcore/include/mesh/Mesh.h b/zephcore/include/mesh/Mesh.h index c34e432..cb1f25b 100644 --- a/zephcore/include/mesh/Mesh.h +++ b/zephcore/include/mesh/Mesh.h @@ -1,107 +1,107 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Mesh - routing protocol layer - */ - -#pragma once - -#include -#include -#ifdef CONFIG_ZEPHCORE_APC -#include -#endif -#include - -namespace mesh { - -struct GroupChannel { - uint8_t hash[PATH_HASH_SIZE]; - uint8_t secret[PUB_KEY_SIZE]; -}; - -class MeshTables { -public: - virtual bool hasSeen(const Packet *packet) = 0; - virtual void clear(const Packet *packet) = 0; -}; - -class Mesh : public Dispatcher { - RNG *_rng; - RTCClock *_rtc; - MeshTables *_tables; - - void removeSelfFromPath(Packet *packet); - void routeDirectRecvAcks(Packet *packet, uint32_t delay_millis); - DispatcherAction forwardMultipartDirect(Packet *pkt); - -protected: - ContentionTracker _contention; - ContentionTracker& getContentionTracker() { return _contention; } - const ContentionTracker& getContentionTracker() const { return _contention; } -#ifdef CONFIG_ZEPHCORE_APC - PowerController _power_ctrl; - PowerController& getPowerController() { return _power_ctrl; } - const PowerController& getPowerController() const { return _power_ctrl; } -#endif - void extendPendingRetransmit(uint32_t hash32); - - DispatcherAction onRecvPacket(Packet *pkt) override; - virtual uint32_t getCADFailRetryDelay() const override; - virtual DispatcherAction routeRecvPacket(Packet *packet); - virtual bool filterRecvFloodPacket(Packet *packet) { return false; } - virtual bool allowPacketForward(const Packet *packet); - virtual uint32_t getRetransmitDelay(const Packet *packet); - virtual uint32_t getDirectRetransmitDelay(const Packet *packet) { return 0; } - /* Passive contention tracking: if true, track heard floods we don't forward - * (warms the contention EMA on nodes that don't relay, e.g. companions). */ - virtual bool passivelyTrackFloods() const { return false; } - /* Added to caller-supplied delay on every sendFlood. Default 0 (repeater - * behavior). Companion overrides to spread its initial TX adaptively. */ - virtual uint32_t getInitialFloodJitter(const Packet *packet) { (void)packet; return 0; } - virtual uint8_t getExtraAckTransmitCount() const { return 0; } - virtual int searchPeersByHash(const uint8_t *hash) { (void)hash; return 0; } - virtual void getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { (void)dest_secret; (void)peer_idx; } - virtual void onPeerDataRecv(Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { (void)packet; (void)type; (void)sender_idx; (void)secret; (void)data; (void)len; } - virtual void onTraceRecv(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) { (void)packet; (void)tag; (void)auth_code; (void)flags; (void)path_snrs; (void)path_hashes; (void)path_len; } - virtual bool onPeerPathRecv(Packet *packet, int sender_idx, const uint8_t *secret, uint8_t *path, uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) { (void)packet; (void)sender_idx; (void)secret; (void)path; (void)path_len; (void)extra_type; (void)extra; (void)extra_len; return false; } - virtual void onAdvertRecv(Packet *packet, const Identity &id, uint32_t timestamp, const uint8_t *app_data, size_t app_data_len) { (void)packet; (void)id; (void)timestamp; (void)app_data; (void)app_data_len; } - virtual void onAnonDataRecv(Packet *packet, const uint8_t *secret, const Identity &sender, uint8_t *data, size_t len) { (void)packet; (void)secret; (void)sender; (void)data; (void)len; } - virtual void onPathRecv(Packet *packet, Identity &sender, uint8_t *path, uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) { (void)packet; (void)sender; (void)path; (void)path_len; (void)extra_type; (void)extra; (void)extra_len; } - virtual void onControlDataRecv(Packet *packet) { (void)packet; } - virtual void onRawDataRecv(Packet *packet) { (void)packet; } - virtual int searchChannelsByHash(const uint8_t *hash, GroupChannel channels[], int max_matches) { (void)hash; (void)channels; (void)max_matches; return 0; } - virtual void onGroupDataRecv(Packet *packet, uint8_t type, const GroupChannel &channel, uint8_t *data, size_t len) { (void)packet; (void)type; (void)channel; (void)data; (void)len; } - virtual void onAckRecv(Packet *packet, uint32_t ack_crc) { (void)packet; (void)ack_crc; } - -public: - Mesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc, PacketManager &mgr, MeshTables &tables); - void begin(); - void loop(); - void maintenanceLoop(); - - LocalIdentity self_id; - - RNG *getRNG() const { return _rng; } - RTCClock *getRTCClock() const { return _rtc; } - MeshTables *getTables() const { return _tables; } - - Packet *createAdvert(const LocalIdentity &id, const uint8_t *app_data = nullptr, size_t app_data_len = 0); - Packet *createAck(uint32_t ack_crc); - Packet *createMultiAck(uint32_t ack_crc, uint8_t remaining); - Packet *createControlData(const uint8_t *data, size_t len); - Packet *createDatagram(uint8_t type, const Identity &dest, const uint8_t *secret, const uint8_t *data, size_t len); - Packet *createAnonDatagram(uint8_t type, const LocalIdentity &sender, const Identity &dest, const uint8_t *secret, const uint8_t *data, size_t data_len); - Packet *createGroupDatagram(uint8_t type, const GroupChannel &channel, const uint8_t *data, size_t data_len); - Packet *createPathReturn(const Identity &dest, const uint8_t *secret, const uint8_t *path, uint8_t path_len, uint8_t extra_type, const uint8_t *extra, size_t extra_len); - Packet *createPathReturn(const uint8_t *dest_hash, const uint8_t *secret, const uint8_t *path, uint8_t path_len, uint8_t extra_type, const uint8_t *extra, size_t extra_len); - Packet *createRawData(const uint8_t *data, size_t len); - Packet *createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0); - - void sendFlood(Packet *packet, uint32_t delay_millis = 0, uint8_t path_hash_size = 1); - void sendFlood(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis = 0, uint8_t path_hash_size = 1); - void sendDirect(Packet *packet, const uint8_t *path, uint8_t path_len, uint32_t delay_millis = 0); - void sendZeroHop(Packet *packet, uint32_t delay_millis = 0); - void sendZeroHop(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis = 0); -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Mesh - routing protocol layer + */ + +#pragma once + +#include +#include +#ifdef CONFIG_ZEPHCORE_APC +#include +#endif +#include + +namespace mesh { + +struct GroupChannel { + uint8_t hash[PATH_HASH_SIZE]; + uint8_t secret[PUB_KEY_SIZE]; +}; + +class MeshTables { +public: + virtual bool hasSeen(const Packet *packet) = 0; + virtual void clear(const Packet *packet) = 0; +}; + +class Mesh : public Dispatcher { + RNG *_rng; + RTCClock *_rtc; + MeshTables *_tables; + + void removeSelfFromPath(Packet *packet); + void routeDirectRecvAcks(Packet *packet, uint32_t delay_millis); + DispatcherAction forwardMultipartDirect(Packet *pkt); + +protected: + ContentionTracker _contention; + ContentionTracker& getContentionTracker() { return _contention; } + const ContentionTracker& getContentionTracker() const { return _contention; } +#ifdef CONFIG_ZEPHCORE_APC + PowerController _power_ctrl; + PowerController& getPowerController() { return _power_ctrl; } + const PowerController& getPowerController() const { return _power_ctrl; } +#endif + void extendPendingRetransmit(uint32_t hash32); + + DispatcherAction onRecvPacket(Packet *pkt) override; + virtual uint32_t getCADFailRetryDelay() const override; + virtual DispatcherAction routeRecvPacket(Packet *packet); + virtual bool filterRecvFloodPacket(Packet *packet) { return false; } + virtual bool allowPacketForward(const Packet *packet); + virtual uint32_t getRetransmitDelay(const Packet *packet); + virtual uint32_t getDirectRetransmitDelay(const Packet *packet) { return 0; } + /* Passive contention tracking: if true, track heard floods we don't forward + * (warms the contention EMA on nodes that don't relay, e.g. companions). */ + virtual bool passivelyTrackFloods() const { return false; } + /* Added to caller-supplied delay on every sendFlood. Default 0 (repeater + * behavior). Companion overrides to spread its initial TX adaptively. */ + virtual uint32_t getInitialFloodJitter(const Packet *packet) { (void)packet; return 0; } + virtual uint8_t getExtraAckTransmitCount() const { return 0; } + virtual int searchPeersByHash(const uint8_t *hash) { (void)hash; return 0; } + virtual void getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { (void)dest_secret; (void)peer_idx; } + virtual void onPeerDataRecv(Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { (void)packet; (void)type; (void)sender_idx; (void)secret; (void)data; (void)len; } + virtual void onTraceRecv(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) { (void)packet; (void)tag; (void)auth_code; (void)flags; (void)path_snrs; (void)path_hashes; (void)path_len; } + virtual bool onPeerPathRecv(Packet *packet, int sender_idx, const uint8_t *secret, uint8_t *path, uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) { (void)packet; (void)sender_idx; (void)secret; (void)path; (void)path_len; (void)extra_type; (void)extra; (void)extra_len; return false; } + virtual void onAdvertRecv(Packet *packet, const Identity &id, uint32_t timestamp, const uint8_t *app_data, size_t app_data_len) { (void)packet; (void)id; (void)timestamp; (void)app_data; (void)app_data_len; } + virtual void onAnonDataRecv(Packet *packet, const uint8_t *secret, const Identity &sender, uint8_t *data, size_t len) { (void)packet; (void)secret; (void)sender; (void)data; (void)len; } + virtual void onPathRecv(Packet *packet, Identity &sender, uint8_t *path, uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) { (void)packet; (void)sender; (void)path; (void)path_len; (void)extra_type; (void)extra; (void)extra_len; } + virtual void onControlDataRecv(Packet *packet) { (void)packet; } + virtual void onRawDataRecv(Packet *packet) { (void)packet; } + virtual int searchChannelsByHash(const uint8_t *hash, GroupChannel channels[], int max_matches) { (void)hash; (void)channels; (void)max_matches; return 0; } + virtual void onGroupDataRecv(Packet *packet, uint8_t type, const GroupChannel &channel, uint8_t *data, size_t len) { (void)packet; (void)type; (void)channel; (void)data; (void)len; } + virtual void onAckRecv(Packet *packet, uint32_t ack_crc) { (void)packet; (void)ack_crc; } + +public: + Mesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc, PacketManager &mgr, MeshTables &tables); + void begin(); + void loop(); + void maintenanceLoop(); + + LocalIdentity self_id; + + RNG *getRNG() const { return _rng; } + RTCClock *getRTCClock() const { return _rtc; } + MeshTables *getTables() const { return _tables; } + + Packet *createAdvert(const LocalIdentity &id, const uint8_t *app_data = nullptr, size_t app_data_len = 0); + Packet *createAck(uint32_t ack_crc); + Packet *createMultiAck(uint32_t ack_crc, uint8_t remaining); + Packet *createControlData(const uint8_t *data, size_t len); + Packet *createDatagram(uint8_t type, const Identity &dest, const uint8_t *secret, const uint8_t *data, size_t len); + Packet *createAnonDatagram(uint8_t type, const LocalIdentity &sender, const Identity &dest, const uint8_t *secret, const uint8_t *data, size_t data_len); + Packet *createGroupDatagram(uint8_t type, const GroupChannel &channel, const uint8_t *data, size_t data_len); + Packet *createPathReturn(const Identity &dest, const uint8_t *secret, const uint8_t *path, uint8_t path_len, uint8_t extra_type, const uint8_t *extra, size_t extra_len); + Packet *createPathReturn(const uint8_t *dest_hash, const uint8_t *secret, const uint8_t *path, uint8_t path_len, uint8_t extra_type, const uint8_t *extra, size_t extra_len); + Packet *createRawData(const uint8_t *data, size_t len); + Packet *createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0); + + void sendFlood(Packet *packet, uint32_t delay_millis = 0, uint8_t path_hash_size = 1); + void sendFlood(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis = 0, uint8_t path_hash_size = 1); + void sendDirect(Packet *packet, const uint8_t *path, uint8_t path_len, uint32_t delay_millis = 0); + void sendZeroHop(Packet *packet, uint32_t delay_millis = 0); + void sendZeroHop(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis = 0); +}; + +} /* namespace mesh */ diff --git a/zephcore/include/mesh/PowerController.h b/zephcore/include/mesh/PowerController.h index ed7fba2..5253196 100644 --- a/zephcore/include/mesh/PowerController.h +++ b/zephcore/include/mesh/PowerController.h @@ -1,113 +1,113 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Adaptive Power Control (APC) — echo-based TX power reduction - * - * Measures link margin by tracking echo packets (flood dupes of - * packets we sent or retransmitted, heard back from neighbors). - * Feeds per-packet margins into a rolling EMA to produce an - * adaptive TX power reduction in dBm. - * - * Rogue filtering: when 2+ distinct neighbors echo the same packet, - * clusters their SNRs within 6 dB of the best. An isolated high - * outlier (rogue, badly placed neighbor) is dropped. - */ - -#pragma once - -#include - -namespace mesh { - -class Packet; - -class PowerController { -public: - PowerController(); - - /* Enable/disable APC. When disabled, APC tracking/math is bypassed. */ - void setEnabled(bool en); - bool isEnabled() const { return _enabled; } - - /* Set current spreading factor (needed for margin calculation). */ - void setSF(uint8_t sf) { _sf = sf; } - - /* Set target link margin in dB. Higher = more conservative - * (better for networks with poor-RX hardware). Default 16 dB. */ - void setTargetMargin(uint8_t margin_db) { _target_margin_x4 = (int)margin_db * 4; } - uint8_t getTargetMargin() const { return (uint8_t)(_target_margin_x4 / 4); } - - /* Called when we send or retransmit a flood packet. */ - void trackTransmit(uint32_t hash32, uint32_t now_ms); - - /* Called for every received flood dupe. Updates per-source best - * SNR and source diversity. Returns true if the dupe matched a - * tracked transmit. */ - bool recordEcho(uint32_t hash32, int8_t snr_x4, - uint8_t first_hop_hash, uint32_t now_ms); - - /* Finalize expired entries, update EMA, adjust power, handle - * staleness. Call from maintenanceLoop (~5 s). */ - void tick(uint32_t now_ms); - - /* Current TX power reduction in dBm (0 to MAX_REDUCTION_DB). - * Returns 0 when disabled. */ - int8_t getPowerReduction() const { return _enabled ? _power_reduction_db : 0; } - - /* Current margin estimate in dB (for diagnostics). */ - float getMarginEstimate() const; - - /* Source count from most recently finalized entry (diagnostics). */ - uint8_t getLastSourceCount() const { return _last_source_count; } - - bool isWarmedUp() const { return _finalized_count >= WARMUP_COUNT; } - bool isStale(uint32_t now_ms) const; - -private: - static constexpr int RING_SIZE = 16; - static constexpr uint32_t ECHO_WINDOW_MS = 10000; /* 10s: covers SF12 2-hop echo */ - static constexpr uint32_t STALE_MS = 120000; /* 2 min */ - static constexpr int EMA_SHIFT = 2; /* alpha = 1/4 */ - static constexpr int WARMUP_COUNT = 3; - static constexpr int MAX_SOURCES = 3; - static constexpr int8_t STEP_DOWN_DB = 3; - static constexpr int8_t STEP_UP_DB = 6; - static constexpr int8_t MAX_REDUCTION_DB = 12; - static constexpr int8_t MIN_TX_POWER_DBM = -9; /* SX1262 hw min */ - static constexpr int CLUSTER_WIDTH_X4 = 24; /* 6 dB in x4 */ - static constexpr int DEFAULT_TARGET_MARGIN_X4 = 64; /* 16 dB * 4 */ - static constexpr int HYSTERESIS_X4 = 4; /* 1 dB * 4 */ - - struct Source { - uint8_t hash; - int8_t snr_x4; - }; - - struct EchoEntry { - uint32_t hash32; - uint32_t timestamp_ms; - uint8_t source_count; - uint8_t sf_at_track; /* SF when packet was transmitted */ - Source sources[MAX_SOURCES]; - bool active; - }; - - EchoEntry _ring[RING_SIZE]; - int _next_idx; - int32_t _margin_ema_x256; /* fixed-point EMA (x4 * 256) */ - int _finalized_count; - uint32_t _last_echo_ms; - int8_t _power_reduction_db; - bool _enabled; - uint8_t _sf; - uint8_t _last_source_count; - int _target_margin_x4; - - void finalizeEntry(int idx); - int findEntry(uint32_t hash32) const; - int8_t computeRobustSNR(const EchoEntry &entry) const; - - /* SNR threshold for a given SF (x4 fixed point). */ - static int8_t sfThresholdX4(uint8_t sf); -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * Adaptive Power Control (APC) — echo-based TX power reduction + * + * Measures link margin by tracking echo packets (flood dupes of + * packets we sent or retransmitted, heard back from neighbors). + * Feeds per-packet margins into a rolling EMA to produce an + * adaptive TX power reduction in dBm. + * + * Rogue filtering: when 2+ distinct neighbors echo the same packet, + * clusters their SNRs within 6 dB of the best. An isolated high + * outlier (rogue, badly placed neighbor) is dropped. + */ + +#pragma once + +#include + +namespace mesh { + +class Packet; + +class PowerController { +public: + PowerController(); + + /* Enable/disable APC. When disabled, APC tracking/math is bypassed. */ + void setEnabled(bool en); + bool isEnabled() const { return _enabled; } + + /* Set current spreading factor (needed for margin calculation). */ + void setSF(uint8_t sf) { _sf = sf; } + + /* Set target link margin in dB. Higher = more conservative + * (better for networks with poor-RX hardware). Default 16 dB. */ + void setTargetMargin(uint8_t margin_db) { _target_margin_x4 = (int)margin_db * 4; } + uint8_t getTargetMargin() const { return (uint8_t)(_target_margin_x4 / 4); } + + /* Called when we send or retransmit a flood packet. */ + void trackTransmit(uint32_t hash32, uint32_t now_ms); + + /* Called for every received flood dupe. Updates per-source best + * SNR and source diversity. Returns true if the dupe matched a + * tracked transmit. */ + bool recordEcho(uint32_t hash32, int8_t snr_x4, + uint8_t first_hop_hash, uint32_t now_ms); + + /* Finalize expired entries, update EMA, adjust power, handle + * staleness. Call from maintenanceLoop (~5 s). */ + void tick(uint32_t now_ms); + + /* Current TX power reduction in dBm (0 to MAX_REDUCTION_DB). + * Returns 0 when disabled. */ + int8_t getPowerReduction() const { return _enabled ? _power_reduction_db : 0; } + + /* Current margin estimate in dB (for diagnostics). */ + float getMarginEstimate() const; + + /* Source count from most recently finalized entry (diagnostics). */ + uint8_t getLastSourceCount() const { return _last_source_count; } + + bool isWarmedUp() const { return _finalized_count >= WARMUP_COUNT; } + bool isStale(uint32_t now_ms) const; + +private: + static constexpr int RING_SIZE = 16; + static constexpr uint32_t ECHO_WINDOW_MS = 10000; /* 10s: covers SF12 2-hop echo */ + static constexpr uint32_t STALE_MS = 120000; /* 2 min */ + static constexpr int EMA_SHIFT = 2; /* alpha = 1/4 */ + static constexpr int WARMUP_COUNT = 3; + static constexpr int MAX_SOURCES = 3; + static constexpr int8_t STEP_DOWN_DB = 3; + static constexpr int8_t STEP_UP_DB = 6; + static constexpr int8_t MAX_REDUCTION_DB = 12; + static constexpr int8_t MIN_TX_POWER_DBM = -9; /* SX1262 hw min */ + static constexpr int CLUSTER_WIDTH_X4 = 24; /* 6 dB in x4 */ + static constexpr int DEFAULT_TARGET_MARGIN_X4 = 64; /* 16 dB * 4 */ + static constexpr int HYSTERESIS_X4 = 4; /* 1 dB * 4 */ + + struct Source { + uint8_t hash; + int8_t snr_x4; + }; + + struct EchoEntry { + uint32_t hash32; + uint32_t timestamp_ms; + uint8_t source_count; + uint8_t sf_at_track; /* SF when packet was transmitted */ + Source sources[MAX_SOURCES]; + bool active; + }; + + EchoEntry _ring[RING_SIZE]; + int _next_idx; + int32_t _margin_ema_x256; /* fixed-point EMA (x4 * 256) */ + int _finalized_count; + uint32_t _last_echo_ms; + int8_t _power_reduction_db; + bool _enabled; + uint8_t _sf; + uint8_t _last_source_count; + int _target_margin_x4; + + void finalizeEntry(int idx); + int findEntry(uint32_t hash32) const; + int8_t computeRobustSNR(const EchoEntry &entry) const; + + /* SNR threshold for a given SF (x4 fixed point). */ + static int8_t sfThresholdX4(uint8_t sf); +}; + +} /* namespace mesh */ diff --git a/zephcore/include/mesh/Radio.h b/zephcore/include/mesh/Radio.h index 6bba447..1be338c 100644 --- a/zephcore/include/mesh/Radio.h +++ b/zephcore/include/mesh/Radio.h @@ -1,43 +1,43 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Radio interface - matches Dispatcher.h - */ - -#pragma once - -#include - -namespace mesh { - -class Radio { -public: - virtual void begin() {} - - virtual int recvRaw(uint8_t *bytes, int sz) = 0; - virtual uint32_t getEstAirtimeFor(int len_bytes) = 0; - virtual float packetScore(float snr, int packet_len) = 0; - virtual bool startSendRaw(const uint8_t *bytes, int len) = 0; - virtual bool isSendComplete() = 0; - virtual void onSendFinished() = 0; - - virtual int getNoiseFloor() const { return 0; } - virtual void triggerNoiseFloorCalibrate(int threshold) { (void)threshold; } - virtual void resetAGC() {} - - virtual bool isInRecvMode() const = 0; - virtual bool isReceiving() { return false; } - virtual bool isRadioReady() { return true; } - virtual float getLastRSSI() const { return 0; } - virtual float getLastSNR() const { return 0; } - - /* Adaptive Power Control */ - virtual void setTxPowerReduction(int8_t reduction_db) { (void)reduction_db; } - virtual int8_t getTxPowerReduction() const { return 0; } - - /* Packet statistics */ - virtual uint32_t getPacketsRecv() const { return 0; } - virtual uint32_t getPacketsSent() const { return 0; } - virtual uint32_t getPacketsRecvErrors() const { return 0; } -}; - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Radio interface - matches Dispatcher.h + */ + +#pragma once + +#include + +namespace mesh { + +class Radio { +public: + virtual void begin() {} + + virtual int recvRaw(uint8_t *bytes, int sz) = 0; + virtual uint32_t getEstAirtimeFor(int len_bytes) = 0; + virtual float packetScore(float snr, int packet_len) = 0; + virtual bool startSendRaw(const uint8_t *bytes, int len) = 0; + virtual bool isSendComplete() = 0; + virtual void onSendFinished() = 0; + + virtual int getNoiseFloor() const { return 0; } + virtual void triggerNoiseFloorCalibrate(int threshold) { (void)threshold; } + virtual void resetAGC() {} + + virtual bool isInRecvMode() const = 0; + virtual bool isReceiving() { return false; } + virtual bool isRadioReady() { return true; } + virtual float getLastRSSI() const { return 0; } + virtual float getLastSNR() const { return 0; } + + /* Adaptive Power Control */ + virtual void setTxPowerReduction(int8_t reduction_db) { (void)reduction_db; } + virtual int8_t getTxPowerReduction() const { return 0; } + + /* Packet statistics */ + virtual uint32_t getPacketsRecv() const { return 0; } + virtual uint32_t getPacketsSent() const { return 0; } + virtual uint32_t getPacketsRecvErrors() const { return 0; } +}; + +} /* namespace mesh */ diff --git a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h index 05be0c9..cc8c52b 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h +++ b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h @@ -1,116 +1,116 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * SX126x native driver — extension API - * - * Functions extending the standard Zephyr lora_driver_api with - * SX126x-specific features (duty cycle, RX boost, RSSI readout, - * preamble detection). - */ - -#ifndef SX126X_EXT_H -#define SX126X_EXT_H - -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * @brief Get instantaneous RSSI (for noise floor calibration) - * - * Reads the current RSSI from the radio while in RX mode. - * Uses non-blocking mutex — returns -128 if SPI is busy. - * - * @param dev LoRa device - * @return RSSI in dBm, or -128 on error - */ -int16_t sx126x_get_rssi_inst(const struct device *dev); - -/** - * @brief Check if radio is actively receiving a packet - * - * Checks IRQ status for preamble/header detection. - * Uses non-blocking mutex — returns false if SPI is busy. - * - * @param dev LoRa device - * @return true if preamble or header detected - */ -bool sx126x_is_receiving(const struct device *dev); - -/** - * @brief Enable/disable RX boosted mode - * - * Boosted mode increases LNA gain for +3dB sensitivity at +2mA cost. - * - * @param dev LoRa device - * @param enable true to enable boost - */ -void sx126x_set_rx_boost(const struct device *dev, bool enable); - -/** - * @brief Check if the radio chip is busy (cannot accept SPI commands) - * - * Reads the BUSY GPIO pin directly — no SPI, no blocking. - * Returns true when the chip is in its duty-cycle sleep phase. - * Safe to call at any time. - * - * @param dev LoRa device - * @return true if BUSY pin is high (chip sleeping / processing) - */ -bool sx126x_is_chip_busy(const struct device *dev); - -/** - * @brief Apply undocumented register 0x8B5 RX improvement for Heltec V4 - * - * Sets the LSB of register 0x8B5 which consistently improves RX reception - * on boards with GC1109 or KCT8103L PA (Heltec V4/V4.3). Described by - * Heltec engineer @Quency-D in MeshCore PR#1398. - * - * Must be called after the first lora_config() completes. - * - * @param dev LoRa device - */ -void sx126x_apply_heltec_reg_patch(const struct device *dev); - -/** - * @brief Reset AGC by performing warm sleep + full recalibration - * - * Warm sleep powers down the analog frontend (resets AGC gain state), - * then Calibrate(0x7F) refreshes all blocks (ADC, PLL, image, oscillators). - * Re-applies DIO2 RF switch, RX boosted gain, and image calibration afterward. - * - * Must be called while NOT actively receiving a packet. - * - * @param dev LoRa device - */ -void sx126x_reset_agc(const struct device *dev); - -/** - * @brief Get duty-cycle preamble false-positive counter - * - * Returns the number of times duty-cycle RX tripped IRQ_RX_TX_TIMEOUT - * and was silently re-armed. High values mean the preamble detector is - * firing on noise/neighbour interference without a real packet arriving, - * which inflates RX-on time beyond the nominal duty cycle and shortens - * battery life. - * - * @param dev LoRa device - * @return Cumulative re-arm count since last reset - */ -uint32_t sx126x_get_dc_timeout_restarts(const struct device *dev); - -/** - * @brief Reset the duty-cycle preamble false-positive counter to zero. - * - * @param dev LoRa device - */ -void sx126x_reset_dc_timeout_restarts(const struct device *dev); - -#ifdef __cplusplus -} -#endif - -#endif /* SX126X_EXT_H */ +/* + * SPDX-License-Identifier: Apache-2.0 + * SX126x native driver — extension API + * + * Functions extending the standard Zephyr lora_driver_api with + * SX126x-specific features (duty cycle, RX boost, RSSI readout, + * preamble detection). + */ + +#ifndef SX126X_EXT_H +#define SX126X_EXT_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Get instantaneous RSSI (for noise floor calibration) + * + * Reads the current RSSI from the radio while in RX mode. + * Uses non-blocking mutex — returns -128 if SPI is busy. + * + * @param dev LoRa device + * @return RSSI in dBm, or -128 on error + */ +int16_t sx126x_get_rssi_inst(const struct device *dev); + +/** + * @brief Check if radio is actively receiving a packet + * + * Checks IRQ status for preamble/header detection. + * Uses non-blocking mutex — returns false if SPI is busy. + * + * @param dev LoRa device + * @return true if preamble or header detected + */ +bool sx126x_is_receiving(const struct device *dev); + +/** + * @brief Enable/disable RX boosted mode + * + * Boosted mode increases LNA gain for +3dB sensitivity at +2mA cost. + * + * @param dev LoRa device + * @param enable true to enable boost + */ +void sx126x_set_rx_boost(const struct device *dev, bool enable); + +/** + * @brief Check if the radio chip is busy (cannot accept SPI commands) + * + * Reads the BUSY GPIO pin directly — no SPI, no blocking. + * Returns true when the chip is in its duty-cycle sleep phase. + * Safe to call at any time. + * + * @param dev LoRa device + * @return true if BUSY pin is high (chip sleeping / processing) + */ +bool sx126x_is_chip_busy(const struct device *dev); + +/** + * @brief Apply undocumented register 0x8B5 RX improvement for Heltec V4 + * + * Sets the LSB of register 0x8B5 which consistently improves RX reception + * on boards with GC1109 or KCT8103L PA (Heltec V4/V4.3). Described by + * Heltec engineer @Quency-D in MeshCore PR#1398. + * + * Must be called after the first lora_config() completes. + * + * @param dev LoRa device + */ +void sx126x_apply_heltec_reg_patch(const struct device *dev); + +/** + * @brief Reset AGC by performing warm sleep + full recalibration + * + * Warm sleep powers down the analog frontend (resets AGC gain state), + * then Calibrate(0x7F) refreshes all blocks (ADC, PLL, image, oscillators). + * Re-applies DIO2 RF switch, RX boosted gain, and image calibration afterward. + * + * Must be called while NOT actively receiving a packet. + * + * @param dev LoRa device + */ +void sx126x_reset_agc(const struct device *dev); + +/** + * @brief Get duty-cycle preamble false-positive counter + * + * Returns the number of times duty-cycle RX tripped IRQ_RX_TX_TIMEOUT + * and was silently re-armed. High values mean the preamble detector is + * firing on noise/neighbour interference without a real packet arriving, + * which inflates RX-on time beyond the nominal duty cycle and shortens + * battery life. + * + * @param dev LoRa device + * @return Cumulative re-arm count since last reset + */ +uint32_t sx126x_get_dc_timeout_restarts(const struct device *dev); + +/** + * @brief Reset the duty-cycle preamble false-positive counter to zero. + * + * @param dev LoRa device + */ +void sx126x_reset_dc_timeout_restarts(const struct device *dev); + +#ifdef __cplusplus +} +#endif + +#endif /* SX126X_EXT_H */ diff --git a/zephcore/src/ContentionTracker.cpp b/zephcore/src/ContentionTracker.cpp index ef04b03..b9b5d03 100644 --- a/zephcore/src/ContentionTracker.cpp +++ b/zephcore/src/ContentionTracker.cpp @@ -1,166 +1,166 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Adaptive Contention Window — dupe-counting based delay estimation - */ - -#include -#include -#include - -namespace mesh { - -ContentionTracker::ContentionTracker() - : _next_idx(0), _ema_x256(0), _finalized_count(0), - _last_retransmit_ms(0), _backoff_multiplier(DEFAULT_BACKOFF_MULT) -{ - memset(_ring, 0, sizeof(_ring)); -} - -/* FNV-1a over payload_type + first 8 payload bytes */ -uint32_t ContentionTracker::computePacketHash32(const Packet *pkt) -{ - uint32_t h = 0x811c9dc5u; /* FNV-1a offset basis */ - uint8_t t = pkt->getPayloadType(); - h = (h ^ t) * 0x01000193u; - int n = pkt->payload_len < 8 ? pkt->payload_len : 8; - for (int i = 0; i < n; i++) { - h = (h ^ pkt->payload[i]) * 0x01000193u; - } - return h; -} - -int ContentionTracker::findEntry(uint32_t hash32) const -{ - for (int i = 0; i < RING_SIZE; i++) { - if (_ring[i].active && _ring[i].hash32 == hash32) { - return i; - } - } - return -1; -} - -void ContentionTracker::finalizeEntry(int idx) -{ - if (!_ring[idx].active) return; - - uint32_t sample_x256 = (uint32_t)_ring[idx].dupe_count << 8; - - int32_t diff = (int32_t)sample_x256 - (int32_t)_ema_x256; - - if (_finalized_count < WARMUP_PACKETS) { - /* Warmup: seed EMA with fast convergence */ - if (_finalized_count == 0) { - _ema_x256 = sample_x256; - } else { - _ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> 1)); - } - } else { - _ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> EMA_SHIFT)); - } - - _finalized_count++; - _ring[idx].active = false; -} - -void ContentionTracker::trackRetransmit(uint32_t hash32, uint32_t now_ms) -{ - _last_retransmit_ms = now_ms; - - /* Evict oldest if ring slot occupied */ - if (_ring[_next_idx].active) { - finalizeEntry(_next_idx); - } - - Entry &e = _ring[_next_idx]; - e.hash32 = hash32; - e.first_seen_ms = now_ms; - e.dupe_count = 0; - e.reactive_added_ms = 0; - e.active = true; - - _next_idx = (_next_idx + 1) % RING_SIZE; -} - -bool ContentionTracker::recordDupeIfTracked(uint32_t hash32, uint32_t now_ms) -{ - int idx = findEntry(hash32); - if (idx < 0) return false; - - Entry &e = _ring[idx]; - - if (now_ms - e.first_seen_ms > WINDOW_MS) { - finalizeEntry(idx); - return false; - } - - if (e.dupe_count < 255) { - e.dupe_count++; - } - return true; -} - -uint16_t ContentionTracker::getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const -{ - int idx = findEntry(hash32); - if (idx < 0) return 0; - - uint32_t per_dupe = (uint32_t)(_backoff_multiplier * (float)airtime_ms); - if (per_dupe == 0) return 0; - - /* Effective cap: ~12 relay-slots (airtime-scaled), absolute ceiling REACTIVE_HARD_CAP_MS */ - uint32_t effective_cap = 12 * airtime_ms; - if (effective_cap > REACTIVE_HARD_CAP_MS) effective_cap = REACTIVE_HARD_CAP_MS; - - if (_ring[idx].reactive_added_ms >= effective_cap) return 0; - - uint32_t remaining = effective_cap - _ring[idx].reactive_added_ms; - if (per_dupe > remaining) per_dupe = remaining; - return per_dupe > 0xFFFF ? 0xFFFF : (uint16_t)per_dupe; -} - -void ContentionTracker::addReactiveExtension(uint32_t hash32, uint16_t added_ms) -{ - int idx = findEntry(hash32); - if (idx < 0) return; - - uint32_t total = (uint32_t)_ring[idx].reactive_added_ms + added_ms; - _ring[idx].reactive_added_ms = total > 0xFFFF ? 0xFFFF : (uint16_t)total; -} - -void ContentionTracker::tick(uint32_t now_ms) -{ - for (int i = 0; i < RING_SIZE; i++) { - if (_ring[i].active && now_ms - _ring[i].first_seen_ms > WINDOW_MS) { - finalizeEntry(i); - } - } - - /* Decay EMA toward 0 if no retransmit in STALE_MS */ - if (_last_retransmit_ms != 0 && now_ms - _last_retransmit_ms > STALE_MS) { - if (_ema_x256 > 0) { - _ema_x256 -= _ema_x256 >> EMA_SHIFT; - } - } -} - -float ContentionTracker::getContentionEstimate() const -{ - return (float)_ema_x256 / 256.0f; -} - -float ContentionTracker::getFloodDelayFactor() const -{ - if (!isWarmedUp()) return 0.5f; /* conservative default before warmup */ - - float est = getContentionEstimate(); - if (est <= 0.0f) return MIN_FLOOD_FACTOR; - - /* Arduino-like center near 0.5 in light contention, rising smoothly - * toward 0.8 as contention increases. */ - float factor = MIN_FLOOD_FACTOR + (MAX_FLOOD_FACTOR - MIN_FLOOD_FACTOR) * - (est / (est + FLOOD_EST_HALFPOINT)); - if (factor > MAX_FLOOD_FACTOR) factor = MAX_FLOOD_FACTOR; - return factor; -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * Adaptive Contention Window — dupe-counting based delay estimation + */ + +#include +#include +#include + +namespace mesh { + +ContentionTracker::ContentionTracker() + : _next_idx(0), _ema_x256(0), _finalized_count(0), + _last_retransmit_ms(0), _backoff_multiplier(DEFAULT_BACKOFF_MULT) +{ + memset(_ring, 0, sizeof(_ring)); +} + +/* FNV-1a over payload_type + first 8 payload bytes */ +uint32_t ContentionTracker::computePacketHash32(const Packet *pkt) +{ + uint32_t h = 0x811c9dc5u; /* FNV-1a offset basis */ + uint8_t t = pkt->getPayloadType(); + h = (h ^ t) * 0x01000193u; + int n = pkt->payload_len < 8 ? pkt->payload_len : 8; + for (int i = 0; i < n; i++) { + h = (h ^ pkt->payload[i]) * 0x01000193u; + } + return h; +} + +int ContentionTracker::findEntry(uint32_t hash32) const +{ + for (int i = 0; i < RING_SIZE; i++) { + if (_ring[i].active && _ring[i].hash32 == hash32) { + return i; + } + } + return -1; +} + +void ContentionTracker::finalizeEntry(int idx) +{ + if (!_ring[idx].active) return; + + uint32_t sample_x256 = (uint32_t)_ring[idx].dupe_count << 8; + + int32_t diff = (int32_t)sample_x256 - (int32_t)_ema_x256; + + if (_finalized_count < WARMUP_PACKETS) { + /* Warmup: seed EMA with fast convergence */ + if (_finalized_count == 0) { + _ema_x256 = sample_x256; + } else { + _ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> 1)); + } + } else { + _ema_x256 = (uint32_t)((int32_t)_ema_x256 + (diff >> EMA_SHIFT)); + } + + _finalized_count++; + _ring[idx].active = false; +} + +void ContentionTracker::trackRetransmit(uint32_t hash32, uint32_t now_ms) +{ + _last_retransmit_ms = now_ms; + + /* Evict oldest if ring slot occupied */ + if (_ring[_next_idx].active) { + finalizeEntry(_next_idx); + } + + Entry &e = _ring[_next_idx]; + e.hash32 = hash32; + e.first_seen_ms = now_ms; + e.dupe_count = 0; + e.reactive_added_ms = 0; + e.active = true; + + _next_idx = (_next_idx + 1) % RING_SIZE; +} + +bool ContentionTracker::recordDupeIfTracked(uint32_t hash32, uint32_t now_ms) +{ + int idx = findEntry(hash32); + if (idx < 0) return false; + + Entry &e = _ring[idx]; + + if (now_ms - e.first_seen_ms > WINDOW_MS) { + finalizeEntry(idx); + return false; + } + + if (e.dupe_count < 255) { + e.dupe_count++; + } + return true; +} + +uint16_t ContentionTracker::getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const +{ + int idx = findEntry(hash32); + if (idx < 0) return 0; + + uint32_t per_dupe = (uint32_t)(_backoff_multiplier * (float)airtime_ms); + if (per_dupe == 0) return 0; + + /* Effective cap: ~12 relay-slots (airtime-scaled), absolute ceiling REACTIVE_HARD_CAP_MS */ + uint32_t effective_cap = 12 * airtime_ms; + if (effective_cap > REACTIVE_HARD_CAP_MS) effective_cap = REACTIVE_HARD_CAP_MS; + + if (_ring[idx].reactive_added_ms >= effective_cap) return 0; + + uint32_t remaining = effective_cap - _ring[idx].reactive_added_ms; + if (per_dupe > remaining) per_dupe = remaining; + return per_dupe > 0xFFFF ? 0xFFFF : (uint16_t)per_dupe; +} + +void ContentionTracker::addReactiveExtension(uint32_t hash32, uint16_t added_ms) +{ + int idx = findEntry(hash32); + if (idx < 0) return; + + uint32_t total = (uint32_t)_ring[idx].reactive_added_ms + added_ms; + _ring[idx].reactive_added_ms = total > 0xFFFF ? 0xFFFF : (uint16_t)total; +} + +void ContentionTracker::tick(uint32_t now_ms) +{ + for (int i = 0; i < RING_SIZE; i++) { + if (_ring[i].active && now_ms - _ring[i].first_seen_ms > WINDOW_MS) { + finalizeEntry(i); + } + } + + /* Decay EMA toward 0 if no retransmit in STALE_MS */ + if (_last_retransmit_ms != 0 && now_ms - _last_retransmit_ms > STALE_MS) { + if (_ema_x256 > 0) { + _ema_x256 -= _ema_x256 >> EMA_SHIFT; + } + } +} + +float ContentionTracker::getContentionEstimate() const +{ + return (float)_ema_x256 / 256.0f; +} + +float ContentionTracker::getFloodDelayFactor() const +{ + if (!isWarmedUp()) return 0.5f; /* conservative default before warmup */ + + float est = getContentionEstimate(); + if (est <= 0.0f) return MIN_FLOOD_FACTOR; + + /* Arduino-like center near 0.5 in light contention, rising smoothly + * toward 0.8 as contention increases. */ + float factor = MIN_FLOOD_FACTOR + (MAX_FLOOD_FACTOR - MIN_FLOOD_FACTOR) * + (est / (est + FLOOD_EST_HALFPOINT)); + if (factor > MAX_FLOOD_FACTOR) factor = MAX_FLOOD_FACTOR; + return factor; +} + +} /* namespace mesh */ diff --git a/zephcore/src/Mesh.cpp b/zephcore/src/Mesh.cpp index a040a15..a5ff8e6 100644 --- a/zephcore/src/Mesh.cpp +++ b/zephcore/src/Mesh.cpp @@ -1,732 +1,732 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Mesh - minimal port for Phase 5 - */ - -#include -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_mesh, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); - -namespace mesh { - -Mesh::Mesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc, PacketManager &mgr, MeshTables &tables) - : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables) -{ -} - -void Mesh::begin() -{ - Dispatcher::begin(); -} - -void Mesh::loop() -{ - Dispatcher::loop(); -} - -void Mesh::maintenanceLoop() -{ - Dispatcher::maintenanceLoop(); - uint32_t now = (uint32_t)_ms->getMillis(); - _contention.tick(now); -#ifdef CONFIG_ZEPHCORE_APC - _power_ctrl.tick(now); - _radio->setTxPowerReduction(_power_ctrl.getPowerReduction()); -#endif -} - -void Mesh::extendPendingRetransmit(uint32_t hash32) -{ - uint32_t now = (uint32_t)_ms->getMillis(); - int total = _mgr->getOutboundTotal(); - for (int i = 0; i < total; i++) { - Packet *pkt = _mgr->getOutboundByIdx(i); - if (pkt && pkt->isRouteFlood() - && ContentionTracker::computePacketHash32(pkt) == hash32) { - uint32_t airtime = _radio->getEstAirtimeFor(pkt->getRawLength()); - uint16_t delay = _contention.getReactiveHeadroom(hash32, airtime); - if (delay == 0) break; - /* Reschedule from NOW: heard a dupe, defer by one - * backoff_multiplier × airtime window per dupe. */ - _mgr->rescheduleOutbound(i, now + delay); - _contention.addReactiveExtension(hash32, delay); - notifyTxQueued(delay); - break; - } - } -} - -bool Mesh::allowPacketForward(const Packet *packet) -{ - (void)packet; - return false; -} - -uint32_t Mesh::getRetransmitDelay(const Packet *packet) -{ - uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2; - return _rng->nextInt(0, 5) * t; -} - -uint32_t Mesh::getCADFailRetryDelay() const -{ - return _rng->nextInt(1, 4) * 120; -} - -void Mesh::removeSelfFromPath(Packet *pkt) -{ - pkt->setPathHashCount(pkt->getPathHashCount() - 1); // decrement the count - - uint8_t sz = pkt->getPathHashSize(); - for (int k = 0; k < pkt->getPathHashCount()*sz; k += sz) { // shuffle path by 1 'entry' - memcpy(&pkt->path[k], &pkt->path[k + sz], sz); - } -} - -DispatcherAction Mesh::routeRecvPacket(Packet *packet) -{ - uint8_t n = packet->getPathHashCount(); - if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit() - && (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE && allowPacketForward(packet)) { - // append this node's hash to 'path' - self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize()); - packet->setPathHashCount(n + 1); - uint32_t h = ContentionTracker::computePacketHash32(packet); - _contention.trackRetransmit(h, (uint32_t)_ms->getMillis()); -#ifdef CONFIG_ZEPHCORE_APC - _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis()); -#endif - uint32_t d = getRetransmitDelay(packet); - return ACTION_RETRANSMIT_DELAYED(packet->getPathHashCount(), d); // give priority to closer sources - } - return ACTION_RELEASE; -} - -DispatcherAction Mesh::forwardMultipartDirect(Packet *pkt) -{ - uint8_t remaining = pkt->payload[0] >> 4; - uint8_t type = pkt->payload[0] & 0x0F; - if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { - Packet tmp; - tmp.header = pkt->header; - tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); - tmp.payload_len = pkt->payload_len - 1; - memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); - if (!_tables->hasSeen(&tmp)) { - removeSelfFromPath(&tmp); - routeDirectRecvAcks(&tmp, ((uint32_t)remaining + 1) * 300); - } - } - return ACTION_RELEASE; -} - -void Mesh::routeDirectRecvAcks(Packet *packet, uint32_t delay_millis) -{ - if (!packet->isMarkedDoNotRetransmit()) { - uint32_t crc; - memcpy(&crc, packet->payload, 4); - Packet *a2 = createAck(crc); - if (a2) { - a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len); - a2->header &= ~PH_ROUTE_MASK; - a2->header |= ROUTE_TYPE_DIRECT; - sendPacket(a2, 0, delay_millis); - } - } -} - -DispatcherAction Mesh::onRecvPacket(Packet *pkt) -{ - // Handle direct TRACE packets - if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) { - if (pkt->path_len < MAX_PATH_SIZE) { - int i = 0; - uint32_t trace_tag; - memcpy(&trace_tag, &pkt->payload[i], 4); i += 4; - uint32_t auth_code; - memcpy(&auth_code, &pkt->payload[i], 4); i += 4; - uint8_t flags = pkt->payload[i++]; - uint8_t path_sz = flags & 0x03; - - uint8_t len = pkt->payload_len - i; - uint8_t offset = pkt->path_len << path_sz; - if (offset >= len) { - onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); - } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { - pkt->path[pkt->path_len++] = (int8_t)(pkt->getSNR() * 4); - uint32_t d = getDirectRetransmitDelay(pkt); - return ACTION_RETRANSMIT_DELAYED(5, d); - } - } - return ACTION_RELEASE; - } - - // Handle direct CONTROL packets (zero-hop only) - if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_CONTROL && (pkt->payload[0] & 0x80) != 0) { - if (pkt->getPathHashCount() == 0) { - onControlDataRecv(pkt); - } - return ACTION_RELEASE; - } - - // Handle direct zero-hop ACKs (path_len=0) - if (pkt->isRouteDirect() && pkt->getPathHashCount() == 0 && pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { - uint32_t ack_crc; - memcpy(&ack_crc, pkt->payload, 4); - onAckRecv(pkt, ack_crc); - return ACTION_RELEASE; - } - - if (pkt->isRouteDirect() && pkt->getPathHashCount() > 0) { - if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { - uint32_t ack_crc; - memcpy(&ack_crc, pkt->payload, 4); - onAckRecv(pkt, ack_crc); - } - if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) { - if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { - return forwardMultipartDirect(pkt); - } - if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { - if (!_tables->hasSeen(pkt)) { - removeSelfFromPath(pkt); - routeDirectRecvAcks(pkt, 0); - } - return ACTION_RELEASE; - } - if (!_tables->hasSeen(pkt)) { - removeSelfFromPath(pkt); - return ACTION_RETRANSMIT_DELAYED(0, getDirectRetransmitDelay(pkt)); - } - } - return ACTION_RELEASE; - } - - if (pkt->isRouteFlood() && filterRecvFloodPacket(pkt)) return ACTION_RELEASE; - - /* Record dupes for contention tracking + reactive backoff */ - if (pkt->isRouteFlood()) { - uint32_t h = ContentionTracker::computePacketHash32(pkt); -#ifdef CONFIG_ZEPHCORE_APC - uint8_t first_hop = (pkt->getPathHashCount() > 0) ? pkt->path[0] : 0; - _power_ctrl.recordEcho(h, pkt->_snr, first_hop, (uint32_t)_ms->getMillis()); -#endif - if (_contention.recordDupeIfTracked(h, (uint32_t)_ms->getMillis())) { - extendPendingRetransmit(h); - } else if (passivelyTrackFloods()) { - /* First hearing of a flood we won't forward — track it so the - * EMA reflects local contention (companion-side awareness). */ - _contention.trackRetransmit(h, (uint32_t)_ms->getMillis()); - } - } - - DispatcherAction action = ACTION_RELEASE; - - switch (pkt->getPayloadType()) { - case PAYLOAD_TYPE_ACK: { - uint32_t ack_crc; - memcpy(&ack_crc, pkt->payload, 4); - if (!_tables->hasSeen(pkt)) { - onAckRecv(pkt, ack_crc); - action = routeRecvPacket(pkt); - } - break; - } - case PAYLOAD_TYPE_PATH: - case PAYLOAD_TYPE_REQ: - case PAYLOAD_TYPE_RESPONSE: - case PAYLOAD_TYPE_TXT_MSG: { - int i = 0; - uint8_t dest_hash = pkt->payload[i++]; - uint8_t src_hash = pkt->payload[i++]; - - uint8_t *macAndData = &pkt->payload[i]; - if (i + CIPHER_MAC_SIZE >= (int)pkt->payload_len) { - LOG_WRN("onRecvPacket: incomplete packet (i=%d, payload_len=%d)", i, pkt->payload_len); - } else if (!_tables->hasSeen(pkt)) { - if (self_id.isHashMatch(&dest_hash)) { - int num = searchPeersByHash(&src_hash); - bool found = false; - for (int j = 0; j < num; j++) { - uint8_t secret[PUB_KEY_SIZE]; - getPeerSharedSecret(secret, j); - - uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); - if (len > 0) { - if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { - int k = 0; - uint8_t path_len = data[k++]; - if (!Packet::isValidPathLen(path_len)) { - LOG_WRN("onRecvPacket: invalid inner path_len 0x%02x", path_len); - break; - } - uint8_t hash_size = (path_len >> 6) + 1; - uint8_t hash_count = path_len & 63; - int path_bytes = hash_size * hash_count; - if (k + path_bytes + 1 > len) { - LOG_WRN("onRecvPacket: PATH payload truncated (k=%d path_bytes=%d len=%d)", k, path_bytes, len); - break; - } - uint8_t *path = &data[k]; k += path_bytes; - uint8_t extra_type = data[k++] & 0x0F; - uint8_t *extra = &data[k]; - uint8_t extra_len = (uint8_t)(len - k); - if (onPeerPathRecv(pkt, j, secret, path, path_len, extra_type, extra, extra_len)) { - if (pkt->isRouteFlood()) { - Packet *rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, nullptr, 0); - if (rpath) sendDirect(rpath, path, path_len, 500); - } - } - } else { - onPeerDataRecv(pkt, pkt->getPayloadType(), j, secret, data, len); - } - found = true; - break; - } - } - if (found) { - pkt->markDoNotRetransmit(); - } else { - LOG_WRN("onRecvPacket: no peer could decrypt message"); - } - } - action = routeRecvPacket(pkt); - } - break; - } - case PAYLOAD_TYPE_ANON_REQ: { - int i = 0; - uint8_t dest_hash = pkt->payload[i++]; - uint8_t *sender_pub_key = &pkt->payload[i]; i += PUB_KEY_SIZE; - - uint8_t *macAndData = &pkt->payload[i]; - if (i + 2 >= (int)pkt->payload_len) { - // incomplete packet - } else if (!_tables->hasSeen(pkt)) { - if (self_id.isHashMatch(&dest_hash)) { - Identity sender(sender_pub_key); - uint8_t secret[PUB_KEY_SIZE]; - self_id.calcSharedSecret(secret, sender); - - uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); - if (len > 0) { - onAnonDataRecv(pkt, secret, sender, data, len); - pkt->markDoNotRetransmit(); - } - } - action = routeRecvPacket(pkt); - } - break; - } - case PAYLOAD_TYPE_GRP_DATA: - case PAYLOAD_TYPE_GRP_TXT: { - int i = 0; - uint8_t channel_hash = pkt->payload[i++]; - - uint8_t *macAndData = &pkt->payload[i]; - if (i + 2 >= (int)pkt->payload_len) { - // incomplete packet - } else if (!_tables->hasSeen(pkt)) { - GroupChannel channels[4]; - int num = searchChannelsByHash(&channel_hash, channels, 4); - for (int j = 0; j < num; j++) { - uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, pkt->payload_len - i); - if (len > 0) { - onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len); - break; - } - } - action = routeRecvPacket(pkt); - } - break; - } - case PAYLOAD_TYPE_ADVERT: { - int i = 0; - Identity id; - memcpy(id.pub_key, &pkt->payload[i], PUB_KEY_SIZE); - i += PUB_KEY_SIZE; - uint32_t timestamp; - memcpy(×tamp, &pkt->payload[i], 4); - i += 4; - const uint8_t *signature = &pkt->payload[i]; - i += SIGNATURE_SIZE; - if (i <= (int)pkt->payload_len && !self_id.matches(id.pub_key) && !_tables->hasSeen(pkt)) { - uint8_t *app_data = (uint8_t *)&pkt->payload[i]; - size_t app_data_len = pkt->payload_len - (size_t)i; - if (app_data_len > MAX_ADVERT_DATA_SIZE) app_data_len = MAX_ADVERT_DATA_SIZE; - uint8_t message[PUB_KEY_SIZE + 4 + MAX_ADVERT_DATA_SIZE]; - int msg_len = 0; - memcpy(&message[msg_len], id.pub_key, PUB_KEY_SIZE); msg_len += PUB_KEY_SIZE; - memcpy(&message[msg_len], ×tamp, 4); msg_len += 4; - memcpy(&message[msg_len], app_data, app_data_len); msg_len += app_data_len; - if (id.verify(signature, message, msg_len)) { - onAdvertRecv(pkt, id, timestamp, app_data, app_data_len); - action = routeRecvPacket(pkt); - } - } - break; - } - case PAYLOAD_TYPE_RAW_CUSTOM: - if (pkt->isRouteDirect() && !_tables->hasSeen(pkt)) { - onRawDataRecv(pkt); - } - break; - case PAYLOAD_TYPE_MULTIPART: - if (pkt->payload_len > 2) { - /* uint8_t remaining = pkt->payload[0] >> 4; */ /* Reserved for future multipart support */ - uint8_t type = pkt->payload[0] & 0x0F; - - if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { - Packet tmp; - tmp.header = pkt->header; - tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); - tmp.payload_len = pkt->payload_len - 1; - memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); - - if (!_tables->hasSeen(&tmp)) { - uint32_t ack_crc; - memcpy(&ack_crc, tmp.payload, 4); - onAckRecv(&tmp, ack_crc); - } - } - } - break; - default: - break; - } - return action; -} - -Packet *Mesh::createAdvert(const LocalIdentity &id, const uint8_t *app_data, size_t app_data_len) -{ - if (app_data_len > MAX_ADVERT_DATA_SIZE) return nullptr; - - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - - packet->header = (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); - int len = 0; - memcpy(&packet->payload[len], id.pub_key, PUB_KEY_SIZE); - len += PUB_KEY_SIZE; - uint32_t emitted_timestamp = _rtc->getCurrentTime(); - memcpy(&packet->payload[len], &emitted_timestamp, 4); - len += 4; - uint8_t *signature = &packet->payload[len]; - len += SIGNATURE_SIZE; - if (app_data && app_data_len > 0) { - memcpy(&packet->payload[len], app_data, app_data_len); - len += (int)app_data_len; - } - packet->payload_len = len; - - uint8_t message[PUB_KEY_SIZE + 4 + MAX_ADVERT_DATA_SIZE]; - int msg_len = 0; - memcpy(&message[msg_len], id.pub_key, PUB_KEY_SIZE); msg_len += PUB_KEY_SIZE; - memcpy(&message[msg_len], &emitted_timestamp, 4); msg_len += 4; - if (app_data && app_data_len > 0) { - memcpy(&message[msg_len], app_data, app_data_len); msg_len += (int)app_data_len; - } - id.sign(signature, message, msg_len); - return packet; -} - -Packet *Mesh::createAck(uint32_t ack_crc) -{ - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - packet->header = (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT); - memcpy(packet->payload, &ack_crc, 4); - packet->payload_len = 4; - return packet; -} - -Packet *Mesh::createMultiAck(uint32_t ack_crc, uint8_t remaining) -{ - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - packet->header = (PAYLOAD_TYPE_MULTIPART << PH_TYPE_SHIFT); - packet->payload[0] = (remaining << 4) | PAYLOAD_TYPE_ACK; - memcpy(&packet->payload[1], &ack_crc, 4); - packet->payload_len = 5; - return packet; -} - -Packet *Mesh::createControlData(const uint8_t *data, size_t len) -{ - if (len > sizeof(Packet::payload)) return nullptr; - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - packet->header = (PAYLOAD_TYPE_CONTROL << PH_TYPE_SHIFT); - memcpy(packet->payload, data, len); - packet->payload_len = (uint16_t)len; - return packet; -} - -void Mesh::sendFlood(Packet *packet, uint32_t delay_millis, uint8_t path_hash_size) -{ - if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { - releasePacket(packet); - return; - } - if (path_hash_size == 0 || path_hash_size > 3) { - LOG_WRN("sendFlood: invalid path_hash_size"); - releasePacket(packet); - return; - } - packet->header &= ~PH_ROUTE_MASK; - packet->header |= ROUTE_TYPE_FLOOD; - packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); -#ifdef CONFIG_ZEPHCORE_APC - { - uint32_t h = ContentionTracker::computePacketHash32(packet); - _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis()); - } -#endif - - uint8_t pri; - if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { - pri = 2; - } else if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { - pri = 3; - } else { - pri = 1; - } - sendPacket(packet, pri, delay_millis + getInitialFloodJitter(packet)); -} - -void Mesh::sendFlood(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis, uint8_t path_hash_size) -{ - if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { - releasePacket(packet); - return; - } - if (path_hash_size == 0 || path_hash_size > 3) { - LOG_WRN("sendFlood: invalid path_hash_size"); - releasePacket(packet); - return; - } - packet->header &= ~PH_ROUTE_MASK; - packet->header |= ROUTE_TYPE_TRANSPORT_FLOOD; - packet->transport_codes[0] = transport_codes[0]; - packet->transport_codes[1] = transport_codes[1]; - packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); -#ifdef CONFIG_ZEPHCORE_APC - { - uint32_t h = ContentionTracker::computePacketHash32(packet); - _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis()); - } -#endif - - uint8_t pri; - if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { - pri = 2; - } else if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { - pri = 3; - } else { - pri = 1; - } - sendPacket(packet, pri, delay_millis + getInitialFloodJitter(packet)); -} - -void Mesh::sendDirect(Packet *packet, const uint8_t *path, uint8_t path_len, uint32_t delay_millis) -{ - packet->header &= ~PH_ROUTE_MASK; - packet->header |= ROUTE_TYPE_DIRECT; - - uint8_t pri; - if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { - /* For TRACE packets, path is appended to end of PAYLOAD (used for SNRs) */ - memcpy(&packet->payload[packet->payload_len], path, path_len); - packet->payload_len += path_len; - packet->path_len = 0; - pri = 5; - } else { - packet->path_len = Packet::copyPath(packet->path, path, path_len); - if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { - pri = 1; - } else { - pri = 0; - } - } - - _tables->hasSeen(packet); - sendPacket(packet, pri, delay_millis); -} - -void Mesh::sendZeroHop(Packet *packet, uint32_t delay_millis) -{ - packet->header &= ~PH_ROUTE_MASK; - packet->header |= ROUTE_TYPE_DIRECT; - packet->path_len = 0; - _tables->hasSeen(packet); - sendPacket(packet, 0, delay_millis); -} - -void Mesh::sendZeroHop(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis) -{ - packet->header &= ~PH_ROUTE_MASK; - packet->header |= ROUTE_TYPE_TRANSPORT_DIRECT; - packet->transport_codes[0] = transport_codes[0]; - packet->transport_codes[1] = transport_codes[1]; - packet->path_len = 0; - _tables->hasSeen(packet); - sendPacket(packet, 0, delay_millis); -} - -#define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE) - -Packet *Mesh::createPathReturn(const Identity &dest, const uint8_t *secret, const uint8_t *path, uint8_t path_len, - uint8_t extra_type, const uint8_t *extra, size_t extra_len) -{ - uint8_t dest_hash[PATH_HASH_SIZE]; - dest.copyHashTo(dest_hash); - return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len); -} - -Packet *Mesh::createPathReturn(const uint8_t *dest_hash, const uint8_t *secret, const uint8_t *path, uint8_t path_len, - uint8_t extra_type, const uint8_t *extra, size_t extra_len) -{ - uint8_t path_hash_size = (path_len >> 6) + 1; - uint8_t path_hash_count = path_len & 63; - - if (path_hash_count*path_hash_size + extra_len + 5 > MAX_COMBINED_PATH) return nullptr; - - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - - packet->header = (PAYLOAD_TYPE_PATH << PH_TYPE_SHIFT); - - int len = 0; - memcpy(&packet->payload[len], dest_hash, PATH_HASH_SIZE); len += PATH_HASH_SIZE; - len += self_id.copyHashTo(&packet->payload[len]); - - { - int data_len = 0; - uint8_t data[MAX_PACKET_PAYLOAD]; - - data[data_len++] = path_len; - memcpy(&data[data_len], path, path_hash_count*path_hash_size); data_len += path_hash_count*path_hash_size; - if (extra_len > 0) { - data[data_len++] = extra_type; - memcpy(&data[data_len], extra, extra_len); data_len += extra_len; - } else { - data[data_len++] = 0xFF; // dummy payload type - _rng->random(&data[data_len], 4); data_len += 4; - } - - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); - } - - packet->payload_len = len; - return packet; -} - -Packet *Mesh::createDatagram(uint8_t type, const Identity &dest, const uint8_t *secret, const uint8_t *data, size_t data_len) -{ - if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { - if (data_len + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE - 1 > MAX_PACKET_PAYLOAD) { - LOG_WRN("createDatagram: data too large"); - return nullptr; - } - } else { - LOG_WRN("createDatagram: unsupported type %d", type); - return nullptr; - } - - Packet *packet = obtainNewPacket(); - if (packet == nullptr) { - LOG_ERR("createDatagram: packet alloc failed"); - return nullptr; - } - - packet->header = (type << PH_TYPE_SHIFT); - - int len = 0; - len += dest.copyHashTo(&packet->payload[len]); - len += self_id.copyHashTo(&packet->payload[len]); - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); - - packet->payload_len = len; - return packet; -} - -Packet *Mesh::createAnonDatagram(uint8_t type, const LocalIdentity &sender, const Identity &dest, - const uint8_t *secret, const uint8_t *data, size_t data_len) -{ - if (type == PAYLOAD_TYPE_ANON_REQ) { - if (data_len + 1 + PUB_KEY_SIZE + CIPHER_BLOCK_SIZE - 1 > MAX_PACKET_PAYLOAD) return nullptr; - } else { - return nullptr; - } - - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - - packet->header = (type << PH_TYPE_SHIFT); - - int len = 0; - if (type == PAYLOAD_TYPE_ANON_REQ) { - len += dest.copyHashTo(&packet->payload[len]); - memcpy(&packet->payload[len], sender.pub_key, PUB_KEY_SIZE); len += PUB_KEY_SIZE; - } - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); - - packet->payload_len = len; - return packet; -} - -Packet *Mesh::createGroupDatagram(uint8_t type, const GroupChannel &channel, const uint8_t *data, size_t data_len) -{ - if (!(type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA)) return nullptr; - if (data_len + 1 + CIPHER_BLOCK_SIZE - 1 > MAX_PACKET_PAYLOAD) return nullptr; - - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - - packet->header = (type << PH_TYPE_SHIFT); - - int len = 0; - memcpy(&packet->payload[len], channel.hash, PATH_HASH_SIZE); len += PATH_HASH_SIZE; - len += Utils::encryptThenMAC(channel.secret, &packet->payload[len], data, data_len); - - packet->payload_len = len; - return packet; -} - -Packet *Mesh::createRawData(const uint8_t *data, size_t len) -{ - if (len > sizeof(Packet::payload)) return nullptr; - - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - - packet->header = (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT); - memcpy(packet->payload, data, len); - packet->payload_len = (uint16_t)len; - - return packet; -} - -Packet *Mesh::createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags) -{ - Packet *packet = obtainNewPacket(); - if (packet == nullptr) return nullptr; - - packet->header = (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); - memcpy(packet->payload, &tag, 4); - memcpy(&packet->payload[4], &auth_code, 4); - packet->payload[8] = flags; - packet->payload_len = 9; - - return packet; -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Mesh - minimal port for Phase 5 + */ + +#include +#include +#include + +#include +LOG_MODULE_REGISTER(zephcore_mesh, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +namespace mesh { + +Mesh::Mesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc, PacketManager &mgr, MeshTables &tables) + : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables) +{ +} + +void Mesh::begin() +{ + Dispatcher::begin(); +} + +void Mesh::loop() +{ + Dispatcher::loop(); +} + +void Mesh::maintenanceLoop() +{ + Dispatcher::maintenanceLoop(); + uint32_t now = (uint32_t)_ms->getMillis(); + _contention.tick(now); +#ifdef CONFIG_ZEPHCORE_APC + _power_ctrl.tick(now); + _radio->setTxPowerReduction(_power_ctrl.getPowerReduction()); +#endif +} + +void Mesh::extendPendingRetransmit(uint32_t hash32) +{ + uint32_t now = (uint32_t)_ms->getMillis(); + int total = _mgr->getOutboundTotal(); + for (int i = 0; i < total; i++) { + Packet *pkt = _mgr->getOutboundByIdx(i); + if (pkt && pkt->isRouteFlood() + && ContentionTracker::computePacketHash32(pkt) == hash32) { + uint32_t airtime = _radio->getEstAirtimeFor(pkt->getRawLength()); + uint16_t delay = _contention.getReactiveHeadroom(hash32, airtime); + if (delay == 0) break; + /* Reschedule from NOW: heard a dupe, defer by one + * backoff_multiplier × airtime window per dupe. */ + _mgr->rescheduleOutbound(i, now + delay); + _contention.addReactiveExtension(hash32, delay); + notifyTxQueued(delay); + break; + } + } +} + +bool Mesh::allowPacketForward(const Packet *packet) +{ + (void)packet; + return false; +} + +uint32_t Mesh::getRetransmitDelay(const Packet *packet) +{ + uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2; + return _rng->nextInt(0, 5) * t; +} + +uint32_t Mesh::getCADFailRetryDelay() const +{ + return _rng->nextInt(1, 4) * 120; +} + +void Mesh::removeSelfFromPath(Packet *pkt) +{ + pkt->setPathHashCount(pkt->getPathHashCount() - 1); // decrement the count + + uint8_t sz = pkt->getPathHashSize(); + for (int k = 0; k < pkt->getPathHashCount()*sz; k += sz) { // shuffle path by 1 'entry' + memcpy(&pkt->path[k], &pkt->path[k + sz], sz); + } +} + +DispatcherAction Mesh::routeRecvPacket(Packet *packet) +{ + uint8_t n = packet->getPathHashCount(); + if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit() + && (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE && allowPacketForward(packet)) { + // append this node's hash to 'path' + self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize()); + packet->setPathHashCount(n + 1); + uint32_t h = ContentionTracker::computePacketHash32(packet); + _contention.trackRetransmit(h, (uint32_t)_ms->getMillis()); +#ifdef CONFIG_ZEPHCORE_APC + _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis()); +#endif + uint32_t d = getRetransmitDelay(packet); + return ACTION_RETRANSMIT_DELAYED(packet->getPathHashCount(), d); // give priority to closer sources + } + return ACTION_RELEASE; +} + +DispatcherAction Mesh::forwardMultipartDirect(Packet *pkt) +{ + uint8_t remaining = pkt->payload[0] >> 4; + uint8_t type = pkt->payload[0] & 0x0F; + if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { + Packet tmp; + tmp.header = pkt->header; + tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); + tmp.payload_len = pkt->payload_len - 1; + memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); + if (!_tables->hasSeen(&tmp)) { + removeSelfFromPath(&tmp); + routeDirectRecvAcks(&tmp, ((uint32_t)remaining + 1) * 300); + } + } + return ACTION_RELEASE; +} + +void Mesh::routeDirectRecvAcks(Packet *packet, uint32_t delay_millis) +{ + if (!packet->isMarkedDoNotRetransmit()) { + uint32_t crc; + memcpy(&crc, packet->payload, 4); + Packet *a2 = createAck(crc); + if (a2) { + a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len); + a2->header &= ~PH_ROUTE_MASK; + a2->header |= ROUTE_TYPE_DIRECT; + sendPacket(a2, 0, delay_millis); + } + } +} + +DispatcherAction Mesh::onRecvPacket(Packet *pkt) +{ + // Handle direct TRACE packets + if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) { + if (pkt->path_len < MAX_PATH_SIZE) { + int i = 0; + uint32_t trace_tag; + memcpy(&trace_tag, &pkt->payload[i], 4); i += 4; + uint32_t auth_code; + memcpy(&auth_code, &pkt->payload[i], 4); i += 4; + uint8_t flags = pkt->payload[i++]; + uint8_t path_sz = flags & 0x03; + + uint8_t len = pkt->payload_len - i; + uint8_t offset = pkt->path_len << path_sz; + if (offset >= len) { + onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); + } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { + pkt->path[pkt->path_len++] = (int8_t)(pkt->getSNR() * 4); + uint32_t d = getDirectRetransmitDelay(pkt); + return ACTION_RETRANSMIT_DELAYED(5, d); + } + } + return ACTION_RELEASE; + } + + // Handle direct CONTROL packets (zero-hop only) + if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_CONTROL && (pkt->payload[0] & 0x80) != 0) { + if (pkt->getPathHashCount() == 0) { + onControlDataRecv(pkt); + } + return ACTION_RELEASE; + } + + // Handle direct zero-hop ACKs (path_len=0) + if (pkt->isRouteDirect() && pkt->getPathHashCount() == 0 && pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { + uint32_t ack_crc; + memcpy(&ack_crc, pkt->payload, 4); + onAckRecv(pkt, ack_crc); + return ACTION_RELEASE; + } + + if (pkt->isRouteDirect() && pkt->getPathHashCount() > 0) { + if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { + uint32_t ack_crc; + memcpy(&ack_crc, pkt->payload, 4); + onAckRecv(pkt, ack_crc); + } + if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) { + if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { + return forwardMultipartDirect(pkt); + } + if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { + if (!_tables->hasSeen(pkt)) { + removeSelfFromPath(pkt); + routeDirectRecvAcks(pkt, 0); + } + return ACTION_RELEASE; + } + if (!_tables->hasSeen(pkt)) { + removeSelfFromPath(pkt); + return ACTION_RETRANSMIT_DELAYED(0, getDirectRetransmitDelay(pkt)); + } + } + return ACTION_RELEASE; + } + + if (pkt->isRouteFlood() && filterRecvFloodPacket(pkt)) return ACTION_RELEASE; + + /* Record dupes for contention tracking + reactive backoff */ + if (pkt->isRouteFlood()) { + uint32_t h = ContentionTracker::computePacketHash32(pkt); +#ifdef CONFIG_ZEPHCORE_APC + uint8_t first_hop = (pkt->getPathHashCount() > 0) ? pkt->path[0] : 0; + _power_ctrl.recordEcho(h, pkt->_snr, first_hop, (uint32_t)_ms->getMillis()); +#endif + if (_contention.recordDupeIfTracked(h, (uint32_t)_ms->getMillis())) { + extendPendingRetransmit(h); + } else if (passivelyTrackFloods()) { + /* First hearing of a flood we won't forward — track it so the + * EMA reflects local contention (companion-side awareness). */ + _contention.trackRetransmit(h, (uint32_t)_ms->getMillis()); + } + } + + DispatcherAction action = ACTION_RELEASE; + + switch (pkt->getPayloadType()) { + case PAYLOAD_TYPE_ACK: { + uint32_t ack_crc; + memcpy(&ack_crc, pkt->payload, 4); + if (!_tables->hasSeen(pkt)) { + onAckRecv(pkt, ack_crc); + action = routeRecvPacket(pkt); + } + break; + } + case PAYLOAD_TYPE_PATH: + case PAYLOAD_TYPE_REQ: + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_TXT_MSG: { + int i = 0; + uint8_t dest_hash = pkt->payload[i++]; + uint8_t src_hash = pkt->payload[i++]; + + uint8_t *macAndData = &pkt->payload[i]; + if (i + CIPHER_MAC_SIZE >= (int)pkt->payload_len) { + LOG_WRN("onRecvPacket: incomplete packet (i=%d, payload_len=%d)", i, pkt->payload_len); + } else if (!_tables->hasSeen(pkt)) { + if (self_id.isHashMatch(&dest_hash)) { + int num = searchPeersByHash(&src_hash); + bool found = false; + for (int j = 0; j < num; j++) { + uint8_t secret[PUB_KEY_SIZE]; + getPeerSharedSecret(secret, j); + + uint8_t data[MAX_PACKET_PAYLOAD]; + int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + if (len > 0) { + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { + int k = 0; + uint8_t path_len = data[k++]; + if (!Packet::isValidPathLen(path_len)) { + LOG_WRN("onRecvPacket: invalid inner path_len 0x%02x", path_len); + break; + } + uint8_t hash_size = (path_len >> 6) + 1; + uint8_t hash_count = path_len & 63; + int path_bytes = hash_size * hash_count; + if (k + path_bytes + 1 > len) { + LOG_WRN("onRecvPacket: PATH payload truncated (k=%d path_bytes=%d len=%d)", k, path_bytes, len); + break; + } + uint8_t *path = &data[k]; k += path_bytes; + uint8_t extra_type = data[k++] & 0x0F; + uint8_t *extra = &data[k]; + uint8_t extra_len = (uint8_t)(len - k); + if (onPeerPathRecv(pkt, j, secret, path, path_len, extra_type, extra, extra_len)) { + if (pkt->isRouteFlood()) { + Packet *rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, nullptr, 0); + if (rpath) sendDirect(rpath, path, path_len, 500); + } + } + } else { + onPeerDataRecv(pkt, pkt->getPayloadType(), j, secret, data, len); + } + found = true; + break; + } + } + if (found) { + pkt->markDoNotRetransmit(); + } else { + LOG_WRN("onRecvPacket: no peer could decrypt message"); + } + } + action = routeRecvPacket(pkt); + } + break; + } + case PAYLOAD_TYPE_ANON_REQ: { + int i = 0; + uint8_t dest_hash = pkt->payload[i++]; + uint8_t *sender_pub_key = &pkt->payload[i]; i += PUB_KEY_SIZE; + + uint8_t *macAndData = &pkt->payload[i]; + if (i + 2 >= (int)pkt->payload_len) { + // incomplete packet + } else if (!_tables->hasSeen(pkt)) { + if (self_id.isHashMatch(&dest_hash)) { + Identity sender(sender_pub_key); + uint8_t secret[PUB_KEY_SIZE]; + self_id.calcSharedSecret(secret, sender); + + uint8_t data[MAX_PACKET_PAYLOAD]; + int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + if (len > 0) { + onAnonDataRecv(pkt, secret, sender, data, len); + pkt->markDoNotRetransmit(); + } + } + action = routeRecvPacket(pkt); + } + break; + } + case PAYLOAD_TYPE_GRP_DATA: + case PAYLOAD_TYPE_GRP_TXT: { + int i = 0; + uint8_t channel_hash = pkt->payload[i++]; + + uint8_t *macAndData = &pkt->payload[i]; + if (i + 2 >= (int)pkt->payload_len) { + // incomplete packet + } else if (!_tables->hasSeen(pkt)) { + GroupChannel channels[4]; + int num = searchChannelsByHash(&channel_hash, channels, 4); + for (int j = 0; j < num; j++) { + uint8_t data[MAX_PACKET_PAYLOAD]; + int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, pkt->payload_len - i); + if (len > 0) { + onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len); + break; + } + } + action = routeRecvPacket(pkt); + } + break; + } + case PAYLOAD_TYPE_ADVERT: { + int i = 0; + Identity id; + memcpy(id.pub_key, &pkt->payload[i], PUB_KEY_SIZE); + i += PUB_KEY_SIZE; + uint32_t timestamp; + memcpy(×tamp, &pkt->payload[i], 4); + i += 4; + const uint8_t *signature = &pkt->payload[i]; + i += SIGNATURE_SIZE; + if (i <= (int)pkt->payload_len && !self_id.matches(id.pub_key) && !_tables->hasSeen(pkt)) { + uint8_t *app_data = (uint8_t *)&pkt->payload[i]; + size_t app_data_len = pkt->payload_len - (size_t)i; + if (app_data_len > MAX_ADVERT_DATA_SIZE) app_data_len = MAX_ADVERT_DATA_SIZE; + uint8_t message[PUB_KEY_SIZE + 4 + MAX_ADVERT_DATA_SIZE]; + int msg_len = 0; + memcpy(&message[msg_len], id.pub_key, PUB_KEY_SIZE); msg_len += PUB_KEY_SIZE; + memcpy(&message[msg_len], ×tamp, 4); msg_len += 4; + memcpy(&message[msg_len], app_data, app_data_len); msg_len += app_data_len; + if (id.verify(signature, message, msg_len)) { + onAdvertRecv(pkt, id, timestamp, app_data, app_data_len); + action = routeRecvPacket(pkt); + } + } + break; + } + case PAYLOAD_TYPE_RAW_CUSTOM: + if (pkt->isRouteDirect() && !_tables->hasSeen(pkt)) { + onRawDataRecv(pkt); + } + break; + case PAYLOAD_TYPE_MULTIPART: + if (pkt->payload_len > 2) { + /* uint8_t remaining = pkt->payload[0] >> 4; */ /* Reserved for future multipart support */ + uint8_t type = pkt->payload[0] & 0x0F; + + if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { + Packet tmp; + tmp.header = pkt->header; + tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); + tmp.payload_len = pkt->payload_len - 1; + memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); + + if (!_tables->hasSeen(&tmp)) { + uint32_t ack_crc; + memcpy(&ack_crc, tmp.payload, 4); + onAckRecv(&tmp, ack_crc); + } + } + } + break; + default: + break; + } + return action; +} + +Packet *Mesh::createAdvert(const LocalIdentity &id, const uint8_t *app_data, size_t app_data_len) +{ + if (app_data_len > MAX_ADVERT_DATA_SIZE) return nullptr; + + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + + packet->header = (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + int len = 0; + memcpy(&packet->payload[len], id.pub_key, PUB_KEY_SIZE); + len += PUB_KEY_SIZE; + uint32_t emitted_timestamp = _rtc->getCurrentTime(); + memcpy(&packet->payload[len], &emitted_timestamp, 4); + len += 4; + uint8_t *signature = &packet->payload[len]; + len += SIGNATURE_SIZE; + if (app_data && app_data_len > 0) { + memcpy(&packet->payload[len], app_data, app_data_len); + len += (int)app_data_len; + } + packet->payload_len = len; + + uint8_t message[PUB_KEY_SIZE + 4 + MAX_ADVERT_DATA_SIZE]; + int msg_len = 0; + memcpy(&message[msg_len], id.pub_key, PUB_KEY_SIZE); msg_len += PUB_KEY_SIZE; + memcpy(&message[msg_len], &emitted_timestamp, 4); msg_len += 4; + if (app_data && app_data_len > 0) { + memcpy(&message[msg_len], app_data, app_data_len); msg_len += (int)app_data_len; + } + id.sign(signature, message, msg_len); + return packet; +} + +Packet *Mesh::createAck(uint32_t ack_crc) +{ + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + packet->header = (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT); + memcpy(packet->payload, &ack_crc, 4); + packet->payload_len = 4; + return packet; +} + +Packet *Mesh::createMultiAck(uint32_t ack_crc, uint8_t remaining) +{ + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + packet->header = (PAYLOAD_TYPE_MULTIPART << PH_TYPE_SHIFT); + packet->payload[0] = (remaining << 4) | PAYLOAD_TYPE_ACK; + memcpy(&packet->payload[1], &ack_crc, 4); + packet->payload_len = 5; + return packet; +} + +Packet *Mesh::createControlData(const uint8_t *data, size_t len) +{ + if (len > sizeof(Packet::payload)) return nullptr; + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + packet->header = (PAYLOAD_TYPE_CONTROL << PH_TYPE_SHIFT); + memcpy(packet->payload, data, len); + packet->payload_len = (uint16_t)len; + return packet; +} + +void Mesh::sendFlood(Packet *packet, uint32_t delay_millis, uint8_t path_hash_size) +{ + if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { + releasePacket(packet); + return; + } + if (path_hash_size == 0 || path_hash_size > 3) { + LOG_WRN("sendFlood: invalid path_hash_size"); + releasePacket(packet); + return; + } + packet->header &= ~PH_ROUTE_MASK; + packet->header |= ROUTE_TYPE_FLOOD; + packet->setPathHashSizeAndCount(path_hash_size, 0); + _tables->hasSeen(packet); +#ifdef CONFIG_ZEPHCORE_APC + { + uint32_t h = ContentionTracker::computePacketHash32(packet); + _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis()); + } +#endif + + uint8_t pri; + if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { + pri = 2; + } else if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { + pri = 3; + } else { + pri = 1; + } + sendPacket(packet, pri, delay_millis + getInitialFloodJitter(packet)); +} + +void Mesh::sendFlood(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis, uint8_t path_hash_size) +{ + if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { + releasePacket(packet); + return; + } + if (path_hash_size == 0 || path_hash_size > 3) { + LOG_WRN("sendFlood: invalid path_hash_size"); + releasePacket(packet); + return; + } + packet->header &= ~PH_ROUTE_MASK; + packet->header |= ROUTE_TYPE_TRANSPORT_FLOOD; + packet->transport_codes[0] = transport_codes[0]; + packet->transport_codes[1] = transport_codes[1]; + packet->setPathHashSizeAndCount(path_hash_size, 0); + _tables->hasSeen(packet); +#ifdef CONFIG_ZEPHCORE_APC + { + uint32_t h = ContentionTracker::computePacketHash32(packet); + _power_ctrl.trackTransmit(h, (uint32_t)_ms->getMillis()); + } +#endif + + uint8_t pri; + if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { + pri = 2; + } else if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { + pri = 3; + } else { + pri = 1; + } + sendPacket(packet, pri, delay_millis + getInitialFloodJitter(packet)); +} + +void Mesh::sendDirect(Packet *packet, const uint8_t *path, uint8_t path_len, uint32_t delay_millis) +{ + packet->header &= ~PH_ROUTE_MASK; + packet->header |= ROUTE_TYPE_DIRECT; + + uint8_t pri; + if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { + /* For TRACE packets, path is appended to end of PAYLOAD (used for SNRs) */ + memcpy(&packet->payload[packet->payload_len], path, path_len); + packet->payload_len += path_len; + packet->path_len = 0; + pri = 5; + } else { + packet->path_len = Packet::copyPath(packet->path, path, path_len); + if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { + pri = 1; + } else { + pri = 0; + } + } + + _tables->hasSeen(packet); + sendPacket(packet, pri, delay_millis); +} + +void Mesh::sendZeroHop(Packet *packet, uint32_t delay_millis) +{ + packet->header &= ~PH_ROUTE_MASK; + packet->header |= ROUTE_TYPE_DIRECT; + packet->path_len = 0; + _tables->hasSeen(packet); + sendPacket(packet, 0, delay_millis); +} + +void Mesh::sendZeroHop(Packet *packet, uint16_t *transport_codes, uint32_t delay_millis) +{ + packet->header &= ~PH_ROUTE_MASK; + packet->header |= ROUTE_TYPE_TRANSPORT_DIRECT; + packet->transport_codes[0] = transport_codes[0]; + packet->transport_codes[1] = transport_codes[1]; + packet->path_len = 0; + _tables->hasSeen(packet); + sendPacket(packet, 0, delay_millis); +} + +#define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE) + +Packet *Mesh::createPathReturn(const Identity &dest, const uint8_t *secret, const uint8_t *path, uint8_t path_len, + uint8_t extra_type, const uint8_t *extra, size_t extra_len) +{ + uint8_t dest_hash[PATH_HASH_SIZE]; + dest.copyHashTo(dest_hash); + return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len); +} + +Packet *Mesh::createPathReturn(const uint8_t *dest_hash, const uint8_t *secret, const uint8_t *path, uint8_t path_len, + uint8_t extra_type, const uint8_t *extra, size_t extra_len) +{ + uint8_t path_hash_size = (path_len >> 6) + 1; + uint8_t path_hash_count = path_len & 63; + + if (path_hash_count*path_hash_size + extra_len + 5 > MAX_COMBINED_PATH) return nullptr; + + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + + packet->header = (PAYLOAD_TYPE_PATH << PH_TYPE_SHIFT); + + int len = 0; + memcpy(&packet->payload[len], dest_hash, PATH_HASH_SIZE); len += PATH_HASH_SIZE; + len += self_id.copyHashTo(&packet->payload[len]); + + { + int data_len = 0; + uint8_t data[MAX_PACKET_PAYLOAD]; + + data[data_len++] = path_len; + memcpy(&data[data_len], path, path_hash_count*path_hash_size); data_len += path_hash_count*path_hash_size; + if (extra_len > 0) { + data[data_len++] = extra_type; + memcpy(&data[data_len], extra, extra_len); data_len += extra_len; + } else { + data[data_len++] = 0xFF; // dummy payload type + _rng->random(&data[data_len], 4); data_len += 4; + } + + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + } + + packet->payload_len = len; + return packet; +} + +Packet *Mesh::createDatagram(uint8_t type, const Identity &dest, const uint8_t *secret, const uint8_t *data, size_t data_len) +{ + if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { + if (data_len + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE - 1 > MAX_PACKET_PAYLOAD) { + LOG_WRN("createDatagram: data too large"); + return nullptr; + } + } else { + LOG_WRN("createDatagram: unsupported type %d", type); + return nullptr; + } + + Packet *packet = obtainNewPacket(); + if (packet == nullptr) { + LOG_ERR("createDatagram: packet alloc failed"); + return nullptr; + } + + packet->header = (type << PH_TYPE_SHIFT); + + int len = 0; + len += dest.copyHashTo(&packet->payload[len]); + len += self_id.copyHashTo(&packet->payload[len]); + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + + packet->payload_len = len; + return packet; +} + +Packet *Mesh::createAnonDatagram(uint8_t type, const LocalIdentity &sender, const Identity &dest, + const uint8_t *secret, const uint8_t *data, size_t data_len) +{ + if (type == PAYLOAD_TYPE_ANON_REQ) { + if (data_len + 1 + PUB_KEY_SIZE + CIPHER_BLOCK_SIZE - 1 > MAX_PACKET_PAYLOAD) return nullptr; + } else { + return nullptr; + } + + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + + packet->header = (type << PH_TYPE_SHIFT); + + int len = 0; + if (type == PAYLOAD_TYPE_ANON_REQ) { + len += dest.copyHashTo(&packet->payload[len]); + memcpy(&packet->payload[len], sender.pub_key, PUB_KEY_SIZE); len += PUB_KEY_SIZE; + } + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + + packet->payload_len = len; + return packet; +} + +Packet *Mesh::createGroupDatagram(uint8_t type, const GroupChannel &channel, const uint8_t *data, size_t data_len) +{ + if (!(type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA)) return nullptr; + if (data_len + 1 + CIPHER_BLOCK_SIZE - 1 > MAX_PACKET_PAYLOAD) return nullptr; + + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + + packet->header = (type << PH_TYPE_SHIFT); + + int len = 0; + memcpy(&packet->payload[len], channel.hash, PATH_HASH_SIZE); len += PATH_HASH_SIZE; + len += Utils::encryptThenMAC(channel.secret, &packet->payload[len], data, data_len); + + packet->payload_len = len; + return packet; +} + +Packet *Mesh::createRawData(const uint8_t *data, size_t len) +{ + if (len > sizeof(Packet::payload)) return nullptr; + + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + + packet->header = (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT); + memcpy(packet->payload, data, len); + packet->payload_len = (uint16_t)len; + + return packet; +} + +Packet *Mesh::createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags) +{ + Packet *packet = obtainNewPacket(); + if (packet == nullptr) return nullptr; + + packet->header = (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); + memcpy(packet->payload, &tag, 4); + memcpy(&packet->payload[4], &auth_code, 4); + packet->payload[8] = flags; + packet->payload_len = 9; + + return packet; +} + +} /* namespace mesh */ diff --git a/zephcore/src/Packet.cpp b/zephcore/src/Packet.cpp index 59a5564..fae759d 100644 --- a/zephcore/src/Packet.cpp +++ b/zephcore/src/Packet.cpp @@ -1,106 +1,106 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Packet implementation - */ - -#include -#include -#include - -namespace mesh { - -Packet::Packet() -{ - header = 0; - path_len = 0; - payload_len = 0; -} - -bool Packet::isValidPathLen(uint8_t path_len) -{ - uint8_t hash_count = path_len & 63; - uint8_t hash_size = (path_len >> 6) + 1; - if (hash_size == 4) return false; // Reserved for future - return hash_count * hash_size <= MAX_PATH_SIZE; -} - -size_t Packet::writePath(uint8_t *dest, const uint8_t *src, uint8_t path_len) -{ - uint8_t hash_count = path_len & 63; - uint8_t hash_size = (path_len >> 6) + 1; - size_t len = hash_count * hash_size; - if (len > MAX_PATH_SIZE) { - return 0; // Error - } - memcpy(dest, src, len); - return len; -} - -uint8_t Packet::copyPath(uint8_t *dest, const uint8_t *src, uint8_t path_len) -{ - size_t written = writePath(dest, src, path_len); - return written > 0 ? path_len : 0; -} - -int Packet::getRawLength() const -{ - return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0); -} - -void Packet::calculatePacketHash(uint8_t *hash) const -{ - uint8_t t = getPayloadType(); - if (t == PAYLOAD_TYPE_TRACE) { - uint8_t buf[2 + MAX_PACKET_PAYLOAD]; - buf[0] = t; - memcpy(buf + 1, &path_len, sizeof(path_len)); - memcpy(buf + 2, payload, payload_len); - Utils::sha256(hash, MAX_HASH_SIZE, buf, 2 + payload_len); - } else { - uint8_t buf[1 + MAX_PACKET_PAYLOAD]; - buf[0] = t; - memcpy(buf + 1, payload, payload_len); - Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + payload_len); - } -} - -uint8_t Packet::writeTo(uint8_t dest[]) const -{ - uint8_t i = 0; - dest[i++] = header; - if (hasTransportCodes()) { - memcpy(&dest[i], &transport_codes[0], 2); i += 2; - memcpy(&dest[i], &transport_codes[1], 2); i += 2; - } - dest[i++] = path_len; - i += writePath(&dest[i], path, path_len); - memcpy(&dest[i], payload, payload_len); i += payload_len; - return i; -} - -bool Packet::readFrom(const uint8_t src[], uint8_t len) -{ - uint8_t i = 0; - if (len < 2) return false; - header = src[i++]; - if (hasTransportCodes()) { - if (len < 6) return false; - memcpy(&transport_codes[0], &src[i], 2); i += 2; - memcpy(&transport_codes[1], &src[i], 2); i += 2; - } else { - transport_codes[0] = transport_codes[1] = 0; - } - path_len = src[i++]; - if (!isValidPathLen(path_len)) return false; - - uint8_t bl = getPathByteLen(); - if ((uint16_t)i + bl > len) return false; - memcpy(path, &src[i], bl); i += bl; - if (i >= len) return false; - payload_len = len - i; - if (payload_len > sizeof(payload)) return false; - memcpy(payload, &src[i], payload_len); - return true; -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Packet implementation + */ + +#include +#include +#include + +namespace mesh { + +Packet::Packet() +{ + header = 0; + path_len = 0; + payload_len = 0; +} + +bool Packet::isValidPathLen(uint8_t path_len) +{ + uint8_t hash_count = path_len & 63; + uint8_t hash_size = (path_len >> 6) + 1; + if (hash_size == 4) return false; // Reserved for future + return hash_count * hash_size <= MAX_PATH_SIZE; +} + +size_t Packet::writePath(uint8_t *dest, const uint8_t *src, uint8_t path_len) +{ + uint8_t hash_count = path_len & 63; + uint8_t hash_size = (path_len >> 6) + 1; + size_t len = hash_count * hash_size; + if (len > MAX_PATH_SIZE) { + return 0; // Error + } + memcpy(dest, src, len); + return len; +} + +uint8_t Packet::copyPath(uint8_t *dest, const uint8_t *src, uint8_t path_len) +{ + size_t written = writePath(dest, src, path_len); + return written > 0 ? path_len : 0; +} + +int Packet::getRawLength() const +{ + return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0); +} + +void Packet::calculatePacketHash(uint8_t *hash) const +{ + uint8_t t = getPayloadType(); + if (t == PAYLOAD_TYPE_TRACE) { + uint8_t buf[2 + MAX_PACKET_PAYLOAD]; + buf[0] = t; + memcpy(buf + 1, &path_len, sizeof(path_len)); + memcpy(buf + 2, payload, payload_len); + Utils::sha256(hash, MAX_HASH_SIZE, buf, 2 + payload_len); + } else { + uint8_t buf[1 + MAX_PACKET_PAYLOAD]; + buf[0] = t; + memcpy(buf + 1, payload, payload_len); + Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + payload_len); + } +} + +uint8_t Packet::writeTo(uint8_t dest[]) const +{ + uint8_t i = 0; + dest[i++] = header; + if (hasTransportCodes()) { + memcpy(&dest[i], &transport_codes[0], 2); i += 2; + memcpy(&dest[i], &transport_codes[1], 2); i += 2; + } + dest[i++] = path_len; + i += writePath(&dest[i], path, path_len); + memcpy(&dest[i], payload, payload_len); i += payload_len; + return i; +} + +bool Packet::readFrom(const uint8_t src[], uint8_t len) +{ + uint8_t i = 0; + if (len < 2) return false; + header = src[i++]; + if (hasTransportCodes()) { + if (len < 6) return false; + memcpy(&transport_codes[0], &src[i], 2); i += 2; + memcpy(&transport_codes[1], &src[i], 2); i += 2; + } else { + transport_codes[0] = transport_codes[1] = 0; + } + path_len = src[i++]; + if (!isValidPathLen(path_len)) return false; + + uint8_t bl = getPathByteLen(); + if ((uint16_t)i + bl > len) return false; + memcpy(path, &src[i], bl); i += bl; + if (i >= len) return false; + payload_len = len - i; + if (payload_len > sizeof(payload)) return false; + memcpy(payload, &src[i], payload_len); + return true; +} + +} /* namespace mesh */ diff --git a/zephcore/src/PowerController.cpp b/zephcore/src/PowerController.cpp index c9f3bc4..a3bdbdb 100644 --- a/zephcore/src/PowerController.cpp +++ b/zephcore/src/PowerController.cpp @@ -1,278 +1,278 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Adaptive Power Control — echo-based TX power reduction - */ - -#include -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_apc, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); - -/* SNR thresholds per SF (x4 fixed point, matching radio_common.h) */ -static constexpr int8_t snr_threshold_x4[] = { - -30, /* SF7: -7.5 dB */ - -40, /* SF8: -10.0 dB */ - -50, /* SF9: -12.5 dB */ - -60, /* SF10: -15.0 dB */ - -70, /* SF11: -17.5 dB */ - -80, /* SF12: -20.0 dB */ -}; - -namespace mesh { - -PowerController::PowerController() - : _next_idx(0), _margin_ema_x256(0), _finalized_count(0), - _last_echo_ms(0), _power_reduction_db(0), _enabled(true), - _sf(8), _last_source_count(0), _target_margin_x4(DEFAULT_TARGET_MARGIN_X4) -{ - memset(_ring, 0, sizeof(_ring)); -} - -void PowerController::setEnabled(bool en) -{ - if (_enabled == en) return; - _enabled = en; - if (!_enabled) { - /* Drop APC runtime state while disabled so no tracking work runs. */ - memset(_ring, 0, sizeof(_ring)); - _next_idx = 0; - _margin_ema_x256 = 0; - _finalized_count = 0; - _last_echo_ms = 0; - _last_source_count = 0; - _power_reduction_db = 0; - } -} - -int8_t PowerController::sfThresholdX4(uint8_t sf) -{ - int idx = (int)sf - 7; - if (idx < 0) idx = 0; - if (idx > 5) idx = 5; - return snr_threshold_x4[idx]; -} - -int PowerController::findEntry(uint32_t hash32) const -{ - for (int i = 0; i < RING_SIZE; i++) { - if (_ring[i].active && _ring[i].hash32 == hash32) { - return i; - } - } - return -1; -} - -void PowerController::trackTransmit(uint32_t hash32, uint32_t now_ms) -{ - if (!_enabled) return; - - /* If ring slot is occupied, finalize it first */ - if (_ring[_next_idx].active) { - finalizeEntry(_next_idx); - } - - EchoEntry &e = _ring[_next_idx]; - e.hash32 = hash32; - e.timestamp_ms = now_ms; - e.source_count = 0; - e.sf_at_track = _sf; - memset(e.sources, 0, sizeof(e.sources)); - e.active = true; - - _next_idx = (_next_idx + 1) % RING_SIZE; -} - -bool PowerController::recordEcho(uint32_t hash32, int8_t snr_x4, - uint8_t first_hop_hash, uint32_t now_ms) -{ - if (!_enabled) return false; - - int idx = findEntry(hash32); - if (idx < 0) return false; - - EchoEntry &e = _ring[idx]; - - /* Check if entry has expired */ - if (now_ms - e.timestamp_ms > ECHO_WINDOW_MS) { - finalizeEntry(idx); - return false; - } - - /* Update existing source or add new one */ - for (int i = 0; i < e.source_count; i++) { - if (e.sources[i].hash == first_hop_hash) { - if (snr_x4 > e.sources[i].snr_x4) { - e.sources[i].snr_x4 = snr_x4; - } - _last_echo_ms = now_ms; - return true; - } - } - - if (e.source_count < MAX_SOURCES) { - e.sources[e.source_count].hash = first_hop_hash; - e.sources[e.source_count].snr_x4 = snr_x4; - e.source_count++; - } - - _last_echo_ms = now_ms; - return true; -} - -int8_t PowerController::computeRobustSNR(const EchoEntry &entry) const -{ - if (entry.source_count == 0) { - return sfThresholdX4(entry.sf_at_track); /* no echo = margin 0 */ - } - - if (entry.source_count == 1) { - return entry.sources[0].snr_x4; - } - - /* 2-3 sources: sort descending, then cluster + rogue filter */ - int8_t sorted[MAX_SOURCES]; - int n = entry.source_count; - for (int i = 0; i < n; i++) { - sorted[i] = entry.sources[i].snr_x4; - } - /* Simple insertion sort (max 3 elements) */ - for (int i = 1; i < n; i++) { - int8_t key = sorted[i]; - int j = i - 1; - while (j >= 0 && sorted[j] < key) { - sorted[j + 1] = sorted[j]; - j--; - } - sorted[j + 1] = key; - } - - /* Count how many are within CLUSTER_WIDTH of the best */ - int cluster_count = 1; - for (int i = 1; i < n; i++) { - if (sorted[0] - sorted[i] <= CLUSTER_WIDTH_X4) { - cluster_count++; - } - } - - if (cluster_count >= 2) { - /* 2+ in cluster: median of the cluster values */ - /* For 2 values: average. For 3 values: middle one. */ - if (cluster_count == 2) { - return (int8_t)(((int)sorted[0] + (int)sorted[1]) / 2); - } - /* cluster_count == 3 (all 3 within 6 dB) */ - return sorted[1]; /* median */ - } - - /* Only 1 in top cluster → rogue. Drop it, use next. */ - if (n >= 3 && sorted[1] - sorted[2] <= CLUSTER_WIDTH_X4) { - /* sources[1] and [2] cluster together — median them */ - return (int8_t)(((int)sorted[1] + (int)sorted[2]) / 2); - } - /* Fall back to second-best */ - return sorted[1]; -} - -void PowerController::finalizeEntry(int idx) -{ - if (!_ring[idx].active) return; - - EchoEntry &e = _ring[idx]; - _last_source_count = e.source_count; - - int8_t robust_snr = computeRobustSNR(e); - int32_t margin_x4 = (int32_t)robust_snr - (int32_t)sfThresholdX4(e.sf_at_track); - /* margin_x4 is in x4 units. Convert to x256 for EMA. */ - int32_t sample_x256 = margin_x4 << 6; /* x4 * 64 = x256 */ - - int32_t diff = sample_x256 - _margin_ema_x256; - - if (_finalized_count < WARMUP_COUNT) { - /* During warmup, seed the EMA faster */ - if (_finalized_count == 0) { - _margin_ema_x256 = sample_x256; - } else { - _margin_ema_x256 += diff >> 1; - } - } else { - /* Normal EMA update: ema += (sample - ema) >> shift */ - _margin_ema_x256 += diff >> EMA_SHIFT; - } - - _finalized_count++; - e.active = false; - - LOG_DBG("APC finalize: sources=%d robust_snr=%.1f margin=%.1f ema=%.1f", - (int)_last_source_count, - (double)(robust_snr / 4.0f), - (double)(margin_x4 / 4.0f), - (double)getMarginEstimate()); -} - -void PowerController::tick(uint32_t now_ms) -{ - if (!_enabled) return; - - /* Finalize expired entries */ - for (int i = 0; i < RING_SIZE; i++) { - if (_ring[i].active && now_ms - _ring[i].timestamp_ms > ECHO_WINDOW_MS) { - finalizeEntry(i); - } - } - - if (!isWarmedUp()) return; - - int32_t margin_x256 = _margin_ema_x256; - int32_t target_x256 = _target_margin_x4 << 6; - int32_t hyst_x256 = HYSTERESIS_X4 << 6; - - int8_t old_reduction = _power_reduction_db; - - /* Staleness takes priority: ramp back to full power if no echoes. - * When stale, never increase reduction — old EMA data is unreliable. */ - if (isStale(now_ms)) { - if (_power_reduction_db > 0) { - _power_reduction_db -= STEP_DOWN_DB; - if (_power_reduction_db < 0) { - _power_reduction_db = 0; - } - } - } else if (margin_x256 > target_x256 + hyst_x256) { - /* Margin very good — step down */ - if (_power_reduction_db < MAX_REDUCTION_DB) { - _power_reduction_db += STEP_DOWN_DB; - if (_power_reduction_db > MAX_REDUCTION_DB) { - _power_reduction_db = MAX_REDUCTION_DB; - } - } - } else if (margin_x256 < target_x256 - hyst_x256) { - /* Margin too low — step up (reduce the reduction) */ - if (_power_reduction_db > 0) { - _power_reduction_db -= STEP_UP_DB; - if (_power_reduction_db < 0) { - _power_reduction_db = 0; - } - } - } - - if (_power_reduction_db != old_reduction) { - LOG_INF("APC: reduction %d -> %d dBm (margin=%.1f)", - (int)old_reduction, (int)_power_reduction_db, - (double)getMarginEstimate()); - } -} - -float PowerController::getMarginEstimate() const -{ - return (float)_margin_ema_x256 / 256.0f; -} - -bool PowerController::isStale(uint32_t now_ms) const -{ - if (_last_echo_ms == 0) return false; - return now_ms - _last_echo_ms > STALE_MS; -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * Adaptive Power Control — echo-based TX power reduction + */ + +#include +#include +#include + +#include +LOG_MODULE_REGISTER(zephcore_apc, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +/* SNR thresholds per SF (x4 fixed point, matching radio_common.h) */ +static constexpr int8_t snr_threshold_x4[] = { + -30, /* SF7: -7.5 dB */ + -40, /* SF8: -10.0 dB */ + -50, /* SF9: -12.5 dB */ + -60, /* SF10: -15.0 dB */ + -70, /* SF11: -17.5 dB */ + -80, /* SF12: -20.0 dB */ +}; + +namespace mesh { + +PowerController::PowerController() + : _next_idx(0), _margin_ema_x256(0), _finalized_count(0), + _last_echo_ms(0), _power_reduction_db(0), _enabled(true), + _sf(8), _last_source_count(0), _target_margin_x4(DEFAULT_TARGET_MARGIN_X4) +{ + memset(_ring, 0, sizeof(_ring)); +} + +void PowerController::setEnabled(bool en) +{ + if (_enabled == en) return; + _enabled = en; + if (!_enabled) { + /* Drop APC runtime state while disabled so no tracking work runs. */ + memset(_ring, 0, sizeof(_ring)); + _next_idx = 0; + _margin_ema_x256 = 0; + _finalized_count = 0; + _last_echo_ms = 0; + _last_source_count = 0; + _power_reduction_db = 0; + } +} + +int8_t PowerController::sfThresholdX4(uint8_t sf) +{ + int idx = (int)sf - 7; + if (idx < 0) idx = 0; + if (idx > 5) idx = 5; + return snr_threshold_x4[idx]; +} + +int PowerController::findEntry(uint32_t hash32) const +{ + for (int i = 0; i < RING_SIZE; i++) { + if (_ring[i].active && _ring[i].hash32 == hash32) { + return i; + } + } + return -1; +} + +void PowerController::trackTransmit(uint32_t hash32, uint32_t now_ms) +{ + if (!_enabled) return; + + /* If ring slot is occupied, finalize it first */ + if (_ring[_next_idx].active) { + finalizeEntry(_next_idx); + } + + EchoEntry &e = _ring[_next_idx]; + e.hash32 = hash32; + e.timestamp_ms = now_ms; + e.source_count = 0; + e.sf_at_track = _sf; + memset(e.sources, 0, sizeof(e.sources)); + e.active = true; + + _next_idx = (_next_idx + 1) % RING_SIZE; +} + +bool PowerController::recordEcho(uint32_t hash32, int8_t snr_x4, + uint8_t first_hop_hash, uint32_t now_ms) +{ + if (!_enabled) return false; + + int idx = findEntry(hash32); + if (idx < 0) return false; + + EchoEntry &e = _ring[idx]; + + /* Check if entry has expired */ + if (now_ms - e.timestamp_ms > ECHO_WINDOW_MS) { + finalizeEntry(idx); + return false; + } + + /* Update existing source or add new one */ + for (int i = 0; i < e.source_count; i++) { + if (e.sources[i].hash == first_hop_hash) { + if (snr_x4 > e.sources[i].snr_x4) { + e.sources[i].snr_x4 = snr_x4; + } + _last_echo_ms = now_ms; + return true; + } + } + + if (e.source_count < MAX_SOURCES) { + e.sources[e.source_count].hash = first_hop_hash; + e.sources[e.source_count].snr_x4 = snr_x4; + e.source_count++; + } + + _last_echo_ms = now_ms; + return true; +} + +int8_t PowerController::computeRobustSNR(const EchoEntry &entry) const +{ + if (entry.source_count == 0) { + return sfThresholdX4(entry.sf_at_track); /* no echo = margin 0 */ + } + + if (entry.source_count == 1) { + return entry.sources[0].snr_x4; + } + + /* 2-3 sources: sort descending, then cluster + rogue filter */ + int8_t sorted[MAX_SOURCES]; + int n = entry.source_count; + for (int i = 0; i < n; i++) { + sorted[i] = entry.sources[i].snr_x4; + } + /* Simple insertion sort (max 3 elements) */ + for (int i = 1; i < n; i++) { + int8_t key = sorted[i]; + int j = i - 1; + while (j >= 0 && sorted[j] < key) { + sorted[j + 1] = sorted[j]; + j--; + } + sorted[j + 1] = key; + } + + /* Count how many are within CLUSTER_WIDTH of the best */ + int cluster_count = 1; + for (int i = 1; i < n; i++) { + if (sorted[0] - sorted[i] <= CLUSTER_WIDTH_X4) { + cluster_count++; + } + } + + if (cluster_count >= 2) { + /* 2+ in cluster: median of the cluster values */ + /* For 2 values: average. For 3 values: middle one. */ + if (cluster_count == 2) { + return (int8_t)(((int)sorted[0] + (int)sorted[1]) / 2); + } + /* cluster_count == 3 (all 3 within 6 dB) */ + return sorted[1]; /* median */ + } + + /* Only 1 in top cluster → rogue. Drop it, use next. */ + if (n >= 3 && sorted[1] - sorted[2] <= CLUSTER_WIDTH_X4) { + /* sources[1] and [2] cluster together — median them */ + return (int8_t)(((int)sorted[1] + (int)sorted[2]) / 2); + } + /* Fall back to second-best */ + return sorted[1]; +} + +void PowerController::finalizeEntry(int idx) +{ + if (!_ring[idx].active) return; + + EchoEntry &e = _ring[idx]; + _last_source_count = e.source_count; + + int8_t robust_snr = computeRobustSNR(e); + int32_t margin_x4 = (int32_t)robust_snr - (int32_t)sfThresholdX4(e.sf_at_track); + /* margin_x4 is in x4 units. Convert to x256 for EMA. */ + int32_t sample_x256 = margin_x4 << 6; /* x4 * 64 = x256 */ + + int32_t diff = sample_x256 - _margin_ema_x256; + + if (_finalized_count < WARMUP_COUNT) { + /* During warmup, seed the EMA faster */ + if (_finalized_count == 0) { + _margin_ema_x256 = sample_x256; + } else { + _margin_ema_x256 += diff >> 1; + } + } else { + /* Normal EMA update: ema += (sample - ema) >> shift */ + _margin_ema_x256 += diff >> EMA_SHIFT; + } + + _finalized_count++; + e.active = false; + + LOG_DBG("APC finalize: sources=%d robust_snr=%.1f margin=%.1f ema=%.1f", + (int)_last_source_count, + (double)(robust_snr / 4.0f), + (double)(margin_x4 / 4.0f), + (double)getMarginEstimate()); +} + +void PowerController::tick(uint32_t now_ms) +{ + if (!_enabled) return; + + /* Finalize expired entries */ + for (int i = 0; i < RING_SIZE; i++) { + if (_ring[i].active && now_ms - _ring[i].timestamp_ms > ECHO_WINDOW_MS) { + finalizeEntry(i); + } + } + + if (!isWarmedUp()) return; + + int32_t margin_x256 = _margin_ema_x256; + int32_t target_x256 = _target_margin_x4 << 6; + int32_t hyst_x256 = HYSTERESIS_X4 << 6; + + int8_t old_reduction = _power_reduction_db; + + /* Staleness takes priority: ramp back to full power if no echoes. + * When stale, never increase reduction — old EMA data is unreliable. */ + if (isStale(now_ms)) { + if (_power_reduction_db > 0) { + _power_reduction_db -= STEP_DOWN_DB; + if (_power_reduction_db < 0) { + _power_reduction_db = 0; + } + } + } else if (margin_x256 > target_x256 + hyst_x256) { + /* Margin very good — step down */ + if (_power_reduction_db < MAX_REDUCTION_DB) { + _power_reduction_db += STEP_DOWN_DB; + if (_power_reduction_db > MAX_REDUCTION_DB) { + _power_reduction_db = MAX_REDUCTION_DB; + } + } + } else if (margin_x256 < target_x256 - hyst_x256) { + /* Margin too low — step up (reduce the reduction) */ + if (_power_reduction_db > 0) { + _power_reduction_db -= STEP_UP_DB; + if (_power_reduction_db < 0) { + _power_reduction_db = 0; + } + } + } + + if (_power_reduction_db != old_reduction) { + LOG_INF("APC: reduction %d -> %d dBm (margin=%.1f)", + (int)old_reduction, (int)_power_reduction_db, + (double)getMarginEstimate()); + } +} + +float PowerController::getMarginEstimate() const +{ + return (float)_margin_ema_x256 / 256.0f; +} + +bool PowerController::isStale(uint32_t now_ms) const +{ + if (_last_echo_ms == 0) return false; + return now_ms - _last_echo_ms > STALE_MS; +} + +} /* namespace mesh */