From ac6ceca9f8cce4e09ce69afaa2fc7c10c0bcd23e Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Fri, 6 Feb 2026 19:39:52 -0500 Subject: [PATCH] Initial commit: standalone Pyxis T-Deck firmware Split T-Deck firmware from microReticulum examples/lxmf_tdeck/ into its own repo. microReticulum is consumed as a git submodule dependency pinned to feat/t-deck. All include paths updated from relative symlinks to bare includes resolved via library build flags. Both tdeck (NimBLE) and tdeck-bluedroid environments compile successfully. Licensed under AGPLv3. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release-firmware.yml | 108 + .gitignore | 3 + .gitmodules | 4 + LICENSE | 661 +++++ deps/microReticulum | 1 + docs/flasher/index.html | 563 +++++ docs/flasher/manifest-install.json | 16 + docs/flasher/manifest-update.json | 13 + lib/auto_interface/AutoInterface.cpp | 1537 ++++++++++++ lib/auto_interface/AutoInterface.h | 175 ++ lib/auto_interface/AutoInterfacePeer.h | 74 + lib/auto_interface/auto_config.h.example | 19 + lib/auto_interface/library.json | 15 + lib/ble_interface/BLEFragmenter.cpp | 177 ++ lib/ble_interface/BLEFragmenter.h | 125 + lib/ble_interface/BLEIdentityManager.cpp | 493 ++++ lib/ble_interface/BLEIdentityManager.h | 352 +++ lib/ble_interface/BLEInterface.cpp | 946 ++++++++ lib/ble_interface/BLEInterface.h | 280 +++ lib/ble_interface/BLEOperationQueue.cpp | 227 ++ lib/ble_interface/BLEOperationQueue.h | 144 ++ lib/ble_interface/BLEPeerManager.cpp | 870 +++++++ lib/ble_interface/BLEPeerManager.h | 483 ++++ lib/ble_interface/BLEPlatform.cpp | 69 + lib/ble_interface/BLEPlatform.h | 367 +++ lib/ble_interface/BLEReassembler.cpp | 326 +++ lib/ble_interface/BLEReassembler.h | 199 ++ lib/ble_interface/BLETypes.h | 502 ++++ lib/ble_interface/library.json | 15 + .../platforms/BluedroidPlatform.cpp | 1963 +++++++++++++++ .../platforms/BluedroidPlatform.h | 330 +++ .../platforms/NimBLEPlatform.cpp | 2120 +++++++++++++++++ lib/ble_interface/platforms/NimBLEPlatform.h | 380 +++ lib/lv_conf.h | 188 ++ lib/lv_mem_hybrid.h | 93 + lib/sx1262_interface/SX1262Interface.cpp | 288 +++ lib/sx1262_interface/SX1262Interface.h | 105 + lib/tdeck_ui/Display.cpp | 411 ++++ lib/tdeck_ui/Display.h | 139 ++ lib/tdeck_ui/DisplayGraphics.h | 226 ++ lib/tdeck_ui/Hardware/TDeck/Config.h | 171 ++ lib/tdeck_ui/Hardware/TDeck/Display.cpp | 269 +++ lib/tdeck_ui/Hardware/TDeck/Display.h | 153 ++ lib/tdeck_ui/Hardware/TDeck/Keyboard.cpp | 293 +++ lib/tdeck_ui/Hardware/TDeck/Keyboard.h | 166 ++ lib/tdeck_ui/Hardware/TDeck/SDLogger.cpp | 193 ++ lib/tdeck_ui/Hardware/TDeck/SDLogger.h | 89 + lib/tdeck_ui/Hardware/TDeck/Touch.cpp | 323 +++ lib/tdeck_ui/Hardware/TDeck/Touch.h | 120 + lib/tdeck_ui/Hardware/TDeck/Trackball.cpp | 348 +++ lib/tdeck_ui/Hardware/TDeck/Trackball.h | 132 + lib/tdeck_ui/UI/Clipboard.cpp | 8 + lib/tdeck_ui/UI/Clipboard.h | 53 + lib/tdeck_ui/UI/LVGL/LVGLInit.cpp | 330 +++ lib/tdeck_ui/UI/LVGL/LVGLInit.h | 153 ++ lib/tdeck_ui/UI/LVGL/LVGLLock.h | 80 + lib/tdeck_ui/UI/LXMF/AnnounceListScreen.cpp | 456 ++++ lib/tdeck_ui/UI/LXMF/AnnounceListScreen.h | 154 ++ lib/tdeck_ui/UI/LXMF/ChatScreen.cpp | 686 ++++++ lib/tdeck_ui/UI/LXMF/ChatScreen.h | 189 ++ lib/tdeck_ui/UI/LXMF/ComposeScreen.cpp | 320 +++ lib/tdeck_ui/UI/LXMF/ComposeScreen.h | 129 + .../UI/LXMF/ConversationListScreen.cpp | 723 ++++++ lib/tdeck_ui/UI/LXMF/ConversationListScreen.h | 233 ++ .../UI/LXMF/PropagationNodesScreen.cpp | 418 ++++ lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.h | 174 ++ lib/tdeck_ui/UI/LXMF/QRScreen.cpp | 180 ++ lib/tdeck_ui/UI/LXMF/QRScreen.h | 109 + lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp | 1439 +++++++++++ lib/tdeck_ui/UI/LXMF/SettingsScreen.h | 347 +++ lib/tdeck_ui/UI/LXMF/StatusScreen.cpp | 365 +++ lib/tdeck_ui/UI/LXMF/StatusScreen.h | 171 ++ lib/tdeck_ui/UI/LXMF/Theme.h | 69 + lib/tdeck_ui/UI/LXMF/UIManager.cpp | 651 +++++ lib/tdeck_ui/UI/LXMF/UIManager.h | 227 ++ lib/tdeck_ui/UI/TextAreaHelper.h | 70 + lib/tdeck_ui/library.json | 25 + lib/tone/Tone.cpp | 133 ++ lib/tone/Tone.h | 42 + lib/tone/library.json | 5 + .../UniversalFileSystem.cpp | 529 ++++ .../UniversalFileSystem.h | 187 ++ lib/universal_filesystem/library.json | 7 + partitions.csv | 7 + platformio.ini | 147 ++ sdkconfig.defaults | 78 + src/HDLC.h | 105 + src/TCPClientInterface.cpp | 496 ++++ src/TCPClientInterface.h | 124 + src/main.cpp | 1437 +++++++++++ 90 files changed, 28320 insertions(+) create mode 100644 .github/workflows/release-firmware.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 LICENSE create mode 160000 deps/microReticulum create mode 100644 docs/flasher/index.html create mode 100644 docs/flasher/manifest-install.json create mode 100644 docs/flasher/manifest-update.json create mode 100644 lib/auto_interface/AutoInterface.cpp create mode 100644 lib/auto_interface/AutoInterface.h create mode 100644 lib/auto_interface/AutoInterfacePeer.h create mode 100644 lib/auto_interface/auto_config.h.example create mode 100644 lib/auto_interface/library.json create mode 100644 lib/ble_interface/BLEFragmenter.cpp create mode 100644 lib/ble_interface/BLEFragmenter.h create mode 100644 lib/ble_interface/BLEIdentityManager.cpp create mode 100644 lib/ble_interface/BLEIdentityManager.h create mode 100644 lib/ble_interface/BLEInterface.cpp create mode 100644 lib/ble_interface/BLEInterface.h create mode 100644 lib/ble_interface/BLEOperationQueue.cpp create mode 100644 lib/ble_interface/BLEOperationQueue.h create mode 100644 lib/ble_interface/BLEPeerManager.cpp create mode 100644 lib/ble_interface/BLEPeerManager.h create mode 100644 lib/ble_interface/BLEPlatform.cpp create mode 100644 lib/ble_interface/BLEPlatform.h create mode 100644 lib/ble_interface/BLEReassembler.cpp create mode 100644 lib/ble_interface/BLEReassembler.h create mode 100644 lib/ble_interface/BLETypes.h create mode 100644 lib/ble_interface/library.json create mode 100644 lib/ble_interface/platforms/BluedroidPlatform.cpp create mode 100644 lib/ble_interface/platforms/BluedroidPlatform.h create mode 100644 lib/ble_interface/platforms/NimBLEPlatform.cpp create mode 100644 lib/ble_interface/platforms/NimBLEPlatform.h create mode 100644 lib/lv_conf.h create mode 100644 lib/lv_mem_hybrid.h create mode 100644 lib/sx1262_interface/SX1262Interface.cpp create mode 100644 lib/sx1262_interface/SX1262Interface.h create mode 100644 lib/tdeck_ui/Display.cpp create mode 100644 lib/tdeck_ui/Display.h create mode 100644 lib/tdeck_ui/DisplayGraphics.h create mode 100644 lib/tdeck_ui/Hardware/TDeck/Config.h create mode 100644 lib/tdeck_ui/Hardware/TDeck/Display.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/Display.h create mode 100644 lib/tdeck_ui/Hardware/TDeck/Keyboard.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/Keyboard.h create mode 100644 lib/tdeck_ui/Hardware/TDeck/SDLogger.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/SDLogger.h create mode 100644 lib/tdeck_ui/Hardware/TDeck/Touch.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/Touch.h create mode 100644 lib/tdeck_ui/Hardware/TDeck/Trackball.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/Trackball.h create mode 100644 lib/tdeck_ui/UI/Clipboard.cpp create mode 100644 lib/tdeck_ui/UI/Clipboard.h create mode 100644 lib/tdeck_ui/UI/LVGL/LVGLInit.cpp create mode 100644 lib/tdeck_ui/UI/LVGL/LVGLInit.h create mode 100644 lib/tdeck_ui/UI/LVGL/LVGLLock.h create mode 100644 lib/tdeck_ui/UI/LXMF/AnnounceListScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/AnnounceListScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/ChatScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/ChatScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/ComposeScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/ComposeScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/ConversationListScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/QRScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/QRScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/SettingsScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/StatusScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/StatusScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/Theme.h create mode 100644 lib/tdeck_ui/UI/LXMF/UIManager.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/UIManager.h create mode 100644 lib/tdeck_ui/UI/TextAreaHelper.h create mode 100644 lib/tdeck_ui/library.json create mode 100644 lib/tone/Tone.cpp create mode 100644 lib/tone/Tone.h create mode 100644 lib/tone/library.json create mode 100644 lib/universal_filesystem/UniversalFileSystem.cpp create mode 100644 lib/universal_filesystem/UniversalFileSystem.h create mode 100644 lib/universal_filesystem/library.json create mode 100644 partitions.csv create mode 100644 platformio.ini create mode 100644 sdkconfig.defaults create mode 100644 src/HDLC.h create mode 100644 src/TCPClientInterface.cpp create mode 100644 src/TCPClientInterface.h create mode 100644 src/main.cpp diff --git a/.github/workflows/release-firmware.yml b/.github/workflows/release-firmware.yml new file mode 100644 index 0000000..e20ba5b --- /dev/null +++ b/.github/workflows/release-firmware.yml @@ -0,0 +1,108 @@ +name: Build and Release Firmware + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g., v1.0.0)' + required: true + +permissions: + contents: write + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PlatformIO and esptool + run: | + pip install platformio esptool + + - name: Build T-Deck firmware + run: pio run -e tdeck-bluedroid + + - name: Copy firmware binaries + run: | + mkdir -p docs/flasher/firmware + cp .pio/build/tdeck-bluedroid/bootloader.bin docs/flasher/firmware/ + cp .pio/build/tdeck-bluedroid/partitions.bin docs/flasher/firmware/ + cp .pio/build/tdeck-bluedroid/firmware.bin docs/flasher/firmware/ + BOOT_APP0=$(find ~/.platformio -name "boot_app0.bin" | head -1) + cp "$BOOT_APP0" docs/flasher/firmware/ + + - name: Get version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + fi + + - name: Update manifest files with version + run: | + VERSION=${{ steps.version.outputs.VERSION }} + # Full install manifest + cat > docs/flasher/manifest-install.json << EOF + { + "name": "Pyxis T-Deck", + "version": "${VERSION}", + "builds": [ + { + "chipFamily": "ESP32-S3", + "parts": [ + { "path": "firmware/bootloader.bin", "offset": 0 }, + { "path": "firmware/partitions.bin", "offset": 32768 }, + { "path": "firmware/boot_app0.bin", "offset": 57344 }, + { "path": "firmware/firmware.bin", "offset": 65536 } + ] + } + ] + } + EOF + # Update-only manifest + cat > docs/flasher/manifest-update.json << EOF + { + "name": "Pyxis T-Deck", + "version": "${VERSION}", + "builds": [ + { + "chipFamily": "ESP32-S3", + "parts": [ + { "path": "firmware/firmware.bin", "offset": 65536 } + ] + } + ] + } + EOF + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/flasher + destination_dir: flasher + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + docs/flasher/firmware/firmware.bin + generate_release_notes: true + name: ${{ steps.version.outputs.VERSION }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5706dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.pio/ +.vscode/ +*.pyc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..88f3dc4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "deps/microReticulum"] + path = deps/microReticulum + url = https://github.com/torlando-tech/microReticulum.git + branch = feat/t-deck diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/deps/microReticulum b/deps/microReticulum new file mode 160000 index 0000000..ad1776d --- /dev/null +++ b/deps/microReticulum @@ -0,0 +1 @@ +Subproject commit ad1776dae68e11b3ce2d0c50942680bb269d4e8f diff --git a/docs/flasher/index.html b/docs/flasher/index.html new file mode 100644 index 0000000..fbd74fc --- /dev/null +++ b/docs/flasher/index.html @@ -0,0 +1,563 @@ + + + + + + Pyxis T-Deck Flasher + + + +
+
+

Pyxis T-Deck Flasher

+

Flash your LilyGO T-Deck Plus with Pyxis firmware

+
+ +
+

Install Firmware

+ +
+
Device Status
+
+ Not connected +
+
+ +
+ + + +
+ + + + + +
+ +

Firmware version: dev

+
+ +
+

Requirements

+
    +
  • Browser: Chrome, Edge, or Opera (Web Serial API required)
  • +
  • Device: LilyGO T-Deck Plus (ESP32-S3, 8MB Flash)
  • +
  • Cable: USB-C data cable (not charge-only)
  • +
+
+ +
+

How It Works

+
    +
  • 1. Click "Connect & Detect" and select your T-Deck
  • +
  • 2. Flasher checks if microReticulum is installed
  • +
  • 3. Click "Flash Firmware" - settings preserved by default
  • +
  • 4. Only check "Full install" for a clean wipe
  • +
+ +
+ Tip: Update mode only flashes the app, preserving your settings and messages. +
+
+ + +
+ + + + diff --git a/docs/flasher/manifest-install.json b/docs/flasher/manifest-install.json new file mode 100644 index 0000000..836b195 --- /dev/null +++ b/docs/flasher/manifest-install.json @@ -0,0 +1,16 @@ +{ + "name": "Pyxis T-Deck", + "version": "dev", + "new_install_prompt_erase": false, + "builds": [ + { + "chipFamily": "ESP32-S3", + "parts": [ + { "path": "firmware/bootloader.bin", "offset": 0 }, + { "path": "firmware/partitions.bin", "offset": 32768 }, + { "path": "firmware/boot_app0.bin", "offset": 57344 }, + { "path": "firmware/firmware.bin", "offset": 65536 } + ] + } + ] +} diff --git a/docs/flasher/manifest-update.json b/docs/flasher/manifest-update.json new file mode 100644 index 0000000..dbc152f --- /dev/null +++ b/docs/flasher/manifest-update.json @@ -0,0 +1,13 @@ +{ + "name": "Pyxis T-Deck", + "version": "dev", + "new_install_prompt_erase": false, + "builds": [ + { + "chipFamily": "ESP32-S3", + "parts": [ + { "path": "firmware/firmware.bin", "offset": 65536 } + ] + } + ] +} diff --git a/lib/auto_interface/AutoInterface.cpp b/lib/auto_interface/AutoInterface.cpp new file mode 100644 index 0000000..33b6386 --- /dev/null +++ b/lib/auto_interface/AutoInterface.cpp @@ -0,0 +1,1537 @@ +#include "AutoInterface.h" +#include "Log.h" +#include "Utilities/OS.h" + +#include +#include + +#ifdef ARDUINO +#include // For ESP.getMaxAllocHeap() +// ESP32 lwIP headers for raw socket support +#include +#include +#include +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +using namespace RNS; + +// Helper: Convert IPv6 address bytes to compressed string format (RFC 5952) +// This matches Python's inet_ntop output +static std::string ipv6_to_compressed_string(const uint8_t* addr) { + // Build 8 groups of 16-bit values + uint16_t groups[8]; + for (int i = 0; i < 8; i++) { + groups[i] = (addr[i*2] << 8) | addr[i*2+1]; + } + + // Find longest run of zeros for :: compression + int best_start = -1, best_len = 0; + int cur_start = -1, cur_len = 0; + for (int i = 0; i < 8; i++) { + if (groups[i] == 0) { + if (cur_start < 0) cur_start = i; + cur_len++; + } else { + if (cur_len > best_len && cur_len > 1) { + best_start = cur_start; + best_len = cur_len; + } + cur_start = -1; + cur_len = 0; + } + } + if (cur_len > best_len && cur_len > 1) { + best_start = cur_start; + best_len = cur_len; + } + + // Build string + std::string result; + char buf[8]; + for (int i = 0; i < 8; i++) { + if (best_start >= 0 && i >= best_start && i < best_start + best_len) { + if (i == best_start) result += "::"; + continue; + } + if (!result.empty() && result.back() != ':') result += ":"; + snprintf(buf, sizeof(buf), "%x", groups[i]); + result += buf; + } + // Handle trailing :: + if (best_start >= 0 && best_start + best_len == 8 && result.size() == 2) { + // Just "::" for all zeros + } + return result; +} + +AutoInterface::AutoInterface(const char* name) : InterfaceImpl(name) { + _IN = true; + _OUT = true; + _bitrate = BITRATE_GUESS; + _HW_MTU = HW_MTU; + memset(&_multicast_address, 0, sizeof(_multicast_address)); + memset(&_link_local_address, 0, sizeof(_link_local_address)); +} + +AutoInterface::~AutoInterface() { + stop(); +} + +bool AutoInterface::start() { + _online = false; + + INFO("AutoInterface: Starting with group_id: " + _group_id); + INFO("AutoInterface: Discovery port: " + std::to_string(_discovery_port)); + INFO("AutoInterface: Data port: " + std::to_string(_data_port)); + +#ifdef ARDUINO + // ESP32 implementation using WiFiUDP + + // Get link-local address for our interface + if (!get_link_local_address()) { + ERROR("AutoInterface: Could not get link-local IPv6 address"); + return false; + } + + // Calculate multicast address from group_id hash + calculate_multicast_address(); + + // Calculate our discovery token + calculate_discovery_token(); + + // Set up discovery socket (multicast receive) + if (!setup_discovery_socket()) { + ERROR("AutoInterface: Could not set up discovery socket"); + return false; + } + + // Set up unicast discovery socket (reverse peering receive) + if (!setup_unicast_discovery_socket()) { + // Non-fatal - reverse peering won't work but multicast discovery will + WARNING("AutoInterface: Could not set up unicast discovery socket (reverse peering disabled)"); + } + + // Set up data socket (unicast send/receive) + if (!setup_data_socket()) { + // Data socket failure is non-fatal - we can still discover peers + WARNING("AutoInterface: Could not set up data socket (discovery-only mode)"); + _data_socket_ok = false; + } else { + _data_socket_ok = true; + } + + _online = true; + INFO("AutoInterface: Started successfully (data_socket=" + std::string(_data_socket_ok ? "yes" : "no") + + ", unicast_discovery=" + std::string(_unicast_discovery_socket >= 0 ? "yes" : "no") + ")"); + INFO("AutoInterface: Multicast address: " + _multicast_address_str); + INFO("AutoInterface: Link-local address: " + _link_local_address_str); + INFO("AutoInterface: Discovery token: " + _discovery_token.toHex()); + + return true; +#else + // Get link-local address for our interface + if (!get_link_local_address()) { + ERROR("AutoInterface: Could not get link-local IPv6 address"); + return false; + } + + // Calculate multicast address from group_id hash + calculate_multicast_address(); + + // Calculate our discovery token + calculate_discovery_token(); + + // Set up discovery socket (multicast receive) + if (!setup_discovery_socket()) { + ERROR("AutoInterface: Could not set up discovery socket"); + return false; + } + + // Set up unicast discovery socket (reverse peering receive) + if (!setup_unicast_discovery_socket()) { + // Non-fatal - reverse peering won't work but multicast discovery will + WARNING("AutoInterface: Could not set up unicast discovery socket (reverse peering disabled)"); + } + + // Set up data socket (unicast send/receive) + if (!setup_data_socket()) { + // Data socket failure is non-fatal - we can still discover peers + // This happens when Python RNS is already bound to the same address:port + WARNING("AutoInterface: Could not set up data socket (discovery-only mode)"); + WARNING("AutoInterface: Another RNS instance may be using this address"); + } + + _online = true; + INFO("AutoInterface: Started successfully (data_socket=" + + std::string(_data_socket >= 0 ? "yes" : "no") + + ", unicast_discovery=" + std::string(_unicast_discovery_socket >= 0 ? "yes" : "no") + ")"); + INFO("AutoInterface: Multicast address: " + std::string(inet_ntop(AF_INET6, &_multicast_address, + (char*)_buffer.writable(INET6_ADDRSTRLEN), INET6_ADDRSTRLEN))); + INFO("AutoInterface: Link-local address: " + _link_local_address_str); + INFO("AutoInterface: Discovery token: " + _discovery_token.toHex()); + + return true; +#endif +} + +void AutoInterface::stop() { +#ifdef ARDUINO + // ESP32 cleanup - raw sockets for discovery, unicast discovery, and data + if (_discovery_socket > -1) { + close(_discovery_socket); + _discovery_socket = -1; + } + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } + _data_socket_ok = false; +#else + if (_discovery_socket > -1) { + close(_discovery_socket); + _discovery_socket = -1; + } + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } +#endif + _online = false; + _peers.clear(); +} + +void AutoInterface::loop() { + if (!_online) return; + + double now = RNS::Utilities::OS::time(); + + // Send periodic discovery announce + if (now - _last_announce >= ANNOUNCE_INTERVAL) { +#ifdef ARDUINO + // Skip announce if memory is critically low - prevents fragmentation + // Threshold lowered to 8KB since announces are small (32 byte token) + uint32_t max_block = ESP.getMaxAllocHeap(); + if (max_block < 8000) { + WARNING("AutoInterface: Skipping announce - low memory (max_block=" + std::to_string(max_block) + ")"); + _last_announce = now; // Still update timer to avoid tight loop + } else { + send_announce(); + _last_announce = now; + } +#else + send_announce(); + _last_announce = now; +#endif + } + + // Process incoming discovery packets (multicast) + process_discovery(); + + // Process incoming unicast discovery packets (reverse peering) + process_unicast_discovery(); + + // Send reverse peering to known peers + send_reverse_peering(); + + // Process incoming data packets + process_data(); + + // Check multicast echo timeout + check_echo_timeout(); + + // Expire stale peers + expire_stale_peers(); + + // Expire old deque entries + expire_deque_entries(); + + // Periodic peer job (every 4 seconds) - check for address changes + if (now - _last_peer_job >= PEER_JOB_INTERVAL) { + check_link_local_address(); + _last_peer_job = now; + } +} + +void AutoInterface::send_outgoing(const Bytes& data) { + DEBUG(toString() + ".send_outgoing: data: " + data.toHex()); + + if (!_online) return; + +#ifdef ARDUINO + // ESP32: Send to all known peers via unicast using persistent raw IPv6 socket + // (WiFiUDP doesn't support IPv6) + if (_data_socket < 0) { + WARNING("AutoInterface: Data socket not ready, cannot send"); + return; + } + + for (const auto& peer : _peers) { + if (peer.is_local) continue; // Don't send to ourselves + + struct sockaddr_in6 peer_addr; + memset(&peer_addr, 0, sizeof(peer_addr)); + peer_addr.sin6_family = AF_INET6; + peer_addr.sin6_port = htons(_data_port); + peer_addr.sin6_scope_id = _if_index; + + // Copy IPv6 address from peer (IPv6Address stores 16 bytes) + for (int i = 0; i < 16; i++) { + ((uint8_t*)&peer_addr.sin6_addr)[i] = peer.address[i]; + } + + ssize_t sent = sendto(_data_socket, data.data(), data.size(), 0, + (struct sockaddr*)&peer_addr, sizeof(peer_addr)); + if (sent < 0) { + WARNING("AutoInterface: Failed to send to peer " + peer.address_string() + + " errno=" + std::to_string(errno)); + } else { + INFO("AutoInterface: Sent " + std::to_string(sent) + " bytes to " + peer.address_string() + + " port " + std::to_string(_data_port)); + } + } + + // Perform post-send housekeeping + InterfaceImpl::handle_outgoing(data); +#else + // POSIX: Send to all known peers via unicast + for (const auto& peer : _peers) { + if (peer.is_local) continue; // Don't send to ourselves + + struct sockaddr_in6 peer_addr; + memset(&peer_addr, 0, sizeof(peer_addr)); + peer_addr.sin6_family = AF_INET6; + peer_addr.sin6_port = htons(_data_port); + peer_addr.sin6_addr = peer.address; + peer_addr.sin6_scope_id = _if_index; + + ssize_t sent = sendto(_data_socket, data.data(), data.size(), 0, + (struct sockaddr*)&peer_addr, sizeof(peer_addr)); + if (sent < 0) { + WARNING("AutoInterface: Failed to send to peer " + peer.address_string() + + ": " + std::string(strerror(errno))); + } else { + TRACE("AutoInterface: Sent " + std::to_string(sent) + " bytes to " + peer.address_string()); + } + } + + // Perform post-send housekeeping + InterfaceImpl::handle_outgoing(data); +#endif +} + +// ============================================================================ +// Platform-specific: get_link_local_address() +// ============================================================================ + +#ifdef ARDUINO + +bool AutoInterface::get_link_local_address() { + // ESP32: Get link-local IPv6 from WiFi + if (WiFi.status() != WL_CONNECTED) { + ERROR("AutoInterface: WiFi not connected"); + return false; + } + + // Enable IPv6 and wait for link-local address + WiFi.enableIpV6(); + DEBUG("AutoInterface: IPv6 enabled, waiting for link-local address..."); + + // Give time for SLAAC to assign link-local address + for (int i = 0; i < 100; i++) { // Increased timeout to 10 seconds + IPv6Address lladdr = WiFi.localIPv6(); + + // Debug: print what we're getting + if (i % 10 == 0) { + DEBUG("AutoInterface: Attempt " + std::to_string(i) + " - IPv6: " + + std::string(lladdr.toString().c_str())); + } + + // Check if we got a valid address (not all zeros) + // IPv6Address stores bytes in network order + if (lladdr[0] != 0 || lladdr[1] != 0) { + // Store as in6_addr - copy from IPv6Address internal storage + // IPv6Address operator[] returns bytes in network order + uint8_t addr_bytes[16]; + for (int j = 0; j < 16; j++) { + addr_bytes[j] = lladdr[j]; + ((uint8_t*)&_link_local_address)[j] = lladdr[j]; + } + + // Store the address string in COMPRESSED format to match Python's inet_ntop + _link_local_address_str = ipv6_to_compressed_string(addr_bytes); + + // Also store as IPAddress for easier ESP32 use + _link_local_ip = lladdr; + + INFO("AutoInterface: Found IPv6 address " + _link_local_address_str); + + // Check if it's link-local (fe80::/10) + if (lladdr[0] == 0xfe && (lladdr[1] & 0xc0) == 0x80) { + INFO("AutoInterface: Confirmed link-local address"); + return true; + } else { + // Got an address but it's not link-local - might be global + // Still use it for now + WARNING("AutoInterface: Got non-link-local IPv6: " + _link_local_address_str); + return true; + } + } + delay(100); + } + + ERROR("AutoInterface: No IPv6 address after timeout"); + return false; +} + +void AutoInterface::check_link_local_address() { + // ESP32: Check if link-local address changed + if (WiFi.status() != WL_CONNECTED) { + WARNING("AutoInterface: WiFi disconnected during address check"); + return; + } + + IPv6Address current_ip = WiFi.localIPv6(); + + // Check for valid address (not all zeros) + if (current_ip[0] == 0 && current_ip[1] == 0) { + WARNING("AutoInterface: Lost IPv6 address"); + return; + } + + // Compare with stored address + if (current_ip == _link_local_ip) { + return; // No change + } + + // Address changed! + std::string old_addr_str = _link_local_address_str; + + // Update stored addresses + _link_local_ip = current_ip; + for (int i = 0; i < 16; i++) { + ((uint8_t*)&_link_local_address)[i] = current_ip[i]; + } + + // Get new address string in compressed format + uint8_t addr_bytes[16]; + for (int i = 0; i < 16; i++) { + addr_bytes[i] = current_ip[i]; + } + _link_local_address_str = ipv6_to_compressed_string(addr_bytes); + + WARNING("AutoInterface: Link-local address changed from " + old_addr_str + " to " + _link_local_address_str); + + // Close and rebind data socket + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } + if (!setup_data_socket()) { + WARNING("AutoInterface: Failed to rebind data socket after address change"); + _data_socket_ok = false; + } else { + _data_socket_ok = true; + INFO("AutoInterface: Data socket rebound to new address"); + } + + // Close and rebind unicast discovery socket + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (!setup_unicast_discovery_socket()) { + WARNING("AutoInterface: Failed to rebind unicast discovery socket after address change"); + } else { + INFO("AutoInterface: Unicast discovery socket rebound to new address"); + } + + // Recalculate discovery token (critical - token includes address) + calculate_discovery_token(); + INFO("AutoInterface: Discovery token recalculated: " + _discovery_token.toHex()); + + // Signal change to Transport layer + _carrier_changed = true; +} + +#else // POSIX/Linux + +bool AutoInterface::get_link_local_address() { + struct ifaddrs* ifaddr; + if (getifaddrs(&ifaddr) == -1) { + ERROR("AutoInterface: getifaddrs failed: " + std::string(strerror(errno))); + return false; + } + + bool found = false; + for (struct ifaddrs* ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr) continue; + if (ifa->ifa_addr->sa_family != AF_INET6) continue; + + // Skip loopback + if (strcmp(ifa->ifa_name, "lo") == 0) continue; + + // If interface name specified, match it + if (!_ifname.empty() && _ifname != ifa->ifa_name) continue; + + struct sockaddr_in6* addr6 = (struct sockaddr_in6*)ifa->ifa_addr; + + // Check for link-local address (fe80::/10) + if (IN6_IS_ADDR_LINKLOCAL(&addr6->sin6_addr)) { + _link_local_address = addr6->sin6_addr; + _if_index = if_nametoindex(ifa->ifa_name); + _ifname = ifa->ifa_name; + + // Convert to string for token generation + char buf[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &addr6->sin6_addr, buf, sizeof(buf)); + _link_local_address_str = buf; + + INFO("AutoInterface: Found link-local address " + _link_local_address_str + + " on interface " + _ifname); + found = true; + break; + } + } + + freeifaddrs(ifaddr); + return found; +} + +void AutoInterface::check_link_local_address() { + // POSIX: Check if link-local address changed + struct in6_addr old_addr = _link_local_address; + std::string old_addr_str = _link_local_address_str; + + // Temporarily clear to force refresh + memset(&_link_local_address, 0, sizeof(_link_local_address)); + + if (!get_link_local_address()) { + // Lost address entirely - restore old address + WARNING("AutoInterface: Lost link-local address during check"); + _link_local_address = old_addr; + _link_local_address_str = old_addr_str; + return; + } + + // Check if address changed + if (memcmp(&old_addr, &_link_local_address, sizeof(old_addr)) == 0) { + return; // No change + } + + // Address changed! + WARNING("AutoInterface: Link-local address changed from " + old_addr_str + " to " + _link_local_address_str); + + // Close and rebind data socket + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } + if (!setup_data_socket()) { + WARNING("AutoInterface: Failed to rebind data socket after address change"); + } else { + INFO("AutoInterface: Data socket rebound to new address"); + } + + // Close and rebind unicast discovery socket + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (!setup_unicast_discovery_socket()) { + WARNING("AutoInterface: Failed to rebind unicast discovery socket after address change"); + } else { + INFO("AutoInterface: Unicast discovery socket rebound to new address"); + } + + // Recalculate discovery token (critical - token includes address) + calculate_discovery_token(); + INFO("AutoInterface: Discovery token recalculated: " + _discovery_token.toHex()); + + // Signal change to Transport layer + _carrier_changed = true; +} + +#endif // ARDUINO + +void AutoInterface::calculate_multicast_address() { + // Python: group_hash = RNS.Identity.full_hash(self.group_id) + Bytes group_id_bytes((const uint8_t*)_group_id.c_str(), _group_id.length()); + Bytes group_hash = Identity::full_hash(group_id_bytes); + + // Build multicast address: ff12:0:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX + // ff = multicast prefix + // 1 = temporary address type (MULTICAST_TEMPORARY_ADDRESS_TYPE) + // 2 = link scope (SCOPE_LINK) + // The remaining 112 bits come from the group hash + + // Python format from AutoInterface.py lines 195-205: + // gt = "0" # literal 0, NOT from hash! + // gt += ":"+"{:02x}".format(g[3]+(g[2]<<8)) # hash bytes 2-3 + // gt += ":"+"{:02x}".format(g[5]+(g[4]<<8)) # hash bytes 4-5 + // gt += ":"+"{:02x}".format(g[7]+(g[6]<<8)) # hash bytes 6-7 + // gt += ":"+"{:02x}".format(g[9]+(g[8]<<8)) # hash bytes 8-9 + // gt += ":"+"{:02x}".format(g[11]+(g[10]<<8)) # hash bytes 10-11 + // gt += ":"+"{:02x}".format(g[13]+(g[12]<<8)) # hash bytes 12-13 + // mcast_discovery_address = "ff12:" + gt + // + // Result: ff12:0:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX (8 groups) + + uint8_t addr[16]; + addr[0] = 0xff; + addr[1] = 0x12; // 1=temporary, 2=link scope + + const uint8_t* g = group_hash.data(); + + // Group 1: Python uses literal "0", NOT hash bytes 0-1! + addr[2] = 0x00; + addr[3] = 0x00; + + // Group 2: g[3]+(g[2]<<8) - starts at hash byte 2 + addr[4] = g[2]; + addr[5] = g[3]; + + // Group 3: g[5]+(g[4]<<8) + addr[6] = g[4]; + addr[7] = g[5]; + + // Group 4: g[7]+(g[6]<<8) + addr[8] = g[6]; + addr[9] = g[7]; + + // Group 5: g[9]+(g[8]<<8) + addr[10] = g[8]; + addr[11] = g[9]; + + // Group 6: g[11]+(g[10]<<8) + addr[12] = g[10]; + addr[13] = g[11]; + + // Group 7: g[13]+(g[12]<<8) + addr[14] = g[12]; + addr[15] = g[13]; + + memcpy(&_multicast_address, addr, 16); + _multicast_address_bytes = Bytes(addr, 16); + + // Convert to string for logging + char buf[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &_multicast_address, buf, sizeof(buf)); + _multicast_address_str = buf; + +#ifdef ARDUINO + // Also store as IPv6Address for ESP32 use + _multicast_ip = IPv6Address(addr); +#endif +} + +// ============================================================================ +// Platform-independent: calculate_discovery_token() +// ============================================================================ + +void AutoInterface::calculate_discovery_token() { + // Python: discovery_token = RNS.Identity.full_hash(self.group_id+link_local_address.encode("utf-8")) + // Python sends the FULL 32-byte hash (not truncated) + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)_link_local_address_str.c_str(), _link_local_address_str.length()); + + Bytes full_hash = Identity::full_hash(combined); + // Use full TOKEN_SIZE (32 bytes) to match Python RNS + _discovery_token = Bytes(full_hash.data(), TOKEN_SIZE); + TRACE("AutoInterface: Discovery token input: " + combined.toHex()); + TRACE("AutoInterface: Discovery token: " + _discovery_token.toHex()); +} + +// ============================================================================ +// Platform-specific: Socket setup +// ============================================================================ + +#ifdef ARDUINO + +bool AutoInterface::setup_discovery_socket() { + // ESP32: Use raw lwIP socket for IPv6 multicast (WiFiUDP.beginMulticast() only supports IPv4) + + // Create IPv6 UDP socket + _discovery_socket = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (_discovery_socket < 0) { + ERROR("AutoInterface: Failed to create discovery socket (errno=" + std::to_string(errno) + ")"); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + // Bind to discovery port on in6addr_any (ESP32 lwIP doesn't support binding to multicast) + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_discovery_port); + bind_addr.sin6_addr = in6addr_any; // Receive from any source + + if (bind(_discovery_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Failed to bind discovery socket (errno=" + std::to_string(errno) + ")"); + close(_discovery_socket); + _discovery_socket = -1; + return false; + } + + // Set non-blocking + int flags = fcntl(_discovery_socket, F_GETFL, 0); + fcntl(_discovery_socket, F_SETFL, flags | O_NONBLOCK); + + // Find the station netif (WiFi interface) to get interface index + struct netif* nif = netif_list; + while (nif != NULL) { + if (nif->name[0] == 's' && nif->name[1] == 't') { // "st" = station + break; + } + nif = nif->next; + } + + if (nif != NULL) { + // Get interface index for multicast (needed for send and receive) + _if_index = netif_get_index(nif); + INFO("AutoInterface: Using interface index " + std::to_string(_if_index) + " for multicast"); + + // Join the IPv6 multicast group using standard socket API + // This properly links the socket to receive multicast packets + struct ipv6_mreq mreq; + memcpy(&mreq.ipv6mr_multiaddr, &_multicast_address, sizeof(_multicast_address)); + mreq.ipv6mr_interface = _if_index; + + if (setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq, sizeof(mreq)) < 0) { + WARNING("AutoInterface: Failed to join multicast group via setsockopt (errno=" + + std::to_string(errno) + "), trying mld6 API"); + // Fallback to lwIP mld6 API + ip6_addr_t mcast_addr; + memcpy(&mcast_addr.addr, &_multicast_address, sizeof(_multicast_address)); + err_t err = mld6_joingroup_netif(nif, &mcast_addr); + if (err == ERR_OK) { + INFO("AutoInterface: Joined IPv6 multicast group via mld6 API: " + _multicast_address_str); + } else { + WARNING("AutoInterface: mld6_joingroup failed (err=" + std::to_string(err) + + ") - discovery may not work"); + } + } else { + INFO("AutoInterface: Joined IPv6 multicast group via setsockopt: " + _multicast_address_str); + } + + // Set multicast interface for outgoing packets (critical for multicast to reach other hosts!) + if (setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_MULTICAST_IF, + &_if_index, sizeof(_if_index)) < 0) { + WARNING("AutoInterface: Failed to set IPV6_MULTICAST_IF (errno=" + std::to_string(errno) + ")"); + } else { + DEBUG("AutoInterface: Set IPV6_MULTICAST_IF to interface " + std::to_string(_if_index)); + } + } else { + WARNING("AutoInterface: Could not find station netif for multicast join"); + } + + INFO("AutoInterface: Discovery socket listening on port " + std::to_string(_discovery_port)); + return true; +} + +bool AutoInterface::setup_data_socket() { + // ESP32: Use raw IPv6 socket for data port (WiFiUDP doesn't support IPv6) + _data_socket = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (_data_socket < 0) { + ERROR("AutoInterface: Failed to create data socket (errno=" + std::to_string(errno) + ")"); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(_data_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + // Note: _if_index should already be set by setup_discovery_socket() + // Fallback in case discovery socket wasn't set up first + if (_if_index == 0) { + struct netif* nif = netif_list; + while (nif != NULL) { + if (nif->name[0] == 's' && nif->name[1] == 't') { // "st" = station + _if_index = netif_get_index(nif); + break; + } + nif = nif->next; + } + INFO("AutoInterface: Using interface index " + std::to_string(_if_index) + " for data socket (fallback)"); + } + + // Bind to our link-local address and data port (helps with routing) + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_data_port); + memcpy(&bind_addr.sin6_addr, &_link_local_address, sizeof(_link_local_address)); + bind_addr.sin6_scope_id = _if_index; + + if (bind(_data_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + WARNING("AutoInterface: Failed to bind to link-local (errno=" + std::to_string(errno) + + "), trying any address"); + // Fallback to any address + bind_addr.sin6_addr = in6addr_any; + bind_addr.sin6_scope_id = 0; + if (bind(_data_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Failed to bind data socket (errno=" + std::to_string(errno) + ")"); + close(_data_socket); + _data_socket = -1; + return false; + } + } + + // Set non-blocking + int flags = fcntl(_data_socket, F_GETFL, 0); + fcntl(_data_socket, F_SETFL, flags | O_NONBLOCK); + + INFO("AutoInterface: Data socket listening on port " + std::to_string(_data_port)); + return true; +} + +bool AutoInterface::setup_unicast_discovery_socket() { + // ESP32: Create socket for receiving unicast discovery (reverse peering) + _unicast_discovery_socket = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (_unicast_discovery_socket < 0) { + ERROR("AutoInterface: Failed to create unicast discovery socket (errno=" + std::to_string(errno) + ")"); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(_unicast_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + // Bind to our link-local address and unicast discovery port + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_unicast_discovery_port); + memcpy(&bind_addr.sin6_addr, &_link_local_address, sizeof(_link_local_address)); + bind_addr.sin6_scope_id = _if_index; + + if (bind(_unicast_discovery_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + WARNING("AutoInterface: Failed to bind unicast discovery to link-local (errno=" + std::to_string(errno) + + "), trying any address"); + // Fallback to any address + bind_addr.sin6_addr = in6addr_any; + bind_addr.sin6_scope_id = 0; + if (bind(_unicast_discovery_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Failed to bind unicast discovery socket (errno=" + std::to_string(errno) + ")"); + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + return false; + } + } + + // Set non-blocking + int flags = fcntl(_unicast_discovery_socket, F_GETFL, 0); + fcntl(_unicast_discovery_socket, F_SETFL, flags | O_NONBLOCK); + + INFO("AutoInterface: Unicast discovery socket listening on port " + std::to_string(_unicast_discovery_port)); + return true; +} + +bool AutoInterface::join_multicast_group() { + // ESP32: Multicast join handled by beginMulticast() + INFO("AutoInterface: Joined multicast group " + _multicast_address_str); + return true; +} + +void AutoInterface::send_announce() { + // ESP32: Send discovery token to multicast address using raw socket + if (_discovery_socket < 0) { + WARNING("AutoInterface: Discovery socket not initialized"); + return; + } + + struct sockaddr_in6 dest_addr; + memset(&dest_addr, 0, sizeof(dest_addr)); + dest_addr.sin6_family = AF_INET6; + dest_addr.sin6_port = htons(_discovery_port); + memcpy(&dest_addr.sin6_addr, &_multicast_address, sizeof(_multicast_address)); + dest_addr.sin6_scope_id = _if_index; // Specify WiFi interface for link-local multicast + + ssize_t sent = sendto(_discovery_socket, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&dest_addr, sizeof(dest_addr)); + if (sent > 0) { + DEBUG("AutoInterface: Sent discovery announce (" + std::to_string(sent) + " bytes) to " + _multicast_address_str); + } else { + WARNING("AutoInterface: Failed to send discovery announce (errno=" + std::to_string(errno) + ")"); + } +} + +void AutoInterface::process_discovery() { + // ESP32: Use raw socket recvfrom for IPv6 multicast + if (_discovery_socket < 0) return; + + uint8_t recv_buffer[128]; + struct sockaddr_in6 src_addr; + socklen_t src_len = sizeof(src_addr); + + ssize_t len = recvfrom(_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + + // Debug: log even when no packet received (periodically) + static int recv_check_count = 0; + if (++recv_check_count >= 600) { // Every ~10 seconds at 60Hz loop + DEBUG("AutoInterface: Discovery poll (peers=" + std::to_string(_peers.size()) + + ", socket=" + std::to_string(_discovery_socket) + + ", errno=" + std::to_string(errno) + ")"); + recv_check_count = 0; + } + + // Hot path - no logging to avoid heap allocation on every packet + + while (len > 0) { + // Convert source address to COMPRESSED string format (match Python) + std::string src_str = ipv6_to_compressed_string((const uint8_t*)&src_addr.sin6_addr); + + // Verify the peering hash (full TOKEN_SIZE = 32 bytes) + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str.c_str(), src_str.length()); + Bytes expected_hash = Identity::full_hash(combined); + + // Compare received token with expected (full TOKEN_SIZE = 32 bytes) + if (len >= (ssize_t)TOKEN_SIZE && memcmp(recv_buffer, expected_hash.data(), TOKEN_SIZE) == 0) { + // Valid peer - use IPv6Address (IPAddress is IPv4-only!) + IPv6Address remoteIP((const uint8_t*)&src_addr.sin6_addr); + add_or_refresh_peer(remoteIP, RNS::Utilities::OS::time()); + } + + // Try to receive more + src_len = sizeof(src_addr); + len = recvfrom(_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + } +} + +void AutoInterface::process_data() { + // ESP32: Use raw socket for IPv6 data reception + if (_data_socket < 0) return; + + uint8_t recv_buffer[Type::Reticulum::MTU + 64]; + struct sockaddr_in6 src_addr; + socklen_t src_len = sizeof(src_addr); + + ssize_t len = recvfrom(_data_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + + while (len > 0) { + _buffer.clear(); + _buffer.append(recv_buffer, len); + + // Check for duplicates + if (is_duplicate(_buffer)) { + TRACE("AutoInterface: Dropping duplicate packet"); + src_len = sizeof(src_addr); + len = recvfrom(_data_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + continue; + } + + add_to_deque(_buffer); + + // Convert source address to string for logging + std::string src_str = ipv6_to_compressed_string((const uint8_t*)&src_addr.sin6_addr); + DEBUG("AutoInterface: Received data from " + src_str + " (" + std::to_string(len) + " bytes)"); + + // Pass to transport + InterfaceImpl::handle_incoming(_buffer); + + // Try to receive more + src_len = sizeof(src_addr); + len = recvfrom(_data_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + } +} + +void AutoInterface::process_unicast_discovery() { + // ESP32: Process incoming unicast discovery packets (reverse peering) + if (_unicast_discovery_socket < 0) return; + + uint8_t recv_buffer[128]; + struct sockaddr_in6 src_addr; + socklen_t src_len = sizeof(src_addr); + + ssize_t len = recvfrom(_unicast_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + + while (len > 0) { + // Convert source address to COMPRESSED string format (match Python) + std::string src_str = ipv6_to_compressed_string((const uint8_t*)&src_addr.sin6_addr); + + // Verify the peering hash (full TOKEN_SIZE = 32 bytes) + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str.c_str(), src_str.length()); + Bytes expected_hash = Identity::full_hash(combined); + + // Compare received token with expected (full TOKEN_SIZE = 32 bytes) + if (len >= (ssize_t)TOKEN_SIZE && memcmp(recv_buffer, expected_hash.data(), TOKEN_SIZE) == 0) { + // Valid peer via unicast discovery (reverse peering) + IPv6Address remoteIP((const uint8_t*)&src_addr.sin6_addr); + DEBUG("AutoInterface: Received unicast discovery from " + src_str); + add_or_refresh_peer(remoteIP, RNS::Utilities::OS::time()); + } + + // Try to receive more + src_len = sizeof(src_addr); + len = recvfrom(_unicast_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + } +} + +void AutoInterface::reverse_announce(AutoInterfacePeer& peer) { + // ESP32: Send our discovery token directly to a peer's unicast discovery port + // This allows peer to discover us even if multicast is not working + + // Create temporary socket for sending + int sock = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + WARNING("AutoInterface: Failed to create reverse announce socket (errno=" + std::to_string(errno) + ")"); + return; + } + + // Build destination address + struct sockaddr_in6 dest_addr; + memset(&dest_addr, 0, sizeof(dest_addr)); + dest_addr.sin6_family = AF_INET6; + dest_addr.sin6_port = htons(_unicast_discovery_port); + dest_addr.sin6_scope_id = _if_index; + + // Copy peer's IPv6 address + for (int i = 0; i < 16; i++) { + ((uint8_t*)&dest_addr.sin6_addr)[i] = peer.address[i]; + } + + // Send discovery token + ssize_t sent = sendto(sock, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&dest_addr, sizeof(dest_addr)); + + close(sock); + + if (sent > 0) { + TRACE("AutoInterface: Sent reverse announce to " + peer.address_string()); + } else { + WARNING("AutoInterface: Failed to send reverse announce to " + peer.address_string() + + " (errno=" + std::to_string(errno) + ")"); + } +} + +void AutoInterface::send_reverse_peering() { + // ESP32: Periodically send reverse peering to known peers + // This maintains peer connections even when multicast is unreliable + double now = RNS::Utilities::OS::time(); + + for (auto& peer : _peers) { + // Skip local peers (our own announcements) + if (peer.is_local) continue; + + // Check if it's time to send reverse peering to this peer + if (now > peer.last_outbound + REVERSE_PEERING_INTERVAL) { + reverse_announce(peer); + peer.last_outbound = now; + } + } +} + +#else // POSIX/Linux + +bool AutoInterface::setup_discovery_socket() { + // Create IPv6 UDP socket + _discovery_socket = socket(AF_INET6, SOCK_DGRAM, 0); + if (_discovery_socket < 0) { + ERROR("AutoInterface: Could not create discovery socket: " + std::string(strerror(errno))); + return false; + } + + // Enable address reuse + int reuse = 1; + setsockopt(_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(_discovery_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + + // Set multicast interface + setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_MULTICAST_IF, &_if_index, sizeof(_if_index)); + + // Join multicast group + if (!join_multicast_group()) { + close(_discovery_socket); + _discovery_socket = -1; + return false; + } + + // Bind to discovery port on multicast address + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_discovery_port); + bind_addr.sin6_addr = _multicast_address; + bind_addr.sin6_scope_id = _if_index; + + if (bind(_discovery_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Could not bind discovery socket: " + std::string(strerror(errno))); + close(_discovery_socket); + _discovery_socket = -1; + return false; + } + + // Make socket non-blocking + int flags = 1; + ioctl(_discovery_socket, FIONBIO, &flags); + + INFO("AutoInterface: Discovery socket bound to port " + std::to_string(_discovery_port)); + return true; +} + +bool AutoInterface::setup_data_socket() { + // Create IPv6 UDP socket for data + _data_socket = socket(AF_INET6, SOCK_DGRAM, 0); + if (_data_socket < 0) { + ERROR("AutoInterface: Could not create data socket: " + std::string(strerror(errno))); + return false; + } + + // Enable address reuse + int reuse = 1; + setsockopt(_data_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(_data_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + + // Bind to data port on link-local address + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_data_port); + bind_addr.sin6_addr = _link_local_address; + bind_addr.sin6_scope_id = _if_index; + + if (bind(_data_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Could not bind data socket: " + std::string(strerror(errno))); + close(_data_socket); + _data_socket = -1; + return false; + } + + // Make socket non-blocking + int flags = 1; + ioctl(_data_socket, FIONBIO, &flags); + + INFO("AutoInterface: Data socket bound to port " + std::to_string(_data_port)); + return true; +} + +bool AutoInterface::join_multicast_group() { + struct ipv6_mreq mreq; + memcpy(&mreq.ipv6mr_multiaddr, &_multicast_address, sizeof(_multicast_address)); + mreq.ipv6mr_interface = _if_index; + + if (setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq, sizeof(mreq)) < 0) { + ERROR("AutoInterface: Could not join multicast group: " + std::string(strerror(errno))); + return false; + } + + INFO("AutoInterface: Joined multicast group " + _multicast_address_str); + return true; +} + +void AutoInterface::send_announce() { + if (_discovery_socket < 0) return; + + // Send discovery token to multicast address + struct sockaddr_in6 mcast_addr; + memset(&mcast_addr, 0, sizeof(mcast_addr)); + mcast_addr.sin6_family = AF_INET6; + mcast_addr.sin6_port = htons(_discovery_port); + mcast_addr.sin6_addr = _multicast_address; + mcast_addr.sin6_scope_id = _if_index; + + ssize_t sent = sendto(_discovery_socket, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&mcast_addr, sizeof(mcast_addr)); + if (sent < 0) { + WARNING("AutoInterface: Failed to send discovery announce: " + std::string(strerror(errno))); + } else { + TRACE("AutoInterface: Sent discovery announce (" + std::to_string(sent) + " bytes)"); + } +} + +void AutoInterface::process_discovery() { + if (_discovery_socket < 0) return; + + uint8_t recv_buffer[1024]; + struct sockaddr_in6 src_addr; + socklen_t addr_len = sizeof(src_addr); + + while (true) { + ssize_t len = recvfrom(_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &addr_len); + if (len <= 0) break; + + // Get source address string + char src_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &src_addr.sin6_addr, src_str, sizeof(src_str)); + + DEBUG("AutoInterface: Received discovery packet from " + std::string(src_str) + + " (" + std::to_string(len) + " bytes)"); + + // Verify the peering hash + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str, strlen(src_str)); + Bytes expected_hash = Identity::full_hash(combined); + + // Compare received hash with expected + if (len >= 32 && memcmp(recv_buffer, expected_hash.data(), 32) == 0) { + // Valid peer + add_or_refresh_peer(src_addr.sin6_addr, RNS::Utilities::OS::time()); + } else { + DEBUG("AutoInterface: Invalid discovery hash from " + std::string(src_str)); + } + } +} + +void AutoInterface::process_data() { + if (_data_socket < 0) return; + + struct sockaddr_in6 src_addr; + socklen_t addr_len = sizeof(src_addr); + + while (true) { + _buffer.clear(); + ssize_t len = recvfrom(_data_socket, _buffer.writable(Type::Reticulum::MTU), + Type::Reticulum::MTU, 0, + (struct sockaddr*)&src_addr, &addr_len); + if (len <= 0) break; + + _buffer.resize(len); + + // Check for duplicates (multi-interface deduplication) + if (is_duplicate(_buffer)) { + TRACE("AutoInterface: Dropping duplicate packet"); + continue; + } + + add_to_deque(_buffer); + + char src_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &src_addr.sin6_addr, src_str, sizeof(src_str)); + DEBUG("AutoInterface: Received data from " + std::string(src_str) + + " (" + std::to_string(len) + " bytes)"); + + // Pass to transport + InterfaceImpl::handle_incoming(_buffer); + } +} + +bool AutoInterface::setup_unicast_discovery_socket() { + // POSIX: Create socket for receiving unicast discovery (reverse peering) + _unicast_discovery_socket = socket(AF_INET6, SOCK_DGRAM, 0); + if (_unicast_discovery_socket < 0) { + ERROR("AutoInterface: Could not create unicast discovery socket: " + std::string(strerror(errno))); + return false; + } + + // Enable address reuse + int reuse = 1; + setsockopt(_unicast_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(_unicast_discovery_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + + // Bind to unicast discovery port on link-local address + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_unicast_discovery_port); + bind_addr.sin6_addr = _link_local_address; + bind_addr.sin6_scope_id = _if_index; + + if (bind(_unicast_discovery_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Could not bind unicast discovery socket: " + std::string(strerror(errno))); + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + return false; + } + + // Make socket non-blocking + int flags = 1; + ioctl(_unicast_discovery_socket, FIONBIO, &flags); + + INFO("AutoInterface: Unicast discovery socket bound to port " + std::to_string(_unicast_discovery_port)); + return true; +} + +void AutoInterface::process_unicast_discovery() { + // POSIX: Process incoming unicast discovery packets (reverse peering) + if (_unicast_discovery_socket < 0) return; + + uint8_t recv_buffer[128]; + struct sockaddr_in6 src_addr; + socklen_t addr_len = sizeof(src_addr); + + while (true) { + ssize_t len = recvfrom(_unicast_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &addr_len); + if (len <= 0) break; + + // Get source address string + char src_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &src_addr.sin6_addr, src_str, sizeof(src_str)); + + DEBUG("AutoInterface: Received unicast discovery from " + std::string(src_str) + + " (" + std::to_string(len) + " bytes)"); + + // Verify the peering hash + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str, strlen(src_str)); + Bytes expected_hash = Identity::full_hash(combined); + + // Compare received hash with expected + if (len >= 32 && memcmp(recv_buffer, expected_hash.data(), 32) == 0) { + // Valid peer via unicast discovery (reverse peering) + add_or_refresh_peer(src_addr.sin6_addr, RNS::Utilities::OS::time()); + } else { + DEBUG("AutoInterface: Invalid unicast discovery hash from " + std::string(src_str)); + } + } +} + +void AutoInterface::reverse_announce(AutoInterfacePeer& peer) { + // POSIX: Send our discovery token directly to a peer's unicast discovery port + // This allows peer to discover us even if multicast is not working + + // Create temporary socket for sending + int sock = socket(AF_INET6, SOCK_DGRAM, 0); + if (sock < 0) { + WARNING("AutoInterface: Failed to create reverse announce socket: " + std::string(strerror(errno))); + return; + } + + // Build destination address + struct sockaddr_in6 dest_addr; + memset(&dest_addr, 0, sizeof(dest_addr)); + dest_addr.sin6_family = AF_INET6; + dest_addr.sin6_port = htons(_unicast_discovery_port); + dest_addr.sin6_addr = peer.address; + dest_addr.sin6_scope_id = _if_index; + + // Send discovery token + ssize_t sent = sendto(sock, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&dest_addr, sizeof(dest_addr)); + + close(sock); + + if (sent > 0) { + TRACE("AutoInterface: Sent reverse announce to " + peer.address_string()); + } else { + WARNING("AutoInterface: Failed to send reverse announce to " + peer.address_string() + + ": " + std::string(strerror(errno))); + } +} + +void AutoInterface::send_reverse_peering() { + // POSIX: Periodically send reverse peering to known peers + // This maintains peer connections even when multicast is unreliable + double now = RNS::Utilities::OS::time(); + + for (auto& peer : _peers) { + // Skip local peers (our own announcements) + if (peer.is_local) continue; + + // Check if it's time to send reverse peering to this peer + if (now > peer.last_outbound + REVERSE_PEERING_INTERVAL) { + reverse_announce(peer); + peer.last_outbound = now; + } + } +} + +#endif // ARDUINO + +// ============================================================================ +// Platform-specific: Peer management +// ============================================================================ + +#ifdef ARDUINO + +void AutoInterface::add_or_refresh_peer(const IPv6Address& addr, double timestamp) { + // Check if this is our own address (IPv6Address == properly compares all 16 bytes) + if (addr == _link_local_ip) { + // Update echo timestamp + _last_multicast_echo = timestamp; + + // Track initial echo received + if (!_initial_echo_received) { + _initial_echo_received = true; + INFO("AutoInterface: Initial multicast echo received - multicast is working"); + } + + DEBUG("AutoInterface: Received own multicast echo - ignoring"); + return; + } + + // Check if peer already exists + for (auto& peer : _peers) { + if (peer.same_address(addr)) { + peer.last_heard = timestamp; + TRACE("AutoInterface: Refreshed peer " + peer.address_string()); + return; + } + } + + // Add new peer + AutoInterfacePeer new_peer(addr, _data_port, timestamp); + _peers.push_back(new_peer); + + INFO("AutoInterface: Added new peer " + new_peer.address_string()); +} + +#else // POSIX + +void AutoInterface::add_or_refresh_peer(const struct in6_addr& addr, double timestamp) { + // Check if this is our own address + if (memcmp(&addr, &_link_local_address, sizeof(addr)) == 0) { + // Update echo timestamp + _last_multicast_echo = timestamp; + + // Track initial echo received + if (!_initial_echo_received) { + _initial_echo_received = true; + INFO("AutoInterface: Initial multicast echo received - multicast is working"); + } + + DEBUG("AutoInterface: Received own multicast echo - ignoring"); + return; + } + + // Check if peer already exists + for (auto& peer : _peers) { + if (peer.same_address(addr)) { + peer.last_heard = timestamp; + TRACE("AutoInterface: Refreshed peer " + peer.address_string()); + return; + } + } + + // Add new peer + AutoInterfacePeer new_peer(addr, _data_port, timestamp); + _peers.push_back(new_peer); + + INFO("AutoInterface: Added new peer " + new_peer.address_string()); +} + +#endif // ARDUINO + +// ============================================================================ +// Platform-independent: Echo Timeout Checking +// ============================================================================ + +void AutoInterface::check_echo_timeout() { + double now = RNS::Utilities::OS::time(); + + // Only check if we've started announcing + if (_last_announce == 0) { + return; // Haven't sent first announce yet + } + + // Calculate time since last echo + double echo_age = now - _last_multicast_echo; + bool timed_out = (echo_age > MCAST_ECHO_TIMEOUT); + + // Detect timeout state transitions + if (timed_out != _timed_out) { + _timed_out = timed_out; + _carrier_changed = true; + + if (!timed_out) { + WARNING("AutoInterface: Carrier recovered on interface"); + } else { + WARNING("AutoInterface: Multicast echo timeout for interface. Carrier lost."); + } + } + + // One-time firewall diagnostic (after grace period) + double startup_grace = ANNOUNCE_INTERVAL * 3.0; // ~5 seconds + + if (!_initial_echo_received && + (now - _last_announce) > startup_grace && + !_firewall_warning_logged) { + ERROR("AutoInterface: No multicast echoes received. " + "The networking hardware or a firewall may be blocking multicast traffic."); + _firewall_warning_logged = true; + } +} + +// ============================================================================ +// Platform-independent: Deduplication +// ============================================================================ + +void AutoInterface::expire_stale_peers() { + double now = RNS::Utilities::OS::time(); + + _peers.erase( + std::remove_if(_peers.begin(), _peers.end(), + [this, now](const AutoInterfacePeer& peer) { + if (now - peer.last_heard > PEERING_TIMEOUT) { + INFO("AutoInterface: Removed stale peer " + peer.address_string()); + return true; + } + return false; + }), + _peers.end()); +} + +bool AutoInterface::is_duplicate(const Bytes& packet) { + Bytes packet_hash = Identity::full_hash(packet); + + for (const auto& entry : _packet_deque) { + if (entry.hash == packet_hash) { + return true; + } + } + return false; +} + +void AutoInterface::add_to_deque(const Bytes& packet) { + DequeEntry entry; + entry.hash = Identity::full_hash(packet); + entry.timestamp = RNS::Utilities::OS::time(); + + _packet_deque.push_back(entry); + + // Limit deque size + while (_packet_deque.size() > DEQUE_SIZE) { + _packet_deque.pop_front(); + } +} + +void AutoInterface::expire_deque_entries() { + double now = RNS::Utilities::OS::time(); + + while (!_packet_deque.empty() && now - _packet_deque.front().timestamp > DEQUE_TTL) { + _packet_deque.pop_front(); + } +} diff --git a/lib/auto_interface/AutoInterface.h b/lib/auto_interface/AutoInterface.h new file mode 100644 index 0000000..1475c54 --- /dev/null +++ b/lib/auto_interface/AutoInterface.h @@ -0,0 +1,175 @@ +#pragma once + +#include "Interface.h" +#include "Identity.h" +#include "Bytes.h" +#include "Type.h" +#include "AutoInterfacePeer.h" + +#ifdef ARDUINO +#include +#include +#include +#include +#include +#include +#else +#include +#include +#include +#endif + +#include +#include +#include +#include + +// AutoInterface - automatic peer discovery via IPv6 multicast +// Matches Python RNS AutoInterface behavior for interoperability +class AutoInterface : public RNS::InterfaceImpl { + +public: + // Protocol constants (match Python RNS) + static const uint16_t DEFAULT_DISCOVERY_PORT = 29716; + static const uint16_t DEFAULT_DATA_PORT = 42671; + static constexpr const char* DEFAULT_GROUP_ID = "reticulum"; + static constexpr double PEERING_TIMEOUT = 22.0; // seconds (matches Python RNS) + static constexpr double ANNOUNCE_INTERVAL = 1.6; // seconds (matches Python RNS) + static constexpr double MCAST_ECHO_TIMEOUT = 6.5; // seconds (matches Python RNS) + static constexpr double REVERSE_PEERING_INTERVAL = ANNOUNCE_INTERVAL * 3.25; // ~5.2 seconds + static constexpr double PEER_JOB_INTERVAL = 4.0; // seconds (matches Python RNS) + static const size_t DEQUE_SIZE = 48; // packet dedup window + static constexpr double DEQUE_TTL = 0.75; // seconds + static const uint32_t BITRATE_GUESS = 10 * 1000 * 1000; + static const uint16_t HW_MTU = 1196; + + // Discovery token is full_hash(group_id + link_local_address) = 32 bytes + // Python RNS sends and expects the full 32-byte hash (HASHLENGTH//8 = 256//8 = 32) + static const size_t TOKEN_SIZE = 32; + +public: + AutoInterface(const char* name = "AutoInterface"); + virtual ~AutoInterface(); + + // Configuration (call before start()) + void set_group_id(const std::string& group_id) { _group_id = group_id; } + void set_discovery_port(uint16_t port) { _discovery_port = port; } + void set_data_port(uint16_t port) { _data_port = port; } + void set_interface_name(const std::string& ifname) { _ifname = ifname; } + + // InterfaceImpl overrides + virtual bool start() override; + virtual void stop() override; + virtual void loop() override; + + virtual inline std::string toString() const override { + return "AutoInterface[" + _name + "/" + _group_id + "]"; + } + + // Getters for testing + const RNS::Bytes& get_discovery_token() const { return _discovery_token; } + const RNS::Bytes& get_multicast_address() const { return _multicast_address_bytes; } + size_t peer_count() const { return _peers.size(); } + + // Carrier state tracking (matches Python RNS) + bool carrier_changed() { + bool changed = _carrier_changed; + _carrier_changed = false; // Clear flag on read + return changed; + } + void clear_carrier_changed() { _carrier_changed = false; } + bool is_timed_out() const { return _timed_out; } + +protected: + virtual void send_outgoing(const RNS::Bytes& data) override; + +private: + // Discovery and addressing + void calculate_multicast_address(); + void calculate_discovery_token(); + bool get_link_local_address(); + + // Socket operations + bool setup_discovery_socket(); + bool setup_unicast_discovery_socket(); + bool setup_data_socket(); + bool join_multicast_group(); + + // Main loop operations + void send_announce(); + void process_discovery(); + void process_unicast_discovery(); + void send_reverse_peering(); + void reverse_announce(AutoInterfacePeer& peer); + void process_data(); + void check_echo_timeout(); + void check_link_local_address(); + + // Peer management +#ifdef ARDUINO + void add_or_refresh_peer(const IPv6Address& addr, double timestamp); +#else + void add_or_refresh_peer(const struct in6_addr& addr, double timestamp); +#endif + void expire_stale_peers(); + + // Deduplication + bool is_duplicate(const RNS::Bytes& packet); + void add_to_deque(const RNS::Bytes& packet); + void expire_deque_entries(); + + // Configuration + std::string _group_id = DEFAULT_GROUP_ID; + uint16_t _discovery_port = DEFAULT_DISCOVERY_PORT; + uint16_t _unicast_discovery_port = DEFAULT_DISCOVERY_PORT + 1; // 29717 + uint16_t _data_port = DEFAULT_DATA_PORT; + std::string _ifname; // Network interface name (e.g., "eth0", "wlan0") + + // Computed values + RNS::Bytes _discovery_token; // 16 bytes + RNS::Bytes _multicast_address_bytes; // 16 bytes (IPv6) + struct in6_addr _multicast_address; + struct in6_addr _link_local_address; + std::string _link_local_address_str; + std::string _multicast_address_str; // For logging + bool _data_socket_ok = false; // Data socket initialized successfully +#ifdef ARDUINO + IPv6Address _link_local_ip; // ESP32: link-local as IPv6Address + IPv6Address _multicast_ip; // ESP32: multicast as IPv6Address +#endif + + // Sockets +#ifdef ARDUINO + int _discovery_socket = -1; // Raw socket for IPv6 multicast discovery + int _unicast_discovery_socket = -1; // Raw socket for unicast discovery (reverse peering) + int _data_socket = -1; // Raw socket for IPv6 unicast data (WiFiUDP doesn't support IPv6) + unsigned int _if_index = 0; // Interface index for scope_id +#else + int _discovery_socket = -1; + int _unicast_discovery_socket = -1; // Socket for unicast discovery (reverse peering) + int _data_socket = -1; + unsigned int _if_index = 0; // Interface index for multicast +#endif + + // Peers and state + std::vector _peers; + double _last_announce = 0; + double _last_peer_job = 0; // Timestamp of last peer job check + + // Echo tracking (matches Python RNS multicast_echoes / initial_echoes) + double _last_multicast_echo = 0.0; // Timestamp of last own echo received + bool _initial_echo_received = false; // True once first echo received + bool _timed_out = false; // Current timeout state + bool _carrier_changed = false; // Flag for Transport layer notification + bool _firewall_warning_logged = false; // Track firewall warning (log once) + + // Deduplication: pairs of (packet_hash, timestamp) + struct DequeEntry { + RNS::Bytes hash; + double timestamp; + }; + std::deque _packet_deque; + + // Receive buffer + RNS::Bytes _buffer; +}; diff --git a/lib/auto_interface/AutoInterfacePeer.h b/lib/auto_interface/AutoInterfacePeer.h new file mode 100644 index 0000000..d5994e5 --- /dev/null +++ b/lib/auto_interface/AutoInterfacePeer.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include + +#ifdef ARDUINO +#include +#include +#else +#include +#include +#endif + +// Lightweight peer info holder for AutoInterface +// Not a full InterfaceImpl - just holds address and timing info +struct AutoInterfacePeer { + // IPv6 address storage +#ifdef ARDUINO + IPv6Address address; // Must use IPv6Address, not IPAddress (which is IPv4-only!) +#else + struct in6_addr address; +#endif + uint16_t data_port; + double last_heard; // Timestamp of last activity + double last_outbound; // Timestamp of last reverse peering sent + bool is_local; // True if this is our own announcement (to ignore) + + AutoInterfacePeer() : data_port(0), last_heard(0), last_outbound(0), is_local(false) { +#ifndef ARDUINO + memset(&address, 0, sizeof(address)); +#endif + } + +#ifdef ARDUINO + AutoInterfacePeer(const IPv6Address& addr, uint16_t port, double time, bool local = false) + : address(addr), data_port(port), last_heard(time), last_outbound(0), is_local(local) {} +#else + AutoInterfacePeer(const struct in6_addr& addr, uint16_t port, double time, bool local = false) + : address(addr), data_port(port), last_heard(time), last_outbound(0), is_local(local) {} +#endif + + // Get string representation of address for logging + std::string address_string() const { +#ifdef ARDUINO + // ESP32 IPAddress.toString() is IPv4-only, need manual IPv6 formatting + char buf[64]; + // Read 16 bytes from IPAddress + uint8_t addr[16]; + for (int i = 0; i < 16; i++) { + addr[i] = address[i]; + } + snprintf(buf, sizeof(buf), "%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7], + addr[8], addr[9], addr[10], addr[11], addr[12], addr[13], addr[14], addr[15]); + return std::string(buf); +#else + char buf[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &address, buf, sizeof(buf)); + return std::string(buf); +#endif + } + + // Check if two peers have the same address +#ifdef ARDUINO + bool same_address(const IPv6Address& other) const { + // Use IPv6Address == operator (properly compares all 16 bytes) + return address == other; + } +#else + bool same_address(const struct in6_addr& other) const { + return memcmp(&address, &other, sizeof(address)) == 0; + } +#endif +}; diff --git a/lib/auto_interface/auto_config.h.example b/lib/auto_interface/auto_config.h.example new file mode 100644 index 0000000..5bc83f1 --- /dev/null +++ b/lib/auto_interface/auto_config.h.example @@ -0,0 +1,19 @@ +#pragma once + +// AutoInterface configuration - DO NOT COMMIT auto_config.h +// Copy auto_config.h.example to auto_config.h and fill in your values + +// WiFi credentials (for ESP32) +#define AUTO_WIFI_SSID "your_wifi_ssid" +#define AUTO_WIFI_PASSWORD "your_wifi_password" + +// Network interface to use (optional - leave empty for auto-detect) +// Examples: "eth0", "wlan0", "enp0s3" +#define AUTO_INTERFACE_NAME "" + +// Group ID (default: "reticulum" - must match Python RNS config) +#define AUTO_GROUP_ID "reticulum" + +// Ports (default values match Python RNS) +#define AUTO_DISCOVERY_PORT 29716 +#define AUTO_DATA_PORT 42671 diff --git a/lib/auto_interface/library.json b/lib/auto_interface/library.json new file mode 100644 index 0000000..5f1fc6d --- /dev/null +++ b/lib/auto_interface/library.json @@ -0,0 +1,15 @@ +{ + "name": "auto_interface", + "version": "0.0.1", + "description": "AutoInterface implementation for peer discovery via IPv6 multicast", + "keywords": "reticulum, autointerface, ipv6, multicast", + "license": "MIT", + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "dependencies": { + "microReticulum": "*" + }, + "build": { + "flags": "-std=gnu++11 -I../../../../deps/microReticulum/src" + } +} diff --git a/lib/ble_interface/BLEFragmenter.cpp b/lib/ble_interface/BLEFragmenter.cpp new file mode 100644 index 0000000..d45682c --- /dev/null +++ b/lib/ble_interface/BLEFragmenter.cpp @@ -0,0 +1,177 @@ +/** + * @file BLEFragmenter.cpp + * @brief BLE-Reticulum Protocol v2.2 packet fragmenter implementation + */ + +#include "BLEFragmenter.h" +#include "Log.h" + +namespace RNS { namespace BLE { + +BLEFragmenter::BLEFragmenter(size_t mtu) { + setMTU(mtu); +} + +void BLEFragmenter::setMTU(size_t mtu) { + // Ensure MTU is at least the minimum + _mtu = (mtu >= MTU::MINIMUM) ? mtu : MTU::MINIMUM; + + // Calculate payload size (MTU minus header) + if (_mtu > Fragment::HEADER_SIZE) { + _payload_size = _mtu - Fragment::HEADER_SIZE; + } else { + _payload_size = 0; + WARNING("BLEFragmenter: MTU too small for fragmentation"); + } +} + +bool BLEFragmenter::needsFragmentation(const Bytes& data) const { + return data.size() > _payload_size; +} + +uint16_t BLEFragmenter::calculateFragmentCount(size_t data_size) const { + if (_payload_size == 0) return 0; + if (data_size == 0) return 1; // Empty data still produces one fragment + return static_cast((data_size + _payload_size - 1) / _payload_size); +} + +std::vector BLEFragmenter::fragment(const Bytes& data, uint16_t sequence_base) { + std::vector fragments; + + if (_payload_size == 0) { + ERROR("BLEFragmenter: Cannot fragment with zero payload size"); + return fragments; + } + + // Handle empty data case + if (data.size() == 0) { + // Single empty END fragment + fragments.push_back(createFragment(Fragment::END, sequence_base, 1, Bytes())); + return fragments; + } + + uint16_t total_fragments = calculateFragmentCount(data.size()); + + // Pre-allocate vector to avoid incremental reallocations + fragments.reserve(total_fragments); + + size_t offset = 0; + + for (uint16_t i = 0; i < total_fragments; i++) { + // Calculate payload size for this fragment + size_t remaining = data.size() - offset; + size_t chunk_size = (remaining < _payload_size) ? remaining : _payload_size; + + // Extract payload chunk + Bytes payload(data.data() + offset, chunk_size); + offset += chunk_size; + + // Determine fragment type + Fragment::Type type; + if (total_fragments == 1) { + // Single fragment - use END type + type = Fragment::END; + } else if (i == 0) { + // First of multiple fragments + type = Fragment::START; + } else if (i == total_fragments - 1) { + // Last fragment + type = Fragment::END; + } else { + // Middle fragment + type = Fragment::CONTINUE; + } + + uint16_t sequence = sequence_base + i; + fragments.push_back(createFragment(type, sequence, total_fragments, payload)); + } + + { + char buf[80]; + snprintf(buf, sizeof(buf), "BLEFragmenter: Fragmented %zu bytes into %zu fragments", + data.size(), fragments.size()); + TRACE(buf); + } + + return fragments; +} + +Bytes BLEFragmenter::createFragment(Fragment::Type type, uint16_t sequence, + uint16_t total_fragments, const Bytes& payload) { + // Allocate buffer for header + payload + size_t total_size = Fragment::HEADER_SIZE + payload.size(); + Bytes fragment(total_size); + uint8_t* ptr = fragment.writable(total_size); + fragment.resize(total_size); + + // Byte 0: Type + ptr[0] = static_cast(type); + + // Bytes 1-2: Sequence number (big-endian) + ptr[1] = static_cast((sequence >> 8) & 0xFF); + ptr[2] = static_cast(sequence & 0xFF); + + // Bytes 3-4: Total fragments (big-endian) + ptr[3] = static_cast((total_fragments >> 8) & 0xFF); + ptr[4] = static_cast(total_fragments & 0xFF); + + // Bytes 5+: Payload + if (payload.size() > 0) { + memcpy(ptr + Fragment::HEADER_SIZE, payload.data(), payload.size()); + } + + return fragment; +} + +bool BLEFragmenter::parseHeader(const Bytes& fragment, Fragment::Type& type, + uint16_t& sequence, uint16_t& total_fragments) { + if (fragment.size() < Fragment::HEADER_SIZE) { + return false; + } + + const uint8_t* ptr = fragment.data(); + + // Byte 0: Type + uint8_t type_byte = ptr[0]; + if (type_byte != Fragment::START && + type_byte != Fragment::CONTINUE && + type_byte != Fragment::END) { + return false; + } + type = static_cast(type_byte); + + // Bytes 1-2: Sequence number (big-endian) + sequence = (static_cast(ptr[1]) << 8) | static_cast(ptr[2]); + + // Bytes 3-4: Total fragments (big-endian) + total_fragments = (static_cast(ptr[3]) << 8) | static_cast(ptr[4]); + + // Validate total_fragments is non-zero + if (total_fragments == 0) { + return false; + } + + // Validate sequence < total_fragments + if (sequence >= total_fragments) { + return false; + } + + return true; +} + +Bytes BLEFragmenter::extractPayload(const Bytes& fragment) { + if (fragment.size() <= Fragment::HEADER_SIZE) { + return Bytes(); + } + + return Bytes(fragment.data() + Fragment::HEADER_SIZE, + fragment.size() - Fragment::HEADER_SIZE); +} + +bool BLEFragmenter::isValidFragment(const Bytes& fragment) { + Fragment::Type type; + uint16_t sequence, total; + return parseHeader(fragment, type, sequence, total); +} + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEFragmenter.h b/lib/ble_interface/BLEFragmenter.h new file mode 100644 index 0000000..ffdc9a4 --- /dev/null +++ b/lib/ble_interface/BLEFragmenter.h @@ -0,0 +1,125 @@ +/** + * @file BLEFragmenter.h + * @brief BLE-Reticulum Protocol v2.2 packet fragmenter + * + * Fragments outgoing Reticulum packets into BLE-sized chunks with the v2.2 + * 5-byte header format. This class has no BLE dependencies and can be used + * for testing on native builds. + * + * Fragment Header Format (5 bytes): + * Byte 0: Type (0x01=START, 0x02=CONTINUE, 0x03=END) + * Bytes 1-2: Sequence number (big-endian uint16_t) + * Bytes 3-4: Total fragments (big-endian uint16_t) + * Bytes 5+: Payload data + */ +#pragma once + +#include "BLETypes.h" +#include "Bytes.h" + +#include +#include + +namespace RNS { namespace BLE { + +class BLEFragmenter { +public: + /** + * @brief Construct a fragmenter with specified MTU + * @param mtu The negotiated BLE MTU (default: minimum BLE MTU of 23) + */ + explicit BLEFragmenter(size_t mtu = MTU::MINIMUM); + + /** + * @brief Set the MTU for fragmentation calculations + * + * Call this when MTU is renegotiated with a peer. + * @param mtu The new MTU value + */ + void setMTU(size_t mtu); + + /** + * @brief Get the current MTU + */ + size_t getMTU() const { return _mtu; } + + /** + * @brief Get the maximum payload size per fragment (MTU - HEADER_SIZE) + */ + size_t getPayloadSize() const { return _payload_size; } + + /** + * @brief Check if a packet needs fragmentation + * @param data The packet to check + * @return true if packet exceeds single fragment payload capacity + */ + bool needsFragmentation(const Bytes& data) const; + + /** + * @brief Calculate number of fragments needed for a packet + * @param data_size Size of the data to fragment + * @return Number of fragments needed (minimum 1) + */ + uint16_t calculateFragmentCount(size_t data_size) const; + + /** + * @brief Fragment a packet into BLE-sized chunks + * + * @param data The complete packet to fragment + * @param sequence_base Starting sequence number (default 0) + * @return Vector of fragments, each with the 5-byte header prepended + * + * For a single-fragment packet, returns one fragment with type=END. + * For multi-fragment packets: + * - First fragment has type=START + * - Middle fragments have type=CONTINUE + * - Last fragment has type=END + */ + std::vector fragment(const Bytes& data, uint16_t sequence_base = 0); + + /** + * @brief Create a single fragment with proper header + * + * @param type Fragment type (START, CONTINUE, END) + * @param sequence Sequence number for this fragment + * @param total_fragments Total number of fragments in this message + * @param payload The fragment payload data + * @return Complete fragment with 5-byte header prepended + */ + static Bytes createFragment(Fragment::Type type, uint16_t sequence, + uint16_t total_fragments, const Bytes& payload); + + /** + * @brief Parse the header from a received fragment + * + * @param fragment The received fragment data (must be at least HEADER_SIZE bytes) + * @param type Output: fragment type + * @param sequence Output: sequence number + * @param total_fragments Output: total fragment count + * @return true if header is valid and was parsed successfully + */ + static bool parseHeader(const Bytes& fragment, Fragment::Type& type, + uint16_t& sequence, uint16_t& total_fragments); + + /** + * @brief Extract payload from a fragment (removes header) + * + * @param fragment The complete fragment with header + * @return The payload portion (empty if fragment is too small) + */ + static Bytes extractPayload(const Bytes& fragment); + + /** + * @brief Validate a fragment header + * + * @param fragment The fragment to validate + * @return true if the fragment has a valid header + */ + static bool isValidFragment(const Bytes& fragment); + +private: + size_t _mtu; + size_t _payload_size; +}; + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEIdentityManager.cpp b/lib/ble_interface/BLEIdentityManager.cpp new file mode 100644 index 0000000..efefc31 --- /dev/null +++ b/lib/ble_interface/BLEIdentityManager.cpp @@ -0,0 +1,493 @@ +/** + * @file BLEIdentityManager.cpp + * @brief BLE-Reticulum Protocol v2.2 identity handshake manager implementation + * + * Uses fixed-size pools instead of STL containers to eliminate heap fragmentation. + */ + +#include "BLEIdentityManager.h" +#include "Log.h" + +namespace RNS { namespace BLE { + +BLEIdentityManager::BLEIdentityManager() { + // Initialize all pools to empty state + for (size_t i = 0; i < ADDRESS_IDENTITY_POOL_SIZE; i++) { + _address_identity_pool[i].clear(); + } + for (size_t i = 0; i < HANDSHAKE_POOL_SIZE; i++) { + _handshakes_pool[i].clear(); + } +} + +void BLEIdentityManager::setLocalIdentity(const Bytes& identity_hash) { + if (identity_hash.size() >= Limits::IDENTITY_SIZE) { + _local_identity = Bytes(identity_hash.data(), Limits::IDENTITY_SIZE); + DEBUG("BLEIdentityManager: Local identity set: " + _local_identity.toHex().substr(0, 8) + "..."); + } else { + ERROR("BLEIdentityManager: Invalid identity size: " + std::to_string(identity_hash.size())); + } +} + +void BLEIdentityManager::setHandshakeCompleteCallback(HandshakeCompleteCallback callback) { + _handshake_complete_callback = callback; +} + +void BLEIdentityManager::setHandshakeFailedCallback(HandshakeFailedCallback callback) { + _handshake_failed_callback = callback; +} + +void BLEIdentityManager::setMacRotationCallback(MacRotationCallback callback) { + _mac_rotation_callback = callback; +} + +//============================================================================= +// Handshake Operations +//============================================================================= + +Bytes BLEIdentityManager::initiateHandshake(const Bytes& mac_address) { + if (!hasLocalIdentity()) { + ERROR("BLEIdentityManager: Cannot initiate handshake without local identity"); + return Bytes(); + } + + if (mac_address.size() < Limits::MAC_SIZE) { + ERROR("BLEIdentityManager: Invalid MAC address size"); + return Bytes(); + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + // Create or update handshake session + HandshakeSession* session = getOrCreateSession(mac); + if (!session) { + WARNING("BLEIdentityManager: Handshake pool is full, cannot initiate"); + return Bytes(); + } + session->is_central = true; + session->state = HandshakeState::INITIATED; + session->started_at = Utilities::OS::time(); + + DEBUG("BLEIdentityManager: Initiating handshake as central with " + + BLEAddress(mac.data()).toString()); + + // Return our identity to be written to peer + return _local_identity; +} + +bool BLEIdentityManager::processReceivedData(const Bytes& mac_address, const Bytes& data, bool is_central) { + if (mac_address.size() < Limits::MAC_SIZE) { + return false; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + // Check if this looks like a handshake + if (!isHandshakeData(data, mac)) { + return false; // Regular data, not consumed + } + + // This is a handshake - extract peer's identity + if (data.size() != Limits::IDENTITY_SIZE) { + // Should not happen given isHandshakeData check, but be safe + return false; + } + + Bytes peer_identity(data.data(), Limits::IDENTITY_SIZE); + + DEBUG("BLEIdentityManager: Received identity handshake from " + + BLEAddress(mac.data()).toString() + ": " + + peer_identity.toHex().substr(0, 8) + "..."); + + // Complete the handshake + completeHandshake(mac, peer_identity, is_central); + + return true; // Handshake data consumed +} + +bool BLEIdentityManager::isHandshakeData(const Bytes& data, const Bytes& mac_address) const { + // Handshake is detected if: + // 1. Data is exactly 16 bytes (identity size) + // 2. No existing identity mapping for this MAC + + if (data.size() != Limits::IDENTITY_SIZE) { + return false; + } + + if (mac_address.size() < Limits::MAC_SIZE) { + return false; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + // Check if we already have identity for this MAC + const AddressIdentitySlot* slot = findAddressToIdentitySlot(mac); + if (slot) { + // Already have identity - this is regular data, not handshake + return false; + } + + // No existing identity + 16 bytes = handshake + return true; +} + +void BLEIdentityManager::completeHandshake(const Bytes& mac_address, const Bytes& peer_identity, + bool is_central) { + DEBUG("BLEIdentityManager::completeHandshake: Starting"); + if (mac_address.size() < Limits::MAC_SIZE || peer_identity.size() != Limits::IDENTITY_SIZE) { + DEBUG("BLEIdentityManager::completeHandshake: Invalid sizes, returning"); + return; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + Bytes identity(peer_identity.data(), Limits::IDENTITY_SIZE); + DEBUG("BLEIdentityManager::completeHandshake: Created local copies"); + + // Check for MAC rotation: same identity from different MAC address + Bytes old_mac; + bool is_rotation = false; + AddressIdentitySlot* existing_slot = findIdentityToAddressSlot(identity); + if (existing_slot && existing_slot->mac_address != mac) { + // MAC rotation detected! + old_mac = existing_slot->mac_address; + is_rotation = true; + + INFO("BLEIdentityManager: MAC rotation detected for identity " + + identity.toHex().substr(0, 8) + "...: " + + BLEAddress(old_mac.data()).toString() + " -> " + + BLEAddress(mac.data()).toString()); + + // Update the slot with new MAC (same identity) + existing_slot->mac_address = mac; + } else if (!existing_slot) { + // New mapping - add to pool + if (!setAddressIdentityMapping(mac, identity)) { + WARNING("BLEIdentityManager: Address-identity pool is full"); + return; + } + } + DEBUG("BLEIdentityManager::completeHandshake: Stored mappings"); + + // Remove handshake session + removeHandshakeSession(mac); + DEBUG("BLEIdentityManager::completeHandshake: Removed handshake session"); + + DEBUG("BLEIdentityManager: Handshake complete with " + + BLEAddress(mac.data()).toString() + + " identity: " + identity.toHex().substr(0, 8) + "..." + + (is_central ? " (we are central)" : " (we are peripheral)")); + + // Invoke MAC rotation callback if this was a rotation + if (is_rotation && _mac_rotation_callback) { + DEBUG("BLEIdentityManager::completeHandshake: Calling MAC rotation callback"); + _mac_rotation_callback(old_mac, mac, identity); + DEBUG("BLEIdentityManager::completeHandshake: MAC rotation callback returned"); + } + + // Invoke handshake complete callback + if (_handshake_complete_callback) { + DEBUG("BLEIdentityManager::completeHandshake: Calling handshake complete callback"); + _handshake_complete_callback(mac, identity, is_central); + DEBUG("BLEIdentityManager::completeHandshake: Callback returned"); + } else { + DEBUG("BLEIdentityManager::completeHandshake: No callback set"); + } +} + +void BLEIdentityManager::checkTimeouts() { + double now = Utilities::OS::time(); + + for (size_t i = 0; i < HANDSHAKE_POOL_SIZE; i++) { + HandshakeSession& session = _handshakes_pool[i]; + if (!session.in_use) continue; + + if (session.state != HandshakeState::COMPLETE) { + double age = now - session.started_at; + if (age > Timing::HANDSHAKE_TIMEOUT) { + Bytes mac = session.mac_address; + + WARNING("BLEIdentityManager: Handshake timeout for " + + BLEAddress(mac.data()).toString()); + + if (_handshake_failed_callback) { + _handshake_failed_callback(mac, "Handshake timeout"); + } + + session.clear(); + } + } + } +} + +//============================================================================= +// Identity Mapping +//============================================================================= + +Bytes BLEIdentityManager::getIdentityForMac(const Bytes& mac_address) const { + if (mac_address.size() < Limits::MAC_SIZE) { + return Bytes(); + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + const AddressIdentitySlot* slot = findAddressToIdentitySlot(mac); + if (slot) { + return slot->identity; + } + + return Bytes(); +} + +Bytes BLEIdentityManager::getMacForIdentity(const Bytes& identity) const { + if (identity.size() != Limits::IDENTITY_SIZE) { + return Bytes(); + } + + const AddressIdentitySlot* slot = findIdentityToAddressSlot(identity); + if (slot) { + return slot->mac_address; + } + + return Bytes(); +} + +bool BLEIdentityManager::hasIdentity(const Bytes& mac_address) const { + if (mac_address.size() < Limits::MAC_SIZE) { + return false; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + return findAddressToIdentitySlot(mac) != nullptr; +} + +Bytes BLEIdentityManager::findIdentityByPrefix(const Bytes& prefix) const { + if (prefix.size() == 0 || prefix.size() > Limits::IDENTITY_SIZE) { + return Bytes(); + } + + // Search through all known identities for one that starts with this prefix + for (size_t i = 0; i < ADDRESS_IDENTITY_POOL_SIZE; i++) { + if (_address_identity_pool[i].in_use) { + const Bytes& identity = _address_identity_pool[i].identity; + if (identity.size() >= prefix.size() && + memcmp(identity.data(), prefix.data(), prefix.size()) == 0) { + return identity; + } + } + } + + return Bytes(); +} + +void BLEIdentityManager::updateMacForIdentity(const Bytes& identity, const Bytes& new_mac) { + if (identity.size() != Limits::IDENTITY_SIZE || new_mac.size() < Limits::MAC_SIZE) { + return; + } + + Bytes mac(new_mac.data(), Limits::MAC_SIZE); + + AddressIdentitySlot* slot = findIdentityToAddressSlot(identity); + if (!slot) { + return; // Unknown identity + } + + // Update MAC address in the slot + slot->mac_address = mac; + + DEBUG("BLEIdentityManager: Updated MAC for identity " + + identity.toHex().substr(0, 8) + "... to " + + BLEAddress(mac.data()).toString()); +} + +void BLEIdentityManager::removeMapping(const Bytes& mac_address) { + if (mac_address.size() < Limits::MAC_SIZE) { + return; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + removeAddressIdentityMapping(mac); + + DEBUG("BLEIdentityManager: Removed mapping for " + + BLEAddress(mac.data()).toString()); + + // Also clean up any pending handshake + removeHandshakeSession(mac); +} + +void BLEIdentityManager::clearAllMappings() { + for (size_t i = 0; i < ADDRESS_IDENTITY_POOL_SIZE; i++) { + _address_identity_pool[i].clear(); + } + for (size_t i = 0; i < HANDSHAKE_POOL_SIZE; i++) { + _handshakes_pool[i].clear(); + } + + DEBUG("BLEIdentityManager: Cleared all identity mappings"); +} + +size_t BLEIdentityManager::knownPeerCount() const { + size_t count = 0; + for (size_t i = 0; i < ADDRESS_IDENTITY_POOL_SIZE; i++) { + if (_address_identity_pool[i].in_use) count++; + } + return count; +} + +bool BLEIdentityManager::isHandshakeInProgress(const Bytes& mac_address) const { + if (mac_address.size() < Limits::MAC_SIZE) { + return false; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + const HandshakeSession* session = findHandshakeSession(mac); + if (session) { + return session->state != HandshakeState::NONE && + session->state != HandshakeState::COMPLETE; + } + + return false; +} + +//============================================================================= +// Pool Helper Methods - Address to Identity Mapping +//============================================================================= + +BLEIdentityManager::AddressIdentitySlot* BLEIdentityManager::findAddressToIdentitySlot(const Bytes& mac) { + if (mac.size() < Limits::MAC_SIZE) return nullptr; + + for (size_t i = 0; i < ADDRESS_IDENTITY_POOL_SIZE; i++) { + if (_address_identity_pool[i].in_use && + _address_identity_pool[i].mac_address == mac) { + return &_address_identity_pool[i]; + } + } + return nullptr; +} + +const BLEIdentityManager::AddressIdentitySlot* BLEIdentityManager::findAddressToIdentitySlot(const Bytes& mac) const { + return const_cast(this)->findAddressToIdentitySlot(mac); +} + +BLEIdentityManager::AddressIdentitySlot* BLEIdentityManager::findIdentityToAddressSlot(const Bytes& identity) { + if (identity.size() != Limits::IDENTITY_SIZE) return nullptr; + + for (size_t i = 0; i < ADDRESS_IDENTITY_POOL_SIZE; i++) { + if (_address_identity_pool[i].in_use && + _address_identity_pool[i].identity == identity) { + return &_address_identity_pool[i]; + } + } + return nullptr; +} + +const BLEIdentityManager::AddressIdentitySlot* BLEIdentityManager::findIdentityToAddressSlot(const Bytes& identity) const { + return const_cast(this)->findIdentityToAddressSlot(identity); +} + +BLEIdentityManager::AddressIdentitySlot* BLEIdentityManager::findEmptyAddressIdentitySlot() { + for (size_t i = 0; i < ADDRESS_IDENTITY_POOL_SIZE; i++) { + if (!_address_identity_pool[i].in_use) { + return &_address_identity_pool[i]; + } + } + return nullptr; +} + +bool BLEIdentityManager::setAddressIdentityMapping(const Bytes& mac, const Bytes& identity) { + // Check if already exists for this MAC + AddressIdentitySlot* existing = findAddressToIdentitySlot(mac); + if (existing) { + existing->identity = identity; + return true; + } + + // Check if already exists for this identity (MAC rotation case) + existing = findIdentityToAddressSlot(identity); + if (existing) { + existing->mac_address = mac; + return true; + } + + // Find empty slot + AddressIdentitySlot* slot = findEmptyAddressIdentitySlot(); + if (!slot) { + WARNING("BLEIdentityManager: Address-identity pool is full"); + return false; + } + + slot->in_use = true; + slot->mac_address = mac; + slot->identity = identity; + return true; +} + +void BLEIdentityManager::removeAddressIdentityMapping(const Bytes& mac) { + AddressIdentitySlot* slot = findAddressToIdentitySlot(mac); + if (slot) { + slot->clear(); + } +} + +//============================================================================= +// Pool Helper Methods - Handshake Sessions +//============================================================================= + +BLEIdentityManager::HandshakeSession* BLEIdentityManager::findHandshakeSession(const Bytes& mac) { + if (mac.size() < Limits::MAC_SIZE) return nullptr; + + for (size_t i = 0; i < HANDSHAKE_POOL_SIZE; i++) { + if (_handshakes_pool[i].in_use && + _handshakes_pool[i].mac_address == mac) { + return &_handshakes_pool[i]; + } + } + return nullptr; +} + +const BLEIdentityManager::HandshakeSession* BLEIdentityManager::findHandshakeSession(const Bytes& mac) const { + return const_cast(this)->findHandshakeSession(mac); +} + +BLEIdentityManager::HandshakeSession* BLEIdentityManager::findEmptyHandshakeSlot() { + for (size_t i = 0; i < HANDSHAKE_POOL_SIZE; i++) { + if (!_handshakes_pool[i].in_use) { + return &_handshakes_pool[i]; + } + } + return nullptr; +} + +BLEIdentityManager::HandshakeSession* BLEIdentityManager::getOrCreateSession(const Bytes& mac_address) { + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + // Check if session already exists + HandshakeSession* existing = findHandshakeSession(mac); + if (existing) { + return existing; + } + + // Find empty slot + HandshakeSession* slot = findEmptyHandshakeSlot(); + if (!slot) { + return nullptr; // Pool is full + } + + // Create new session + slot->in_use = true; + slot->mac_address = mac; + slot->state = HandshakeState::NONE; + slot->started_at = Utilities::OS::time(); + + return slot; +} + +void BLEIdentityManager::removeHandshakeSession(const Bytes& mac) { + HandshakeSession* session = findHandshakeSession(mac); + if (session) { + session->clear(); + } +} + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEIdentityManager.h b/lib/ble_interface/BLEIdentityManager.h new file mode 100644 index 0000000..1694c33 --- /dev/null +++ b/lib/ble_interface/BLEIdentityManager.h @@ -0,0 +1,352 @@ +/** + * @file BLEIdentityManager.h + * @brief BLE-Reticulum Protocol v2.2 identity handshake manager + * + * Manages the identity handshake protocol and address-to-identity mapping. + * + * Handshake Protocol (v2.2): + * 1. Central connects to peripheral + * 2. Central writes 16-byte identity to RX characteristic + * 3. Peripheral detects handshake: exactly 16 bytes AND no existing identity for that address + * 4. Both sides now have bidirectional identity mapping + * + * The identity is the first 16 bytes of the Reticulum transport identity hash, + * which remains stable across MAC address rotations. + * + * Uses fixed-size pools instead of STL containers to eliminate heap fragmentation. + */ +#pragma once + +#include "BLETypes.h" +#include "Bytes.h" +#include "Utilities/OS.h" + +#include +#include + +namespace RNS { namespace BLE { + +class BLEIdentityManager { +public: + //========================================================================= + // Pool Configuration + //========================================================================= + static constexpr size_t ADDRESS_IDENTITY_POOL_SIZE = 16; + static constexpr size_t HANDSHAKE_POOL_SIZE = 4; + /** + * @brief Callback when handshake completes successfully + * + * @param mac_address The peer's current MAC address + * @param peer_identity The peer's 16-byte identity hash + * @param is_central true if we are the central (we initiated) + */ + using HandshakeCompleteCallback = std::function; + + /** + * @brief Callback when handshake fails + * + * @param mac_address The peer's MAC address + * @param reason Description of the failure + */ + using HandshakeFailedCallback = std::function; + + /** + * @brief Callback when MAC rotation is detected + * + * Called when an identity we already know appears from a different MAC address. + * This is common on Android which rotates BLE MAC addresses every ~15 minutes. + * + * @param old_mac The previous MAC address for this identity + * @param new_mac The new MAC address + * @param identity The stable 16-byte identity + */ + using MacRotationCallback = std::function; + +public: + BLEIdentityManager(); + + /** + * @brief Set our local identity (from RNS::Identity) + * + * Must be called before any handshakes. The identity should be the + * first 16 bytes of the transport identity hash. + * + * @param identity_hash The 16-byte identity hash + */ + void setLocalIdentity(const Bytes& identity_hash); + + /** + * @brief Get our local identity hash + */ + const Bytes& getLocalIdentity() const { return _local_identity; } + + /** + * @brief Check if local identity is set + */ + bool hasLocalIdentity() const { return _local_identity.size() == Limits::IDENTITY_SIZE; } + + /** + * @brief Set callback for successful handshakes + */ + void setHandshakeCompleteCallback(HandshakeCompleteCallback callback); + + /** + * @brief Set callback for failed handshakes + */ + void setHandshakeFailedCallback(HandshakeFailedCallback callback); + + /** + * @brief Set callback for MAC rotation detection + */ + void setMacRotationCallback(MacRotationCallback callback); + + //========================================================================= + // Handshake Operations + //========================================================================= + + /** + * @brief Start handshake as central (initiator) + * + * Called after BLE connection is established. Returns the identity + * bytes that should be written to the peer's RX characteristic. + * + * @param mac_address Peer's MAC address + * @return The 16-byte identity to write to peer's RX characteristic + */ + Bytes initiateHandshake(const Bytes& mac_address); + + /** + * @brief Process received data to detect/complete handshake + * + * This should be called for all received data. The function detects + * whether the data is an identity handshake or regular data. + * + * @param mac_address Source MAC address + * @param data Received data (may be identity or regular packet) + * @param is_central true if we are central role for this connection + * @return true if this was a handshake message (consumed), false if regular data + */ + bool processReceivedData(const Bytes& mac_address, const Bytes& data, bool is_central); + + /** + * @brief Check if data looks like an identity handshake + * + * A handshake is detected if: + * - Data is exactly 16 bytes + * - No existing identity mapping exists for this MAC address + * + * @param data The received data + * @param mac_address The sender's MAC + * @return true if this appears to be a handshake + */ + bool isHandshakeData(const Bytes& data, const Bytes& mac_address) const; + + /** + * @brief Mark handshake as complete for a peer + * + * Called after receiving identity from peer or after writing our identity. + * + * @param mac_address The peer's MAC address + * @param peer_identity The peer's 16-byte identity + * @param is_central true if we are the central + */ + void completeHandshake(const Bytes& mac_address, const Bytes& peer_identity, bool is_central); + + /** + * @brief Check for timed-out handshakes + */ + void checkTimeouts(); + + //========================================================================= + // Identity Mapping + //========================================================================= + + /** + * @brief Get identity for a MAC address + * @return Identity bytes or empty if not known + */ + Bytes getIdentityForMac(const Bytes& mac_address) const; + + /** + * @brief Get MAC address for an identity + * @return MAC address or empty if not known + */ + Bytes getMacForIdentity(const Bytes& identity) const; + + /** + * @brief Check if we have completed handshake with a MAC + */ + bool hasIdentity(const Bytes& mac_address) const; + + /** + * @brief Find identity that matches a prefix (for MAC rotation detection) + * + * Used when scanning detects a device name with identity prefix (Protocol v2.2). + * If found, returns the full identity so we can recognize the rotated peer. + * + * @param prefix First N bytes of identity (typically 3 bytes from device name) + * @return Full identity bytes if found, empty otherwise + */ + Bytes findIdentityByPrefix(const Bytes& prefix) const; + + /** + * @brief Update MAC address for a known identity (MAC rotation) + * + * @param identity The stable identity + * @param new_mac The new MAC address + */ + void updateMacForIdentity(const Bytes& identity, const Bytes& new_mac); + + /** + * @brief Remove identity mapping (on disconnect) + */ + void removeMapping(const Bytes& mac_address); + + /** + * @brief Clear all mappings + */ + void clearAllMappings(); + + /** + * @brief Get count of known peer identities + */ + size_t knownPeerCount() const; + + /** + * @brief Check if handshake is in progress for a MAC + */ + bool isHandshakeInProgress(const Bytes& mac_address) const; + +private: + /** + * @brief Handshake state tracking + */ + enum class HandshakeState { + NONE, // No handshake in progress + INITIATED, // We sent our identity (as central) + RECEIVED_IDENTITY, // We received peer's identity + COMPLETE // Bidirectional identity exchange done + }; + + /** + * @brief State for an in-progress handshake + */ + struct HandshakeSession { + bool in_use = false; + Bytes mac_address; + Bytes peer_identity; + HandshakeState state = HandshakeState::NONE; + bool is_central = false; + double started_at = 0.0; + + void clear() { + in_use = false; + mac_address.clear(); + peer_identity.clear(); + state = HandshakeState::NONE; + is_central = false; + started_at = 0.0; + } + }; + + /** + * @brief Slot for address-to-identity mapping + */ + struct AddressIdentitySlot { + bool in_use = false; + Bytes mac_address; // 6-byte MAC key + Bytes identity; // 16-byte identity value + + void clear() { + in_use = false; + mac_address.clear(); + identity.clear(); + } + }; + + //========================================================================= + // Pool Helper Methods - Address to Identity Mapping + //========================================================================= + + /** + * @brief Find slot by MAC address + */ + AddressIdentitySlot* findAddressToIdentitySlot(const Bytes& mac); + const AddressIdentitySlot* findAddressToIdentitySlot(const Bytes& mac) const; + + /** + * @brief Find slot by identity + */ + AddressIdentitySlot* findIdentityToAddressSlot(const Bytes& identity); + const AddressIdentitySlot* findIdentityToAddressSlot(const Bytes& identity) const; + + /** + * @brief Find an empty slot in the address-identity pool + */ + AddressIdentitySlot* findEmptyAddressIdentitySlot(); + + /** + * @brief Add or update address-identity mapping + * @return true if successful, false if pool is full + */ + bool setAddressIdentityMapping(const Bytes& mac, const Bytes& identity); + + /** + * @brief Remove mapping by MAC address + */ + void removeAddressIdentityMapping(const Bytes& mac); + + //========================================================================= + // Pool Helper Methods - Handshake Sessions + //========================================================================= + + /** + * @brief Find handshake session by MAC + */ + HandshakeSession* findHandshakeSession(const Bytes& mac); + const HandshakeSession* findHandshakeSession(const Bytes& mac) const; + + /** + * @brief Find an empty handshake session slot + */ + HandshakeSession* findEmptyHandshakeSlot(); + + /** + * @brief Get or create a handshake session for a MAC + */ + HandshakeSession* getOrCreateSession(const Bytes& mac_address); + + /** + * @brief Remove handshake session by MAC + */ + void removeHandshakeSession(const Bytes& mac); + + //========================================================================= + // Fixed-size Pool Storage + //========================================================================= + + // Our local identity hash (16 bytes) + Bytes _local_identity; + + // Bidirectional mappings (survive MAC rotation via identity) + // Note: We use the same pool for both directions since they share the same data + AddressIdentitySlot _address_identity_pool[ADDRESS_IDENTITY_POOL_SIZE]; + + // Active handshake sessions (keyed by MAC) + HandshakeSession _handshakes_pool[HANDSHAKE_POOL_SIZE]; + + // Callbacks + HandshakeCompleteCallback _handshake_complete_callback = nullptr; + HandshakeFailedCallback _handshake_failed_callback = nullptr; + MacRotationCallback _mac_rotation_callback = nullptr; +}; + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEInterface.cpp b/lib/ble_interface/BLEInterface.cpp new file mode 100644 index 0000000..e735b89 --- /dev/null +++ b/lib/ble_interface/BLEInterface.cpp @@ -0,0 +1,946 @@ +/** + * @file BLEInterface.cpp + * @brief BLE-Reticulum Protocol v2.2 interface implementation + */ + +#include "BLEInterface.h" +#include "Log.h" +#include "Utilities/OS.h" + +#ifdef ARDUINO +#include +#include +#endif + +using namespace RNS; +using namespace RNS::BLE; + +BLEInterface::BLEInterface(const char* name) : InterfaceImpl(name) { + _IN = true; + _OUT = true; + _bitrate = BITRATE_GUESS; + _HW_MTU = HW_MTU_DEFAULT; +} + +BLEInterface::~BLEInterface() { + stop(); +} + +//============================================================================= +// Configuration +//============================================================================= + +void BLEInterface::setRole(Role role) { + _role = role; +} + +void BLEInterface::setDeviceName(const std::string& name) { + _device_name = name; +} + +void BLEInterface::setLocalIdentity(const Bytes& identity) { + if (identity.size() >= Limits::IDENTITY_SIZE) { + _local_identity = Bytes(identity.data(), Limits::IDENTITY_SIZE); + _identity_manager.setLocalIdentity(_local_identity); + } +} + +void BLEInterface::setMaxConnections(uint8_t max) { + _max_connections = (max <= Limits::MAX_PEERS) ? max : Limits::MAX_PEERS; +} + +//============================================================================= +// InterfaceImpl Overrides +//============================================================================= + +bool BLEInterface::start() { + if (_platform && _platform->isRunning()) { + return true; + } + + // Validate identity + if (!_identity_manager.hasLocalIdentity()) { + ERROR("BLEInterface: Local identity not set"); + return false; + } + + // Create platform + _platform = BLEPlatformFactory::create(); + if (!_platform) { + ERROR("BLEInterface: Failed to create BLE platform"); + return false; + } + + // Configure platform + PlatformConfig config; + config.role = _role; + config.device_name = _device_name; + config.preferred_mtu = MTU::REQUESTED; + config.max_connections = _max_connections; + + if (!_platform->initialize(config)) { + ERROR("BLEInterface: Failed to initialize BLE platform"); + _platform.reset(); + return false; + } + + // Setup callbacks + setupCallbacks(); + + // Set identity data for peripheral mode + _platform->setIdentityData(_local_identity); + + // Set local MAC in peer manager + _peer_manager.setLocalMac(_platform->getLocalAddress().toBytes()); + + // Start platform + if (!_platform->start()) { + ERROR("BLEInterface: Failed to start BLE platform"); + _platform.reset(); + return false; + } + + _online = true; + _last_scan = 0; // Trigger immediate scan + _last_keepalive = Utilities::OS::time(); + _last_maintenance = Utilities::OS::time(); + + INFO("BLEInterface: Started, role: " + std::string(roleToString(_role)) + + ", identity: " + _local_identity.toHex().substr(0, 8) + "..." + + ", localMAC: " + _platform->getLocalAddress().toString()); + + return true; +} + +void BLEInterface::stop() { + if (_platform) { + _platform->stop(); + _platform->shutdown(); + _platform.reset(); + } + + _fragmenters.clear(); + _online = false; + + INFO("BLEInterface: Stopped"); +} + +void BLEInterface::loop() { + static double last_loop_log = 0; + double now = Utilities::OS::time(); + + // Process any pending handshakes (deferred from callback for stack safety) + if (!_pending_handshakes.empty()) { + std::lock_guard lock(_mutex); + for (const auto& pending : _pending_handshakes) { + DEBUG("BLEInterface: Processing deferred handshake for " + + pending.identity.toHex().substr(0, 8) + "..."); + + // Update peer manager with identity + _peer_manager.setPeerIdentity(pending.mac, pending.identity); + _peer_manager.connectionSucceeded(pending.identity); + + // Create fragmenter for this peer + PeerInfo* peer = _peer_manager.getPeerByIdentity(pending.identity); + uint16_t mtu = peer ? peer->mtu : MTU::MINIMUM; + _fragmenters[pending.identity] = BLEFragmenter(mtu); + + INFO("BLEInterface: Handshake complete with " + pending.identity.toHex().substr(0, 8) + + "... (we are " + (pending.is_central ? "central" : "peripheral") + ")"); + } + _pending_handshakes.clear(); + } + + // Process any pending data fragments (deferred from callback for stack safety) + if (!_pending_data.empty()) { + std::lock_guard lock(_mutex); + for (const auto& pending : _pending_data) { + _reassembler.processFragment(pending.identity, pending.data); + } + _pending_data.clear(); + } + + // Debug: log loop status every 10 seconds + if (now - last_loop_log >= 10.0) { + DEBUG("BLEInterface::loop() platform=" + std::string(_platform ? "yes" : "no") + + " running=" + std::string(_platform && _platform->isRunning() ? "yes" : "no") + + " scanning=" + std::string(_platform && _platform->isScanning() ? "yes" : "no") + + " connected=" + std::to_string(_peer_manager.connectedCount())); + last_loop_log = now; + } + + if (!_platform || !_platform->isRunning()) { + return; + } + + // Platform loop + _platform->loop(); + + // Periodic scanning (central mode) + if (_role == Role::CENTRAL || _role == Role::DUAL) { + if (now - _last_scan >= SCAN_INTERVAL) { + performScan(); + _last_scan = now; + } + } + + // Keepalive processing + if (now - _last_keepalive >= KEEPALIVE_INTERVAL) { + sendKeepalives(); + _last_keepalive = now; + } + + // Maintenance (cleanup, scores, timeouts) + if (now - _last_maintenance >= MAINTENANCE_INTERVAL) { + performMaintenance(); + _last_maintenance = now; + } +} + +//============================================================================= +// Data Transfer +//============================================================================= + +void BLEInterface::send_outgoing(const Bytes& data) { + if (!_platform || !_platform->isRunning()) { + return; + } + + std::lock_guard lock(_mutex); + + // Get all connected peers + auto connected_peers = _peer_manager.getConnectedPeers(); + + if (connected_peers.empty()) { + TRACE("BLEInterface: No connected peers, dropping packet"); + return; + } + + // Count peers with identity + size_t peers_with_identity = 0; + for (PeerInfo* peer : connected_peers) { + if (peer->hasIdentity()) { + peers_with_identity++; + } + } + DEBUG("BLEInterface: Sending to " + std::to_string(peers_with_identity) + + "/" + std::to_string(connected_peers.size()) + " connected peers"); + + // Send to all connected peers with identity + for (PeerInfo* peer : connected_peers) { + if (peer->hasIdentity()) { + sendToPeer(peer->identity, data); + } + } + + // Track outgoing stats + handle_outgoing(data); +} + +bool BLEInterface::sendToPeer(const Bytes& peer_identity, const Bytes& data) { + PeerInfo* peer = _peer_manager.getPeerByIdentity(peer_identity); + if (!peer || !peer->isConnected()) { + return false; + } + + // Get or create fragmenter for this peer + auto frag_it = _fragmenters.find(peer_identity); + if (frag_it == _fragmenters.end()) { + _fragmenters[peer_identity] = BLEFragmenter(peer->mtu); + frag_it = _fragmenters.find(peer_identity); + } + + // Update MTU if changed + frag_it->second.setMTU(peer->mtu); + + // Fragment the data + std::vector fragments = frag_it->second.fragment(data); + + INFO("BLEInterface: Sending " + std::to_string(fragments.size()) + " frags to " + + peer_identity.toHex().substr(0, 8) + " via " + (peer->is_central ? "write" : "notify") + + " conn=" + std::to_string(peer->conn_handle) + " mtu=" + std::to_string(peer->mtu)); + + // Send each fragment + bool all_sent = true; + for (const Bytes& fragment : fragments) { + bool sent = false; + + if (peer->is_central) { + // We are central - write to peripheral (with response for debugging) + sent = _platform->write(peer->conn_handle, fragment, true); + } else { + // We are peripheral - notify central + sent = _platform->notify(peer->conn_handle, fragment); + } + + if (!sent) { + WARNING("BLEInterface: Failed to send fragment to " + + peer_identity.toHex().substr(0, 8) + " conn=" + + std::to_string(peer->conn_handle)); + all_sent = false; + break; + } + } + + if (!all_sent) { + return false; + } + + _peer_manager.recordPacketSent(peer_identity); + return true; +} + +//============================================================================= +// Status +//============================================================================= + +size_t BLEInterface::peerCount() const { + std::lock_guard lock(_mutex); + return _peer_manager.connectedCount(); +} + +size_t BLEInterface::getConnectedPeerSummaries(PeerSummary* out, size_t max_count) const { + if (!out || max_count == 0) return 0; + + std::lock_guard lock(_mutex); + + // Cast away const for read-only access to non-const getConnectedPeers() + auto& mutable_peer_manager = const_cast(_peer_manager); + auto connected_peers = mutable_peer_manager.getConnectedPeers(); + + size_t count = 0; + for (const auto* peer : connected_peers) { + if (!peer || count >= max_count) break; + + PeerSummary& summary = out[count]; + + // Format identity (first 12 hex chars) or empty if no identity + // Look up identity from identity manager (where it's actually stored after handshake) + Bytes identity = _identity_manager.getIdentityForMac(peer->mac_address); + if (identity.size() == Limits::IDENTITY_SIZE) { + std::string hex = identity.toHex(); + size_t len = (hex.length() >= 12) ? 12 : hex.length(); + memcpy(summary.identity, hex.c_str(), len); + summary.identity[len] = '\0'; + } else { + summary.identity[0] = '\0'; + } + + // Format MAC as AA:BB:CC:DD:EE:FF + if (peer->mac_address.size() >= 6) { + const uint8_t* mac = peer->mac_address.data(); + snprintf(summary.mac, sizeof(summary.mac), "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } else { + summary.mac[0] = '\0'; + } + + summary.rssi = peer->rssi; + count++; + } + + return count; +} + +std::map BLEInterface::get_stats() const { + std::map stats; + stats["central_connections"] = 0.0f; + stats["peripheral_connections"] = 0.0f; + + try { + std::lock_guard lock(_mutex); + + // Count central vs peripheral connections + int central_count = 0; + int peripheral_count = 0; + + // Cast away const for read-only access to non-const getConnectedPeers() + auto& mutable_peer_manager = const_cast(_peer_manager); + auto connected_peers = mutable_peer_manager.getConnectedPeers(); + for (const auto* peer : connected_peers) { + if (peer && peer->is_central) { + central_count++; + } else if (peer) { + peripheral_count++; + } + } + + stats["central_connections"] = (float)central_count; + stats["peripheral_connections"] = (float)peripheral_count; + } catch (...) { + // Ignore errors during BLE state changes + } + + return stats; +} + +//============================================================================= +// Platform Callbacks +//============================================================================= + +void BLEInterface::setupCallbacks() { + _platform->setOnScanResult([this](const ScanResult& result) { + onScanResult(result); + }); + + _platform->setOnConnected([this](const ConnectionHandle& conn) { + onConnected(conn); + }); + + _platform->setOnDisconnected([this](const ConnectionHandle& conn, uint8_t reason) { + onDisconnected(conn, reason); + }); + + _platform->setOnMTUChanged([this](const ConnectionHandle& conn, uint16_t mtu) { + onMTUChanged(conn, mtu); + }); + + _platform->setOnServicesDiscovered([this](const ConnectionHandle& conn, bool success) { + onServicesDiscovered(conn, success); + }); + + _platform->setOnDataReceived([this](const ConnectionHandle& conn, const Bytes& data) { + onDataReceived(conn, data); + }); + + _platform->setOnCentralConnected([this](const ConnectionHandle& conn) { + onCentralConnected(conn); + }); + + _platform->setOnCentralDisconnected([this](const ConnectionHandle& conn) { + onCentralDisconnected(conn); + }); + + _platform->setOnWriteReceived([this](const ConnectionHandle& conn, const Bytes& data) { + onWriteReceived(conn, data); + }); + + // Identity manager callbacks + _identity_manager.setHandshakeCompleteCallback( + [this](const Bytes& mac, const Bytes& identity, bool is_central) { + onHandshakeComplete(mac, identity, is_central); + }); + + _identity_manager.setHandshakeFailedCallback( + [this](const Bytes& mac, const std::string& reason) { + onHandshakeFailed(mac, reason); + }); + + _identity_manager.setMacRotationCallback( + [this](const Bytes& old_mac, const Bytes& new_mac, const Bytes& identity) { + onMacRotation(old_mac, new_mac, identity); + }); + + // Reassembler callbacks + _reassembler.setReassemblyCallback( + [this](const Bytes& peer_identity, const Bytes& packet) { + onPacketReassembled(peer_identity, packet); + }); + + _reassembler.setTimeoutCallback( + [this](const Bytes& peer_identity, const std::string& reason) { + onReassemblyTimeout(peer_identity, reason); + }); +} + +void BLEInterface::onScanResult(const ScanResult& result) { + std::lock_guard lock(_mutex); + + if (!result.has_reticulum_service) { + return; + } + + Bytes mac = result.address.toBytes(); + + // Check if identity prefix suggests this is a known peer at a new MAC (rotation) + if (result.identity_prefix.size() >= 3) { + Bytes known_identity = _identity_manager.findIdentityByPrefix(result.identity_prefix); + if (known_identity.size() == Limits::IDENTITY_SIZE) { + Bytes old_mac = _identity_manager.getMacForIdentity(known_identity); + if (old_mac.size() > 0 && old_mac != mac) { + // MAC rotation detected! Update mapping + INFO("BLEInterface: MAC rotation detected for identity " + + known_identity.toHex().substr(0, 8) + "...: " + + BLEAddress(old_mac.data()).toString() + " -> " + + result.address.toString()); + _identity_manager.updateMacForIdentity(known_identity, mac); + } + } + } + + // Add to peer manager with address type + _peer_manager.addDiscoveredPeer(mac, result.rssi, result.address.type); + + INFO("BLEInterface: Discovered Reticulum peer " + result.address.toString() + + " type=" + std::to_string(result.address.type) + + " RSSI=" + std::to_string(result.rssi) + " name=" + result.name); +} + +void BLEInterface::onConnected(const ConnectionHandle& conn) { + std::lock_guard lock(_mutex); + + Bytes mac = conn.peer_address.toBytes(); + + // Update peer state + _peer_manager.setPeerState(mac, PeerState::HANDSHAKING); + _peer_manager.setPeerHandle(mac, conn.handle); + + // Mark as central connection (we initiated the connection) + PeerInfo* peer = _peer_manager.getPeerByMac(mac); + if (peer) { + peer->is_central = true; // We ARE central in this connection + INFO("BLEInterface: Stored conn_handle=" + std::to_string(conn.handle) + + " for peer " + conn.peer_address.toString()); + } + + DEBUG("BLEInterface: Connected to " + conn.peer_address.toString() + + " (we are central)"); + + // Discover services + _platform->discoverServices(conn.handle); +} + +void BLEInterface::onDisconnected(const ConnectionHandle& conn, uint8_t reason) { + std::lock_guard lock(_mutex); + + Bytes mac = conn.peer_address.toBytes(); + Bytes identity = _identity_manager.getIdentityForMac(mac); + + if (identity.size() > 0) { + // Clean up identity-keyed peer + _fragmenters.erase(identity); + _reassembler.clearForPeer(identity); + _peer_manager.setPeerState(identity, PeerState::DISCOVERED); + } else { + // Peer might still be in CONNECTING state (no identity yet) + // Reset to DISCOVERED so we can try again + _peer_manager.connectionFailed(mac); + } + + _identity_manager.removeMapping(mac); + + DEBUG("BLEInterface: Disconnected from " + conn.peer_address.toString() + + " reason: " + std::to_string(reason)); +} + +void BLEInterface::onMTUChanged(const ConnectionHandle& conn, uint16_t mtu) { + std::lock_guard lock(_mutex); + + Bytes mac = conn.peer_address.toBytes(); + _peer_manager.setPeerMTU(mac, mtu); + + // Update fragmenter if exists + Bytes identity = _identity_manager.getIdentityForMac(mac); + if (identity.size() > 0) { + auto it = _fragmenters.find(identity); + if (it != _fragmenters.end()) { + it->second.setMTU(mtu); + } + } + + DEBUG("BLEInterface: MTU changed to " + std::to_string(mtu) + + " for " + conn.peer_address.toString()); +} + +void BLEInterface::onServicesDiscovered(const ConnectionHandle& conn, bool success) { + std::lock_guard lock(_mutex); + + if (!success) { + WARNING("BLEInterface: Service discovery failed for " + conn.peer_address.toString()); + + // Clean up peer state - NimBLE may have already disconnected internally, + // so onDisconnected callback might not fire. Manually reset peer state. + Bytes mac = conn.peer_address.toBytes(); + _peer_manager.connectionFailed(mac); + + // Try to disconnect (may be no-op if already disconnected) + _platform->disconnect(conn.handle); + return; + } + + DEBUG("BLEInterface: Services discovered for " + conn.peer_address.toString()); + + // Enable notifications on TX characteristic + _platform->enableNotifications(conn.handle, true); + + // Protocol v2.2: Read peer's identity characteristic before sending ours + // This matches the Kotlin implementation's 4-step handshake + if (conn.identity_handle != 0) { + Bytes mac = conn.peer_address.toBytes(); + uint16_t handle = conn.handle; + + _platform->read(conn.handle, conn.identity_handle, + [this, mac, handle](OperationResult result, const Bytes& identity) { + if (result == OperationResult::SUCCESS && + identity.size() == Limits::IDENTITY_SIZE) { + DEBUG("BLEInterface: Read peer identity: " + identity.toHex().substr(0, 8) + "..."); + + // Store the peer's identity - handshake complete for receiving direction + _identity_manager.completeHandshake(mac, identity, true); + + // Now send our identity directly (don't use initiateHandshake which + // creates a session that would time out since we already have the mapping) + if (_identity_manager.hasLocalIdentity()) { + _platform->write(handle, _identity_manager.getLocalIdentity(), true); + DEBUG("BLEInterface: Sent identity handshake to peer"); + } + } else { + WARNING("BLEInterface: Failed to read peer identity, trying write-based handshake"); + // Fall back to old behavior - initiate handshake and wait for response + ConnectionHandle conn = _platform->getConnection(handle); + if (conn.handle != 0) { + initiateHandshake(conn); + } + } + }); + } else { + // No identity characteristic - fall back to write-only handshake (Protocol v1) + DEBUG("BLEInterface: No identity characteristic, using v1 fallback"); + initiateHandshake(conn); + } +} + +void BLEInterface::onDataReceived(const ConnectionHandle& conn, const Bytes& data) { + // Called when we receive notification from peripheral (we are central) + handleIncomingData(conn, data); +} + +void BLEInterface::onCentralConnected(const ConnectionHandle& conn) { + std::lock_guard lock(_mutex); + + Bytes mac = conn.peer_address.toBytes(); + + // Update peer manager + _peer_manager.addDiscoveredPeer(mac, 0); + _peer_manager.setPeerState(mac, PeerState::HANDSHAKING); + _peer_manager.setPeerHandle(mac, conn.handle); + + // Mark as peripheral connection (they are central, we are peripheral) + PeerInfo* peer = _peer_manager.getPeerByMac(mac); + if (peer) { + peer->is_central = false; // We are NOT central in this connection + } + + DEBUG("BLEInterface: Central connected: " + conn.peer_address.toString() + + " (we are peripheral)"); +} + +void BLEInterface::onCentralDisconnected(const ConnectionHandle& conn) { + onDisconnected(conn, 0); +} + +void BLEInterface::onWriteReceived(const ConnectionHandle& conn, const Bytes& data) { + // Called when central writes to our RX characteristic (we are peripheral) + handleIncomingData(conn, data); +} + +//============================================================================= +// Handshake Callbacks +//============================================================================= + +void BLEInterface::onHandshakeComplete(const Bytes& mac, const Bytes& identity, bool is_central) { + // Lock before modifying queue - protects against race with loop() + std::lock_guard lock(_mutex); + + // Queue the handshake for processing in loop() to avoid stack overflow in NimBLE callback + // The NimBLE task has limited stack space, so we defer heavy processing + if (_pending_handshakes.size() >= MAX_PENDING_HANDSHAKES) { + WARNING("BLEInterface: Pending handshake queue full, dropping handshake"); + return; + } + PendingHandshake pending; + pending.mac = mac; + pending.identity = identity; + pending.is_central = is_central; + _pending_handshakes.push_back(pending); + DEBUG("BLEInterface::onHandshakeComplete: Queued handshake for deferred processing"); +} + +void BLEInterface::onHandshakeFailed(const Bytes& mac, const std::string& reason) { + std::lock_guard lock(_mutex); + + WARNING("BLEInterface: Handshake failed with " + + BLEAddress(mac.data()).toString() + ": " + reason); + + _peer_manager.connectionFailed(mac); +} + +void BLEInterface::onMacRotation(const Bytes& old_mac, const Bytes& new_mac, const Bytes& identity) { + std::lock_guard lock(_mutex); + + INFO("BLEInterface: MAC rotation detected for identity " + + identity.toHex().substr(0, 8) + "...: " + + BLEAddress(old_mac.data()).toString() + " -> " + + BLEAddress(new_mac.data()).toString()); + + // Update peer manager with new MAC + _peer_manager.updatePeerMac(identity, new_mac); + + // Update fragmenter key if exists (identity stays the same, but log it) + auto frag_it = _fragmenters.find(identity); + if (frag_it != _fragmenters.end()) { + DEBUG("BLEInterface: Fragmenter preserved for rotated identity"); + } +} + +//============================================================================= +// Reassembly Callbacks +//============================================================================= + +void BLEInterface::onPacketReassembled(const Bytes& peer_identity, const Bytes& packet) { + // Packet reassembly complete - pass to transport + _peer_manager.recordPacketReceived(peer_identity); + handle_incoming(packet); +} + +void BLEInterface::onReassemblyTimeout(const Bytes& peer_identity, const std::string& reason) { + WARNING("BLEInterface: Reassembly timeout for " + + peer_identity.toHex().substr(0, 8) + ": " + reason); +} + +//============================================================================= +// Internal Operations +//============================================================================= + +void BLEInterface::performScan() { + if (!_platform || _platform->isScanning()) { + return; + } + + // Only scan if we have room for more connections + if (_peer_manager.connectedCount() >= _max_connections) { + return; + } + + _platform->startScan(5000); // 5 second scan +} + +void BLEInterface::processDiscoveredPeers() { + // Don't attempt connections when memory is critically low + // BLE connection setup requires significant heap allocation +#ifdef ARDUINO + if (ESP.getFreeHeap() < 30000) { + static uint32_t last_low_mem_warn = 0; + if (millis() - last_low_mem_warn > 10000) { + WARNING("BLEInterface: Skipping connection attempts - low memory"); + last_low_mem_warn = millis(); + } + return; + } +#endif + + // Don't try to connect while scanning - BLE stack will return "busy" + if (_platform->isScanning()) { + return; + } + + // Cooldown after connection attempts to let BLE stack settle + double now = Utilities::OS::time(); + if (now - _last_connection_attempt < CONNECTION_COOLDOWN) { + return; // Still in cooldown period + } + + // Find best connection candidate + PeerInfo* candidate = _peer_manager.getBestConnectionCandidate(); + + // Debug: log all peers and why they may not be candidates + static double last_peer_log = 0; + if (now - last_peer_log >= 10.0) { + auto all_peers = _peer_manager.getAllPeers(); + DEBUG("BLEInterface: Peer count=" + std::to_string(all_peers.size()) + + " localMAC=" + _peer_manager.getLocalMac().toHex()); + for (PeerInfo* peer : all_peers) { + bool should_initiate = _peer_manager.shouldInitiateConnection(peer->mac_address); + DEBUG("BLEInterface: Peer " + BLEAddress(peer->mac_address.data()).toString() + + " state=" + std::to_string(static_cast(peer->state)) + + " shouldInitiate=" + std::string(should_initiate ? "yes" : "no") + + " score=" + std::to_string(peer->score)); + } + last_peer_log = now; + } + + if (candidate) { + DEBUG("BLEInterface: Connection candidate: " + BLEAddress(candidate->mac_address.data()).toString() + + " type=" + std::to_string(candidate->address_type) + + " canAccept=" + std::string(_peer_manager.canAcceptConnection() ? "yes" : "no")); + } + + if (candidate && _peer_manager.canAcceptConnection()) { + _peer_manager.setPeerState(candidate->mac_address, PeerState::CONNECTING); + candidate->connection_attempts++; + + // Use stored address type for correct connection + BLEAddress addr(candidate->mac_address.data(), candidate->address_type); + INFO("BLEInterface: Connecting to " + addr.toString() + " type=" + std::to_string(candidate->address_type)); + + // Mark connection attempt time for cooldown + _last_connection_attempt = now; + + // Handle immediate connection failure (resets state for retry) + // Reduced timeout from 10s to 3s to avoid long UI freezes + if (!_platform->connect(addr, 3000)) { + WARNING("BLEInterface: Connection attempt failed immediately"); + _peer_manager.connectionFailed(candidate->mac_address); + } + } +} + +void BLEInterface::sendKeepalives() { + // Send empty keepalive to maintain connections + Bytes keepalive(1); + keepalive.writable(1)[0] = 0x00; + + auto connected = _peer_manager.getConnectedPeers(); + for (PeerInfo* peer : connected) { + if (peer->hasIdentity()) { + // Don't use sendToPeer for keepalives (no fragmentation needed) + if (peer->is_central) { + _platform->write(peer->conn_handle, keepalive, false); + } else { + _platform->notify(peer->conn_handle, keepalive); + } + } + } +} + +void BLEInterface::performMaintenance() { + std::lock_guard lock(_mutex); + + // Check reassembly timeouts + _reassembler.checkTimeouts(); + + // Check handshake timeouts + _identity_manager.checkTimeouts(); + + // Check blacklist expirations + _peer_manager.checkBlacklistExpirations(); + + // Recalculate peer scores + _peer_manager.recalculateScores(); + + // Clean up stale peers + _peer_manager.cleanupStalePeers(); + + // Clean up fragmenters for peers that no longer exist + { + std::lock_guard lock(_mutex); + std::vector orphaned_fragmenters; + for (const auto& kv : _fragmenters) { + if (!_peer_manager.getPeerByIdentity(kv.first)) { + orphaned_fragmenters.push_back(kv.first); + } + } + for (const Bytes& identity : orphaned_fragmenters) { + _fragmenters.erase(identity); + _reassembler.clearForPeer(identity); + TRACE("BLEInterface: Cleaned up orphaned fragmenter for " + identity.toHex().substr(0, 8)); + } + } + + // Process discovered peers (try to connect) + processDiscoveredPeers(); +} + +void BLEInterface::handleIncomingData(const ConnectionHandle& conn, const Bytes& data) { + // Hot path - no logging to avoid blocking main loop + std::lock_guard lock(_mutex); + + Bytes mac = conn.peer_address.toBytes(); + bool is_central = (conn.local_role == Role::CENTRAL); + + // First check if this is an identity handshake + if (_identity_manager.processReceivedData(mac, data, is_central)) { + return; + } + + // Check for keepalive (1 byte, value 0x00) + if (data.size() == 1 && data.data()[0] == 0x00) { + _peer_manager.updateLastActivity(_identity_manager.getIdentityForMac(mac)); + return; + } + + // Queue data for deferred processing (avoid stack overflow in NimBLE callback) + Bytes identity = _identity_manager.getIdentityForMac(mac); + if (identity.size() == 0) { + WARNING("BLEInterface: Received data from peer without identity"); + return; + } + + if (_pending_data.size() >= MAX_PENDING_DATA) { + WARNING("BLEInterface: Pending data queue full, dropping data"); + return; + } + + PendingData pending; + pending.identity = identity; + pending.data = data; + _pending_data.push_back(pending); +} + +void BLEInterface::initiateHandshake(const ConnectionHandle& conn) { + Bytes mac = conn.peer_address.toBytes(); + + // Get handshake data (our identity) + Bytes handshake = _identity_manager.initiateHandshake(mac); + + if (handshake.size() > 0) { + // Write our identity to peer's RX characteristic + _platform->write(conn.handle, handshake, true); + + DEBUG("BLEInterface: Sent identity handshake to " + conn.peer_address.toString()); + } +} + +//============================================================================= +// FreeRTOS Task Support +//============================================================================= + +#ifdef ARDUINO + +void BLEInterface::ble_task(void* param) { + BLEInterface* self = static_cast(param); + Serial.printf("BLE task started on core %d\n", xPortGetCoreID()); + + while (true) { + // Run the BLE loop (already has internal mutex protection) + self->loop(); + + // Yield to other tasks + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +bool BLEInterface::start_task(int priority, int core) { + if (_task_handle != nullptr) { + WARNING("BLEInterface: Task already running"); + return true; + } + + BaseType_t result = xTaskCreatePinnedToCore( + ble_task, + "ble", + 8192, // 8KB stack + this, + priority, + &_task_handle, + core + ); + + if (result != pdPASS) { + ERROR("BLEInterface: Failed to create BLE task"); + return false; + } + + Serial.printf("BLE task created with priority %d on core %d\n", priority, core); + return true; +} + +#else + +// Non-Arduino stub +bool BLEInterface::start_task(int priority, int core) { + WARNING("BLEInterface: Task mode not supported on this platform"); + return false; +} + +#endif diff --git a/lib/ble_interface/BLEInterface.h b/lib/ble_interface/BLEInterface.h new file mode 100644 index 0000000..89850be --- /dev/null +++ b/lib/ble_interface/BLEInterface.h @@ -0,0 +1,280 @@ +/** + * @file BLEInterface.h + * @brief BLE-Reticulum Protocol v2.2 interface for microReticulum + * + * Main BLEInterface class that integrates with the Reticulum transport layer. + * Supports dual-mode operation (central + peripheral) for mesh networking. + * + * Usage: + * BLEInterface ble("ble0"); + * ble.setDeviceName("my-node"); + * ble.setLocalIdentity(identity.hash()); + * + * Interface interface(&ble); + * interface.start(); + * Transport::register_interface(interface); + */ +#pragma once + +#include "Interface.h" +#include "Bytes.h" +#include "Type.h" +#include "BLE/BLETypes.h" +#include "BLE/BLEPlatform.h" +#include "BLE/BLEFragmenter.h" +#include "BLE/BLEReassembler.h" +#include "BLE/BLEPeerManager.h" +#include "BLE/BLEIdentityManager.h" + +#include +#include + +#ifdef ARDUINO +#include +#include +#endif + +/** + * @brief Reticulum BLE Interface + * + * Implements the BLE-Reticulum protocol v2.2 as a microReticulum interface. + * Manages BLE connections, fragmentation, and peer discovery. + */ +class BLEInterface : public RNS::InterfaceImpl { +public: + // Protocol constants + static constexpr uint32_t BITRATE_GUESS = 100000; // ~100 kbps effective throughput + static constexpr uint16_t HW_MTU_DEFAULT = 512; // Default after MTU negotiation + + // Timing constants + static constexpr double SCAN_INTERVAL = 5.0; // Seconds between scans + static constexpr double KEEPALIVE_INTERVAL = 15.0; // Seconds between keepalives + static constexpr double MAINTENANCE_INTERVAL = 1.0; // Seconds between maintenance + static constexpr double CONNECTION_COOLDOWN = 3.0; // Seconds to wait after connection failure + +public: + /** + * @brief Construct a BLE interface + * @param name Interface name (e.g., "ble0") + */ + explicit BLEInterface(const char* name = "BLEInterface"); + + virtual ~BLEInterface(); + + //========================================================================= + // Configuration (call before start()) + //========================================================================= + + /** + * @brief Set the BLE role + * @param role CENTRAL, PERIPHERAL, or DUAL (default: DUAL) + */ + void setRole(RNS::BLE::Role role); + + /** + * @brief Set the advertised device name + * @param name Device name (max ~8 characters recommended) + */ + void setDeviceName(const std::string& name); + + /** + * @brief Set our local identity hash + * + * Required for the identity handshake protocol. + * Should be the first 16 bytes of the transport identity hash. + * + * @param identity 16-byte identity hash + */ + void setLocalIdentity(const RNS::Bytes& identity); + + /** + * @brief Set maximum connections + * @param max Maximum simultaneous connections (default: 7) + */ + void setMaxConnections(uint8_t max); + + //========================================================================= + // InterfaceImpl Overrides + //========================================================================= + + virtual bool start() override; + virtual void stop() override; + virtual void loop() override; + + virtual std::string toString() const override { + return "BLEInterface[" + _name + "/" + _device_name + "]"; + } + + /** + * @brief Get interface statistics + * @return Map with central_connections and peripheral_connections counts + */ + virtual std::map get_stats() const override; + + //========================================================================= + // Status + //========================================================================= + + /** + * @brief Summary info for a connected peer (fixed-size, no heap allocation) + */ + struct PeerSummary { + char identity[14]; // First 12 hex chars + null, or empty if unknown + char mac[18]; // "AA:BB:CC:DD:EE:FF" format + int8_t rssi; + }; + static constexpr size_t MAX_PEER_SUMMARIES = 8; + + /** + * @brief Get count of connected peers + */ + size_t peerCount() const; + + /** + * @brief Get summaries of connected peers (for UI display) + * @param out Pre-allocated array to fill + * @param max_count Maximum entries to fill + * @return Actual number of entries filled + */ + size_t getConnectedPeerSummaries(PeerSummary* out, size_t max_count) const; + + /** + * @brief Check if BLE is running + */ + bool isRunning() const { return _platform && _platform->isRunning(); } + + /** + * @brief Start BLE on its own FreeRTOS task + * + * This allows BLE operations to run independently of the main loop, + * preventing UI freezes during scans and connections. + * + * @param priority Task priority (default 1) + * @param core Core to pin the task to (default 0, where BT controller runs) + * @return true if task started successfully + */ + bool start_task(int priority = 1, int core = 0); + + /** + * @brief Check if BLE is running on its own task + */ + bool is_task_running() const { return _task_handle != nullptr; } + +protected: + virtual void send_outgoing(const RNS::Bytes& data) override; + +private: + //========================================================================= + // Platform Callbacks + //========================================================================= + + void onScanResult(const RNS::BLE::ScanResult& result); + void onConnected(const RNS::BLE::ConnectionHandle& conn); + void onDisconnected(const RNS::BLE::ConnectionHandle& conn, uint8_t reason); + void onMTUChanged(const RNS::BLE::ConnectionHandle& conn, uint16_t mtu); + void onServicesDiscovered(const RNS::BLE::ConnectionHandle& conn, bool success); + void onDataReceived(const RNS::BLE::ConnectionHandle& conn, const RNS::Bytes& data); + void onCentralConnected(const RNS::BLE::ConnectionHandle& conn); + void onCentralDisconnected(const RNS::BLE::ConnectionHandle& conn); + void onWriteReceived(const RNS::BLE::ConnectionHandle& conn, const RNS::Bytes& data); + + //========================================================================= + // Handshake Callbacks + //========================================================================= + + void onHandshakeComplete(const RNS::Bytes& mac, const RNS::Bytes& identity, bool is_central); + void onHandshakeFailed(const RNS::Bytes& mac, const std::string& reason); + void onMacRotation(const RNS::Bytes& old_mac, const RNS::Bytes& new_mac, const RNS::Bytes& identity); + + //========================================================================= + // Reassembly Callbacks + //========================================================================= + + void onPacketReassembled(const RNS::Bytes& peer_identity, const RNS::Bytes& packet); + void onReassemblyTimeout(const RNS::Bytes& peer_identity, const std::string& reason); + + //========================================================================= + // Internal Operations + //========================================================================= + + void setupCallbacks(); + void performScan(); + void processDiscoveredPeers(); + void sendKeepalives(); + void performMaintenance(); + + /** + * @brief Send data to a specific peer (with fragmentation) + */ + bool sendToPeer(const RNS::Bytes& peer_identity, const RNS::Bytes& data); + + /** + * @brief Process incoming data from a peer + */ + void handleIncomingData(const RNS::BLE::ConnectionHandle& conn, const RNS::Bytes& data); + + /** + * @brief Initiate handshake for a new connection + */ + void initiateHandshake(const RNS::BLE::ConnectionHandle& conn); + + //========================================================================= + // Configuration + //========================================================================= + + RNS::BLE::Role _role = RNS::BLE::Role::DUAL; + std::string _device_name = "RNS-Node"; + uint8_t _max_connections = RNS::BLE::Limits::MAX_PEERS; + RNS::Bytes _local_identity; + + //========================================================================= + // Components + //========================================================================= + + RNS::BLE::IBLEPlatform::Ptr _platform; + RNS::BLE::BLEPeerManager _peer_manager; + RNS::BLE::BLEIdentityManager _identity_manager; + RNS::BLE::BLEReassembler _reassembler; + + // Per-peer fragmenters (keyed by identity) + std::map _fragmenters; + + //========================================================================= + // State + //========================================================================= + + double _last_scan = 0; + double _last_keepalive = 0; + double _last_maintenance = 0; + double _last_connection_attempt = 0; // Cooldown after connection failures + + // Pending handshake completions (deferred from callback to loop for stack safety) + static constexpr size_t MAX_PENDING_HANDSHAKES = 32; + struct PendingHandshake { + RNS::Bytes mac; + RNS::Bytes identity; + bool is_central; + }; + std::vector _pending_handshakes; + + // Pending data fragments (deferred from callback to loop for stack safety) + static constexpr size_t MAX_PENDING_DATA = 64; + struct PendingData { + RNS::Bytes identity; + RNS::Bytes data; + }; + std::vector _pending_data; + + // Thread safety for callbacks from BLE stack + // Using recursive_mutex because handleIncomingData holds the lock while + // calling processReceivedData, which can trigger onHandshakeComplete callback + // that also needs the lock + mutable std::recursive_mutex _mutex; + + //========================================================================= + // FreeRTOS Task Support + //========================================================================= + + TaskHandle_t _task_handle = nullptr; + static void ble_task(void* param); +}; diff --git a/lib/ble_interface/BLEOperationQueue.cpp b/lib/ble_interface/BLEOperationQueue.cpp new file mode 100644 index 0000000..60f5793 --- /dev/null +++ b/lib/ble_interface/BLEOperationQueue.cpp @@ -0,0 +1,227 @@ +/** + * @file BLEOperationQueue.cpp + * @brief GATT operation queue implementation + */ + +#include "BLEOperationQueue.h" +#include "Log.h" + +namespace RNS { namespace BLE { + +BLEOperationQueue::BLEOperationQueue() { +} + +void BLEOperationQueue::enqueue(GATTOperation op) { + op.queued_at = Utilities::OS::time(); + + if (op.timeout_ms == 0) { + op.timeout_ms = _default_timeout_ms; + } + + _queue.push(std::move(op)); + + TRACE("BLEOperationQueue: Enqueued operation, queue depth: " + + std::to_string(_queue.size())); +} + +bool BLEOperationQueue::process() { + // Check for timeout on current operation + if (_has_current_op) { + checkTimeout(); + return false; // Still busy + } + + // Nothing to process + if (_queue.empty()) { + return false; + } + + // Dequeue next operation + _current_op = std::move(_queue.front()); + _has_current_op = true; + _queue.pop(); + + GATTOperation& op = _current_op; + op.started_at = Utilities::OS::time(); + + TRACE("BLEOperationQueue: Starting operation type " + + std::to_string(static_cast(op.type))); + + // Execute the operation (implemented by subclass) + bool started = executeOperation(op); + + if (!started) { + // Operation failed to start - call callback with error + if (op.callback) { + op.callback(OperationResult::ERROR, Bytes()); + } + _has_current_op = false; + return false; + } + + return true; +} + +void BLEOperationQueue::complete(OperationResult result, const Bytes& response_data) { + if (!_has_current_op) { + WARNING("BLEOperationQueue: complete() called with no current operation"); + return; + } + + GATTOperation& op = _current_op; + + double duration = Utilities::OS::time() - op.started_at; + TRACE("BLEOperationQueue: Operation completed in " + + std::to_string(static_cast(duration * 1000)) + "ms, result: " + + std::to_string(static_cast(result))); + + // Invoke callback + if (op.callback) { + op.callback(result, response_data); + } + + // Clear current operation + _has_current_op = false; +} + +void BLEOperationQueue::clearForConnection(uint16_t conn_handle) { + // Create temporary queue for non-matching operations + std::queue remaining; + + while (!_queue.empty()) { + GATTOperation op = std::move(_queue.front()); + _queue.pop(); + + if (op.conn_handle != conn_handle) { + remaining.push(std::move(op)); + } else { + // Cancel this operation + if (op.callback) { + op.callback(OperationResult::DISCONNECTED, Bytes()); + } + } + } + + _queue = std::move(remaining); + + // Also cancel current operation if it matches + if (_has_current_op && _current_op.conn_handle == conn_handle) { + if (_current_op.callback) { + _current_op.callback(OperationResult::DISCONNECTED, Bytes()); + } + _has_current_op = false; + } + + TRACE("BLEOperationQueue: Cleared operations for connection " + + std::to_string(conn_handle)); +} + +void BLEOperationQueue::clear() { + // Cancel all pending operations + while (!_queue.empty()) { + GATTOperation op = std::move(_queue.front()); + _queue.pop(); + + if (op.callback) { + op.callback(OperationResult::DISCONNECTED, Bytes()); + } + } + + // Cancel current operation + if (_has_current_op) { + if (_current_op.callback) { + _current_op.callback(OperationResult::DISCONNECTED, Bytes()); + } + _has_current_op = false; + } + + TRACE("BLEOperationQueue: Cleared all operations"); +} + +void BLEOperationQueue::checkTimeout() { + if (!_has_current_op) { + return; + } + + GATTOperation& op = _current_op; + double elapsed = Utilities::OS::time() - op.started_at; + double timeout_sec = op.timeout_ms / 1000.0; + + if (elapsed > timeout_sec) { + WARNING("BLEOperationQueue: Operation timed out after " + + std::to_string(static_cast(elapsed * 1000)) + "ms"); + + // Complete with timeout error + complete(OperationResult::TIMEOUT, Bytes()); + } +} + +//============================================================================= +// GATTOperationBuilder +//============================================================================= + +GATTOperationBuilder& GATTOperationBuilder::read(uint16_t conn_handle, uint16_t char_handle) { + _op.type = OperationType::READ; + _op.conn_handle = conn_handle; + _op.char_handle = char_handle; + return *this; +} + +GATTOperationBuilder& GATTOperationBuilder::write(uint16_t conn_handle, uint16_t char_handle, + const Bytes& data) { + _op.type = OperationType::WRITE; + _op.conn_handle = conn_handle; + _op.char_handle = char_handle; + _op.data = data; + return *this; +} + +GATTOperationBuilder& GATTOperationBuilder::writeNoResponse(uint16_t conn_handle, + uint16_t char_handle, + const Bytes& data) { + _op.type = OperationType::WRITE_NO_RESPONSE; + _op.conn_handle = conn_handle; + _op.char_handle = char_handle; + _op.data = data; + return *this; +} + +GATTOperationBuilder& GATTOperationBuilder::enableNotify(uint16_t conn_handle) { + _op.type = OperationType::NOTIFY_ENABLE; + _op.conn_handle = conn_handle; + return *this; +} + +GATTOperationBuilder& GATTOperationBuilder::disableNotify(uint16_t conn_handle) { + _op.type = OperationType::NOTIFY_DISABLE; + _op.conn_handle = conn_handle; + return *this; +} + +GATTOperationBuilder& GATTOperationBuilder::requestMTU(uint16_t conn_handle, uint16_t mtu) { + _op.type = OperationType::MTU_REQUEST; + _op.conn_handle = conn_handle; + // Store requested MTU in data (as 2-byte big-endian) + _op.data = Bytes(2); + uint8_t* ptr = _op.data.writable(2); + ptr[0] = static_cast((mtu >> 8) & 0xFF); + ptr[1] = static_cast(mtu & 0xFF); + return *this; +} + +GATTOperationBuilder& GATTOperationBuilder::withTimeout(uint32_t timeout_ms) { + _op.timeout_ms = timeout_ms; + return *this; +} + +GATTOperationBuilder& GATTOperationBuilder::withCallback( + std::function callback) { + _op.callback = callback; + return *this; +} + +GATTOperation GATTOperationBuilder::build() { + return std::move(_op); +} + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEOperationQueue.h b/lib/ble_interface/BLEOperationQueue.h new file mode 100644 index 0000000..8fd7364 --- /dev/null +++ b/lib/ble_interface/BLEOperationQueue.h @@ -0,0 +1,144 @@ +/** + * @file BLEOperationQueue.h + * @brief GATT operation queue for serializing BLE operations + * + * BLE stacks typically do not queue operations internally - attempting to + * perform multiple GATT operations simultaneously leads to failures or + * undefined behavior. This queue ensures operations are processed one at + * a time in order. + * + * Platform implementations inherit from this class and implement + * executeOperation() to perform the actual BLE stack calls. + */ +#pragma once + +#include "BLETypes.h" +#include "Bytes.h" +#include "Utilities/OS.h" + +#include +#include + +namespace RNS { namespace BLE { + +/** + * @brief Base class for GATT operation queuing + * + * Subclasses must implement executeOperation() to perform the actual + * BLE stack calls. Call process() from the main loop to execute queued + * operations, and complete() from BLE callbacks to signal completion. + */ +class BLEOperationQueue { +public: + BLEOperationQueue(); + virtual ~BLEOperationQueue() = default; + + /** + * @brief Add operation to queue + * + * @param op Operation to queue + */ + void enqueue(GATTOperation op); + + /** + * @brief Process queue - call from loop() + * + * Starts the next operation if none is in progress. + * @return true if an operation was started + */ + bool process(); + + /** + * @brief Mark current operation complete + * + * Call this from BLE callbacks when an operation completes. + * + * @param result Operation result + * @param response_data Response data (for reads) + */ + void complete(OperationResult result, const Bytes& response_data = Bytes()); + + /** + * @brief Check if operation is in progress + */ + bool isBusy() const { return _has_current_op; } + + /** + * @brief Get current operation (if any) + * @return Pointer to current operation, or nullptr if none + */ + const GATTOperation* currentOperation() const { + return _has_current_op ? &_current_op : nullptr; + } + + /** + * @brief Clear all pending operations for a connection + * + * Call this when a connection is terminated to remove orphaned operations. + * + * @param conn_handle Connection handle + */ + void clearForConnection(uint16_t conn_handle); + + /** + * @brief Clear entire queue + */ + void clear(); + + /** + * @brief Get queue depth + */ + size_t depth() const { return _queue.size(); } + + /** + * @brief Set operation timeout + * @param timeout_ms Timeout in milliseconds + */ + void setTimeout(uint32_t timeout_ms) { _default_timeout_ms = timeout_ms; } + +protected: + /** + * @brief Execute a single operation - implement in subclass + * + * Subclasses must implement this to call platform-specific BLE APIs. + * Return true if the operation was started successfully. + * Call complete() from the BLE callback when the operation finishes. + * + * @param op Operation to execute + * @return true if operation was started + */ + virtual bool executeOperation(const GATTOperation& op) = 0; + +private: + /** + * @brief Check for timeout on current operation + */ + void checkTimeout(); + + std::queue _queue; + GATTOperation _current_op; + bool _has_current_op = false; + uint32_t _default_timeout_ms = 5000; +}; + +/** + * @brief Helper class for building GATT operations + */ +class GATTOperationBuilder { +public: + GATTOperationBuilder& read(uint16_t conn_handle, uint16_t char_handle); + GATTOperationBuilder& write(uint16_t conn_handle, uint16_t char_handle, const Bytes& data); + GATTOperationBuilder& writeNoResponse(uint16_t conn_handle, uint16_t char_handle, const Bytes& data); + GATTOperationBuilder& enableNotify(uint16_t conn_handle); + GATTOperationBuilder& disableNotify(uint16_t conn_handle); + GATTOperationBuilder& requestMTU(uint16_t conn_handle, uint16_t mtu); + GATTOperationBuilder& withTimeout(uint32_t timeout_ms); + GATTOperationBuilder& withCallback(std::function callback); + + GATTOperation build(); + +private: + GATTOperation _op; +}; + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEPeerManager.cpp b/lib/ble_interface/BLEPeerManager.cpp new file mode 100644 index 0000000..e9ea0ad --- /dev/null +++ b/lib/ble_interface/BLEPeerManager.cpp @@ -0,0 +1,870 @@ +/** + * @file BLEPeerManager.cpp + * @brief BLE-Reticulum Protocol v2.2 peer management implementation + * + * Uses fixed-size pools instead of STL containers to eliminate heap fragmentation. + */ + +#include "BLEPeerManager.h" +#include "Log.h" + +#include +#include + +namespace RNS { namespace BLE { + +BLEPeerManager::BLEPeerManager() { + _local_mac = Bytes(6); // Initialize to zeros + + // Initialize all pools to empty state + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + _peers_by_identity_pool[i].clear(); + _peers_by_mac_only_pool[i].clear(); + } + for (size_t i = 0; i < MAC_IDENTITY_POOL_SIZE; i++) { + _mac_to_identity_pool[i].clear(); + } + for (size_t i = 0; i < MAX_CONN_HANDLES; i++) { + _handle_to_peer[i] = nullptr; + } +} + +void BLEPeerManager::setLocalMac(const Bytes& mac) { + if (mac.size() >= Limits::MAC_SIZE) { + _local_mac = Bytes(mac.data(), Limits::MAC_SIZE); + } +} + +//============================================================================= +// Peer Discovery +//============================================================================= + +bool BLEPeerManager::addDiscoveredPeer(const Bytes& mac_address, int8_t rssi, uint8_t address_type) { + if (mac_address.size() < Limits::MAC_SIZE) { + return false; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + double now = Utilities::OS::time(); + + // Check if this MAC maps to a known identity + Bytes identity = getIdentityForMac(mac); + if (identity.size() == Limits::IDENTITY_SIZE) { + // Update existing peer with identity + PeerByIdentitySlot* slot = findPeerByIdentitySlot(identity); + if (slot) { + PeerInfo& peer = slot->peer; + + // Check if blacklisted + if (peer.state == PeerState::BLACKLISTED && now < peer.blacklisted_until) { + return false; + } + + peer.last_seen = now; + peer.rssi = rssi; + peer.address_type = address_type; // Update address type + // Exponential moving average for RSSI + peer.rssi_avg = static_cast(0.7f * peer.rssi_avg + 0.3f * rssi); + + return true; + } + } + + // Check if peer exists in MAC-only storage + PeerByMacSlot* mac_slot = findPeerByMacSlot(mac); + if (mac_slot) { + PeerInfo& peer = mac_slot->peer; + + // Check if blacklisted + if (peer.state == PeerState::BLACKLISTED && now < peer.blacklisted_until) { + return false; + } + + peer.last_seen = now; + peer.rssi = rssi; + peer.address_type = address_type; // Update address type + peer.rssi_avg = static_cast(0.7f * peer.rssi_avg + 0.3f * rssi); + + return true; + } + + // New peer - add to MAC-only storage + PeerByMacSlot* empty_slot = findEmptyPeerByMacSlot(); + if (!empty_slot) { + WARNING("BLEPeerManager: MAC-only peer pool is full, cannot add new peer"); + return false; + } + + empty_slot->in_use = true; + empty_slot->mac_address = mac; + PeerInfo& peer = empty_slot->peer; + peer = PeerInfo(); // Reset to defaults + peer.mac_address = mac; + peer.address_type = address_type; + peer.state = PeerState::DISCOVERED; + peer.discovered_at = now; + peer.last_seen = now; + peer.rssi = rssi; + peer.rssi_avg = rssi; + + char buf[80]; + snprintf(buf, sizeof(buf), "BLEPeerManager: Discovered new peer %s RSSI %d", + BLEAddress(mac.data()).toString().c_str(), rssi); + DEBUG(buf); + + return true; +} + +bool BLEPeerManager::setPeerIdentity(const Bytes& mac_address, const Bytes& identity) { + if (mac_address.size() < Limits::MAC_SIZE || identity.size() != Limits::IDENTITY_SIZE) { + return false; + } + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + // Check if peer exists in MAC-only storage + PeerByMacSlot* mac_slot = findPeerByMacSlot(mac); + if (mac_slot) { + promoteToIdentityKeyed(mac, identity); + return true; + } + + // Check if peer already has identity (MAC might have changed) + PeerByIdentitySlot* identity_slot = findPeerByIdentitySlot(identity); + if (identity_slot) { + // Update MAC address mapping + PeerInfo& peer = identity_slot->peer; + + // Remove old MAC mapping if different + if (peer.mac_address != mac) { + removeMacToIdentity(peer.mac_address); + peer.mac_address = mac; + setMacToIdentity(mac, identity); + } + + return true; + } + + // Peer not found + WARNING("BLEPeerManager: Cannot set identity for unknown peer"); + return false; +} + +bool BLEPeerManager::updatePeerMac(const Bytes& identity, const Bytes& new_mac) { + if (identity.size() != Limits::IDENTITY_SIZE || new_mac.size() < Limits::MAC_SIZE) { + return false; + } + + Bytes mac(new_mac.data(), Limits::MAC_SIZE); + + PeerByIdentitySlot* slot = findPeerByIdentitySlot(identity); + if (!slot) { + return false; + } + + PeerInfo& peer = slot->peer; + + // Remove old MAC mapping + removeMacToIdentity(peer.mac_address); + + // Update to new MAC + peer.mac_address = mac; + setMacToIdentity(mac, identity); + + char buf[80]; + snprintf(buf, sizeof(buf), "BLEPeerManager: Updated MAC for peer to %s", + BLEAddress(mac.data()).toString().c_str()); + DEBUG(buf); + + return true; +} + +//============================================================================= +// Peer Lookup +//============================================================================= + +PeerInfo* BLEPeerManager::getPeerByMac(const Bytes& mac_address) { + if (mac_address.size() < Limits::MAC_SIZE) return nullptr; + + Bytes mac(mac_address.data(), Limits::MAC_SIZE); + + // Check MAC-to-identity mapping first + Bytes identity = getIdentityForMac(mac); + if (identity.size() == Limits::IDENTITY_SIZE) { + PeerByIdentitySlot* slot = findPeerByIdentitySlot(identity); + if (slot) { + return &slot->peer; + } + } + + // Check MAC-only storage + PeerByMacSlot* mac_slot = findPeerByMacSlot(mac); + if (mac_slot) { + return &mac_slot->peer; + } + + return nullptr; +} + +const PeerInfo* BLEPeerManager::getPeerByMac(const Bytes& mac_address) const { + return const_cast(this)->getPeerByMac(mac_address); +} + +PeerInfo* BLEPeerManager::getPeerByIdentity(const Bytes& identity) { + if (identity.size() != Limits::IDENTITY_SIZE) return nullptr; + + PeerByIdentitySlot* slot = findPeerByIdentitySlot(identity); + if (slot) { + return &slot->peer; + } + + return nullptr; +} + +const PeerInfo* BLEPeerManager::getPeerByIdentity(const Bytes& identity) const { + return const_cast(this)->getPeerByIdentity(identity); +} + +PeerInfo* BLEPeerManager::getPeerByHandle(uint16_t conn_handle) { + // O(1) lookup using handle array + return getHandleToPeer(conn_handle); +} + +const PeerInfo* BLEPeerManager::getPeerByHandle(uint16_t conn_handle) const { + return getHandleToPeer(conn_handle); +} + +std::vector BLEPeerManager::getConnectedPeers() { + std::vector result; + result.reserve(PEERS_POOL_SIZE); + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use && _peers_by_identity_pool[i].peer.isConnected()) { + result.push_back(&_peers_by_identity_pool[i].peer); + } + } + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use && _peers_by_mac_only_pool[i].peer.isConnected()) { + result.push_back(&_peers_by_mac_only_pool[i].peer); + } + } + + return result; +} + +std::vector BLEPeerManager::getAllPeers() { + std::vector result; + result.reserve(PEERS_POOL_SIZE * 2); + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use) { + result.push_back(&_peers_by_identity_pool[i].peer); + } + } + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use) { + result.push_back(&_peers_by_mac_only_pool[i].peer); + } + } + + return result; +} + +//============================================================================= +// Connection Management +//============================================================================= + +PeerInfo* BLEPeerManager::getBestConnectionCandidate() { + double now = Utilities::OS::time(); + PeerInfo* best = nullptr; + float best_score = -1.0f; + + auto checkPeer = [&](PeerInfo& peer) { + // Skip if already connected or connecting + if (peer.state != PeerState::DISCOVERED) { + return; + } + + // Skip if blacklisted + if (peer.state == PeerState::BLACKLISTED && now < peer.blacklisted_until) { + return; + } + + // Skip if we shouldn't initiate (MAC sorting) + if (!shouldInitiateConnection(peer.mac_address)) { + return; + } + + if (peer.score > best_score) { + best_score = peer.score; + best = &peer; + } + }; + + // Check identity-keyed peers (unlikely to be DISCOVERED state, but possible after disconnect) + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use) { + checkPeer(_peers_by_identity_pool[i].peer); + } + } + + // Check MAC-only peers (more common for connection candidates) + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use) { + checkPeer(_peers_by_mac_only_pool[i].peer); + } + } + + return best; +} + +bool BLEPeerManager::shouldInitiateConnection(const Bytes& peer_mac) const { + return shouldInitiateConnection(_local_mac, peer_mac); +} + +bool BLEPeerManager::shouldInitiateConnection(const Bytes& our_mac, const Bytes& peer_mac) { + if (our_mac.size() < Limits::MAC_SIZE || peer_mac.size() < Limits::MAC_SIZE) { + return false; + } + + // Check if our MAC is a random address (first byte >= 0xC0) + // Random addresses have the two MSBs of the first byte set (0b11xxxxxx) + // When using random addresses, MAC comparison is unreliable because our + // random MAC changes between restarts. In this case, always initiate + // connections and let the identity layer handle duplicate connections. + if (our_mac.data()[0] >= 0xC0) { + return true; // Always initiate with random address + } + + // Lower MAC initiates connection (standard behavior for public addresses) + BLEAddress our_addr(our_mac.data()); + BLEAddress peer_addr(peer_mac.data()); + + bool result = our_addr.isLowerThan(peer_addr); + char buf[100]; + snprintf(buf, sizeof(buf), "BLEPeerManager::shouldInitiateConnection: our=%s peer=%s result=%s", + our_addr.toString().c_str(), peer_addr.toString().c_str(), result ? "yes" : "no"); + DEBUG(buf); + return result; +} + +void BLEPeerManager::connectionSucceeded(const Bytes& identifier) { + PeerInfo* peer = findPeer(identifier); + if (!peer) return; + + peer->connection_successes++; + peer->consecutive_failures = 0; + peer->connected_at = Utilities::OS::time(); + peer->state = PeerState::CONNECTED; + + DEBUG("BLEPeerManager: Connection succeeded for peer"); +} + +void BLEPeerManager::connectionFailed(const Bytes& identifier) { + PeerInfo* peer = findPeer(identifier); + if (!peer) return; + + // Clear handle mapping on disconnect + if (peer->conn_handle != 0xFFFF) { + clearHandleToPeer(peer->conn_handle); + peer->conn_handle = 0xFFFF; + } + + peer->connection_failures++; + peer->consecutive_failures++; + peer->state = PeerState::DISCOVERED; + + // Check if should blacklist + if (peer->consecutive_failures >= Limits::BLACKLIST_THRESHOLD) { + double duration = calculateBlacklistDuration(peer->consecutive_failures); + peer->blacklisted_until = Utilities::OS::time() + duration; + peer->state = PeerState::BLACKLISTED; + + char buf[80]; + snprintf(buf, sizeof(buf), "BLEPeerManager: Blacklisted peer for %.0fs after %u failures", + duration, peer->consecutive_failures); + WARNING(buf); + } +} + +void BLEPeerManager::setPeerState(const Bytes& identifier, PeerState state) { + PeerInfo* peer = findPeer(identifier); + if (peer) { + peer->state = state; + } +} + +void BLEPeerManager::setPeerHandle(const Bytes& identifier, uint16_t conn_handle) { + PeerInfo* peer = findPeer(identifier); + if (peer) { + // Remove old handle mapping if exists + if (peer->conn_handle != 0xFFFF) { + clearHandleToPeer(peer->conn_handle); + } + peer->conn_handle = conn_handle; + // Add new handle mapping + if (conn_handle != 0xFFFF) { + setHandleToPeer(conn_handle, peer); + } + } +} + +void BLEPeerManager::setPeerMTU(const Bytes& identifier, uint16_t mtu) { + PeerInfo* peer = findPeer(identifier); + if (peer) { + peer->mtu = mtu; + } +} + +void BLEPeerManager::removePeer(const Bytes& identifier) { + // Try identity first + if (identifier.size() == Limits::IDENTITY_SIZE) { + PeerByIdentitySlot* slot = findPeerByIdentitySlot(identifier); + if (slot) { + // Remove handle mapping + if (slot->peer.conn_handle != 0xFFFF) { + clearHandleToPeer(slot->peer.conn_handle); + } + // Remove MAC mapping + removeMacToIdentity(slot->peer.mac_address); + slot->clear(); + return; + } + } + + // Try MAC + if (identifier.size() >= Limits::MAC_SIZE) { + Bytes mac(identifier.data(), Limits::MAC_SIZE); + + // Check if maps to identity + Bytes identity = getIdentityForMac(mac); + if (identity.size() == Limits::IDENTITY_SIZE) { + PeerByIdentitySlot* slot = findPeerByIdentitySlot(identity); + if (slot) { + // Remove handle mapping + if (slot->peer.conn_handle != 0xFFFF) { + clearHandleToPeer(slot->peer.conn_handle); + } + slot->clear(); + } + removeMacToIdentity(mac); + return; + } + + // Check MAC-only + PeerByMacSlot* mac_slot = findPeerByMacSlot(mac); + if (mac_slot) { + // Remove handle mapping + if (mac_slot->peer.conn_handle != 0xFFFF) { + clearHandleToPeer(mac_slot->peer.conn_handle); + } + mac_slot->clear(); + } + } +} + +void BLEPeerManager::updateRssi(const Bytes& identifier, int8_t rssi) { + PeerInfo* peer = findPeer(identifier); + if (peer) { + peer->rssi = rssi; + peer->rssi_avg = static_cast(0.7f * peer->rssi_avg + 0.3f * rssi); + } +} + +//============================================================================= +// Statistics +//============================================================================= + +void BLEPeerManager::recordPacketSent(const Bytes& identifier) { + PeerInfo* peer = findPeer(identifier); + if (peer) { + peer->packets_sent++; + peer->last_activity = Utilities::OS::time(); + } +} + +void BLEPeerManager::recordPacketReceived(const Bytes& identifier) { + PeerInfo* peer = findPeer(identifier); + if (peer) { + peer->packets_received++; + peer->last_activity = Utilities::OS::time(); + } +} + +void BLEPeerManager::updateLastActivity(const Bytes& identifier) { + PeerInfo* peer = findPeer(identifier); + if (peer) { + peer->last_activity = Utilities::OS::time(); + } +} + +//============================================================================= +// Scoring & Blacklist +//============================================================================= + +void BLEPeerManager::recalculateScores() { + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use) { + _peers_by_identity_pool[i].peer.score = calculateScore(_peers_by_identity_pool[i].peer); + } + } + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use) { + _peers_by_mac_only_pool[i].peer.score = calculateScore(_peers_by_mac_only_pool[i].peer); + } + } +} + +void BLEPeerManager::checkBlacklistExpirations() { + double now = Utilities::OS::time(); + + auto checkAndClear = [now](PeerInfo& peer) { + if (peer.state == PeerState::BLACKLISTED && now >= peer.blacklisted_until) { + peer.state = PeerState::DISCOVERED; + peer.blacklisted_until = 0; + DEBUG("BLEPeerManager: Peer blacklist expired, restored to DISCOVERED"); + } + }; + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use) { + checkAndClear(_peers_by_identity_pool[i].peer); + } + } + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use) { + checkAndClear(_peers_by_mac_only_pool[i].peer); + } + } +} + +//============================================================================= +// Counts & Limits +//============================================================================= + +size_t BLEPeerManager::connectedCount() const { + size_t count = 0; + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use && _peers_by_identity_pool[i].peer.isConnected()) { + count++; + } + } + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use && _peers_by_mac_only_pool[i].peer.isConnected()) { + count++; + } + } + + return count; +} + +void BLEPeerManager::cleanupStalePeers(double max_age) { + double now = Utilities::OS::time(); + + // Check MAC-only peers (identity-keyed peers are more persistent) + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (!_peers_by_mac_only_pool[i].in_use) continue; + + const PeerInfo& peer = _peers_by_mac_only_pool[i].peer; + + // Only clean up DISCOVERED peers (not connected or connecting) + if (peer.state == PeerState::DISCOVERED) { + double age = now - peer.last_seen; + if (age > max_age) { + Bytes mac = _peers_by_mac_only_pool[i].mac_address; + _peers_by_mac_only_pool[i].clear(); + char buf[80]; + snprintf(buf, sizeof(buf), "BLEPeerManager: Removed stale peer %s", + BLEAddress(mac.data()).toString().c_str()); + TRACE(buf); + } + } + } +} + +//============================================================================= +// Private Methods +//============================================================================= + +float BLEPeerManager::calculateScore(const PeerInfo& peer) const { + double now = Utilities::OS::time(); + + // RSSI component (60% weight) + float rssi_norm = normalizeRssi(peer.rssi_avg); + float rssi_score = Scoring::RSSI_WEIGHT * rssi_norm; + + // History component (30% weight) + float history_score = 0.0f; + if (peer.connection_attempts > 0) { + float success_rate = static_cast(peer.connection_successes) / + static_cast(peer.connection_attempts); + history_score = Scoring::HISTORY_WEIGHT * success_rate; + } else { + // New peer: benefit of the doubt (50%) + history_score = Scoring::HISTORY_WEIGHT * 0.5f; + } + + // Recency component (10% weight) + float recency_score = 0.0f; + double age = now - peer.last_seen; + if (age < 5.0) { + recency_score = Scoring::RECENCY_WEIGHT * 1.0f; + } else if (age < 30.0) { + // Linear decay from 1.0 to 0.0 over 25 seconds + recency_score = Scoring::RECENCY_WEIGHT * (1.0f - static_cast((age - 5.0) / 25.0)); + } + + return rssi_score + history_score + recency_score; +} + +float BLEPeerManager::normalizeRssi(int8_t rssi) const { + // Clamp to expected range + if (rssi < Scoring::RSSI_MIN) rssi = Scoring::RSSI_MIN; + if (rssi > Scoring::RSSI_MAX) rssi = Scoring::RSSI_MAX; + + // Map to 0.0-1.0 + return static_cast(rssi - Scoring::RSSI_MIN) / + static_cast(Scoring::RSSI_MAX - Scoring::RSSI_MIN); +} + +double BLEPeerManager::calculateBlacklistDuration(uint8_t failures) const { + // Exponential backoff: 60s × min(2^(failures-3), 8) + if (failures < Limits::BLACKLIST_THRESHOLD) { + return 0; + } + + uint8_t exponent = failures - Limits::BLACKLIST_THRESHOLD; + uint8_t multiplier = 1 << exponent; // 2^exponent + if (multiplier > Limits::BLACKLIST_MAX_MULTIPLIER) { + multiplier = Limits::BLACKLIST_MAX_MULTIPLIER; + } + + return Timing::BLACKLIST_BASE_BACKOFF * multiplier; +} + +PeerInfo* BLEPeerManager::findPeer(const Bytes& identifier) { + // Try as identity + if (identifier.size() == Limits::IDENTITY_SIZE) { + PeerByIdentitySlot* slot = findPeerByIdentitySlot(identifier); + if (slot) { + return &slot->peer; + } + } + + // Try as MAC + if (identifier.size() >= Limits::MAC_SIZE) { + return getPeerByMac(identifier); + } + + return nullptr; +} + +void BLEPeerManager::promoteToIdentityKeyed(const Bytes& mac_address, const Bytes& identity) { + PeerByMacSlot* mac_slot = findPeerByMacSlot(mac_address); + if (!mac_slot) { + return; + } + + // Find an empty slot in identity pool + PeerByIdentitySlot* identity_slot = findEmptyPeerByIdentitySlot(); + if (!identity_slot) { + WARNING("BLEPeerManager: Identity pool is full, cannot promote peer"); + return; + } + + // Copy peer info to identity pool + identity_slot->in_use = true; + identity_slot->identity_hash = identity; + identity_slot->peer = mac_slot->peer; + identity_slot->peer.identity = identity; + + // Update handle mapping to point to new location + if (identity_slot->peer.conn_handle != 0xFFFF) { + setHandleToPeer(identity_slot->peer.conn_handle, &identity_slot->peer); + } + + // Add MAC-to-identity mapping + setMacToIdentity(mac_address, identity); + + // Clear MAC-only slot + mac_slot->clear(); + + DEBUG("BLEPeerManager: Promoted peer to identity-keyed storage"); +} + +//============================================================================= +// Pool Helper Methods - Peers by Identity +//============================================================================= + +BLEPeerManager::PeerByIdentitySlot* BLEPeerManager::findPeerByIdentitySlot(const Bytes& identity) { + if (identity.size() != Limits::IDENTITY_SIZE) return nullptr; + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use && + _peers_by_identity_pool[i].identity_hash == identity) { + return &_peers_by_identity_pool[i]; + } + } + return nullptr; +} + +const BLEPeerManager::PeerByIdentitySlot* BLEPeerManager::findPeerByIdentitySlot(const Bytes& identity) const { + return const_cast(this)->findPeerByIdentitySlot(identity); +} + +BLEPeerManager::PeerByIdentitySlot* BLEPeerManager::findEmptyPeerByIdentitySlot() { + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (!_peers_by_identity_pool[i].in_use) { + return &_peers_by_identity_pool[i]; + } + } + return nullptr; +} + +size_t BLEPeerManager::peersByIdentityCount() const { + size_t count = 0; + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_identity_pool[i].in_use) count++; + } + return count; +} + +//============================================================================= +// Pool Helper Methods - Peers by MAC Only +//============================================================================= + +BLEPeerManager::PeerByMacSlot* BLEPeerManager::findPeerByMacSlot(const Bytes& mac) { + if (mac.size() < Limits::MAC_SIZE) return nullptr; + + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use && + _peers_by_mac_only_pool[i].mac_address == mac) { + return &_peers_by_mac_only_pool[i]; + } + } + return nullptr; +} + +const BLEPeerManager::PeerByMacSlot* BLEPeerManager::findPeerByMacSlot(const Bytes& mac) const { + return const_cast(this)->findPeerByMacSlot(mac); +} + +BLEPeerManager::PeerByMacSlot* BLEPeerManager::findEmptyPeerByMacSlot() { + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (!_peers_by_mac_only_pool[i].in_use) { + return &_peers_by_mac_only_pool[i]; + } + } + return nullptr; +} + +size_t BLEPeerManager::peersByMacOnlyCount() const { + size_t count = 0; + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (_peers_by_mac_only_pool[i].in_use) count++; + } + return count; +} + +//============================================================================= +// Pool Helper Methods - MAC to Identity Mapping +//============================================================================= + +BLEPeerManager::MacToIdentitySlot* BLEPeerManager::findMacToIdentitySlot(const Bytes& mac) { + if (mac.size() < Limits::MAC_SIZE) return nullptr; + + for (size_t i = 0; i < MAC_IDENTITY_POOL_SIZE; i++) { + if (_mac_to_identity_pool[i].in_use && + _mac_to_identity_pool[i].mac_address == mac) { + return &_mac_to_identity_pool[i]; + } + } + return nullptr; +} + +const BLEPeerManager::MacToIdentitySlot* BLEPeerManager::findMacToIdentitySlot(const Bytes& mac) const { + return const_cast(this)->findMacToIdentitySlot(mac); +} + +BLEPeerManager::MacToIdentitySlot* BLEPeerManager::findEmptyMacToIdentitySlot() { + for (size_t i = 0; i < MAC_IDENTITY_POOL_SIZE; i++) { + if (!_mac_to_identity_pool[i].in_use) { + return &_mac_to_identity_pool[i]; + } + } + return nullptr; +} + +bool BLEPeerManager::setMacToIdentity(const Bytes& mac, const Bytes& identity) { + // Check if already exists + MacToIdentitySlot* existing = findMacToIdentitySlot(mac); + if (existing) { + existing->identity = identity; + return true; + } + + // Find empty slot + MacToIdentitySlot* slot = findEmptyMacToIdentitySlot(); + if (!slot) { + WARNING("BLEPeerManager: MAC-to-identity pool is full"); + return false; + } + + slot->in_use = true; + slot->mac_address = mac; + slot->identity = identity; + return true; +} + +void BLEPeerManager::removeMacToIdentity(const Bytes& mac) { + MacToIdentitySlot* slot = findMacToIdentitySlot(mac); + if (slot) { + slot->clear(); + } +} + +Bytes BLEPeerManager::getIdentityForMac(const Bytes& mac) const { + const MacToIdentitySlot* slot = findMacToIdentitySlot(mac); + if (slot) { + return slot->identity; + } + return Bytes(); +} + +//============================================================================= +// Pool Helper Methods - Handle to Peer Mapping +//============================================================================= + +void BLEPeerManager::setHandleToPeer(uint16_t handle, PeerInfo* peer) { + if (handle < MAX_CONN_HANDLES) { + _handle_to_peer[handle] = peer; + } +} + +void BLEPeerManager::clearHandleToPeer(uint16_t handle) { + if (handle < MAX_CONN_HANDLES) { + _handle_to_peer[handle] = nullptr; + } +} + +PeerInfo* BLEPeerManager::getHandleToPeer(uint16_t handle) { + if (handle < MAX_CONN_HANDLES) { + return _handle_to_peer[handle]; + } + return nullptr; +} + +const PeerInfo* BLEPeerManager::getHandleToPeer(uint16_t handle) const { + if (handle < MAX_CONN_HANDLES) { + return _handle_to_peer[handle]; + } + return nullptr; +} + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEPeerManager.h b/lib/ble_interface/BLEPeerManager.h new file mode 100644 index 0000000..db02c9b --- /dev/null +++ b/lib/ble_interface/BLEPeerManager.h @@ -0,0 +1,483 @@ +/** + * @file BLEPeerManager.h + * @brief BLE-Reticulum Protocol v2.2 peer management + * + * Manages discovered and connected BLE peers with: + * - Peer scoring for connection prioritization + * - Blacklisting with exponential backoff + * - MAC address rotation handling via identity-based keying + * - Connection direction determination via MAC sorting + * + * Uses fixed-size pools instead of STL containers to eliminate heap fragmentation. + */ +#pragma once + +#include "BLETypes.h" +#include "Bytes.h" +#include "Utilities/OS.h" + +#include + +namespace RNS { namespace BLE { + +/** + * @brief Information about a discovered/connected peer + */ +struct PeerInfo { + // Addressing (both needed for MAC rotation handling) + Bytes mac_address; // Current 6-byte MAC address + Bytes identity; // 16-byte identity hash (stable key) + uint8_t address_type = 0; // BLE address type (0=public, 1=random) + + // Connection state + PeerState state = PeerState::DISCOVERED; + bool is_central = false; // true if we are central (we initiated) + + // Timing + double discovered_at = 0.0; + double last_seen = 0.0; + double last_activity = 0.0; + double connected_at = 0.0; + + // Signal quality + int8_t rssi = Scoring::RSSI_MIN; + int8_t rssi_avg = Scoring::RSSI_MIN; // Smoothed average + + // Statistics for scoring + uint32_t packets_sent = 0; + uint32_t packets_received = 0; + uint32_t connection_attempts = 0; + uint32_t connection_successes = 0; + uint32_t connection_failures = 0; + + // Blacklist tracking + uint8_t consecutive_failures = 0; + double blacklisted_until = 0.0; + + // BLE connection handle (platform-specific) + uint16_t conn_handle = 0xFFFF; + + // MTU for this peer + uint16_t mtu = MTU::MINIMUM; + + // Computed score (cached) + float score = 0.0f; + + // Check if peer has known identity + bool hasIdentity() const { return identity.size() == Limits::IDENTITY_SIZE; } + + // Check if connected + bool isConnected() const { + return state == PeerState::CONNECTED || + state == PeerState::HANDSHAKING; + } +}; + +/** + * @brief Manages BLE peers for the BLEInterface + * + * Uses fixed-size pools to eliminate heap fragmentation: + * - _peers_pool: Stores all peer info (max 8 slots) + * - _mac_to_identity_pool: Maps MAC addresses to identities (max 8 slots) + * - _handle_to_peer: Fixed array indexed by connection handle (max 8) + */ +class BLEPeerManager { +public: + //========================================================================= + // Pool Configuration + //========================================================================= + static constexpr size_t PEERS_POOL_SIZE = 8; + static constexpr size_t MAC_IDENTITY_POOL_SIZE = 8; + static constexpr size_t MAX_CONN_HANDLES = 8; + + /** + * @brief Slot for storing peer info (keyed by identity) + */ + struct PeerByIdentitySlot { + bool in_use = false; + Bytes identity_hash; // 16-byte identity key + PeerInfo peer; // value + + void clear() { + in_use = false; + identity_hash.clear(); + peer = PeerInfo(); + } + }; + + /** + * @brief Slot for storing peer info (keyed by MAC only, no identity yet) + */ + struct PeerByMacSlot { + bool in_use = false; + Bytes mac_address; // 6-byte MAC key + PeerInfo peer; // value + + void clear() { + in_use = false; + mac_address.clear(); + peer = PeerInfo(); + } + }; + + /** + * @brief Slot for MAC to identity mapping + */ + struct MacToIdentitySlot { + bool in_use = false; + Bytes mac_address; // 6-byte MAC key + Bytes identity; // 16-byte identity value + + void clear() { + in_use = false; + mac_address.clear(); + identity.clear(); + } + }; + + BLEPeerManager(); + + /** + * @brief Set our local MAC address (for connection direction decisions) + */ + void setLocalMac(const Bytes& mac); + + /** + * @brief Get our local MAC address + */ + const Bytes& getLocalMac() const { return _local_mac; } + + //========================================================================= + // Peer Discovery + //========================================================================= + + /** + * @brief Register a newly discovered peer from BLE scan + * + * @param mac_address 6-byte MAC address + * @param rssi Signal strength + * @param address_type BLE address type (0=public, 1=random) + * @return true if peer was added or updated (not blacklisted) + */ + bool addDiscoveredPeer(const Bytes& mac_address, int8_t rssi, uint8_t address_type = 0); + + /** + * @brief Update peer identity after handshake completion + * + * @param mac_address Current MAC address + * @param identity 16-byte identity hash + * @return true if peer was found and updated + */ + bool setPeerIdentity(const Bytes& mac_address, const Bytes& identity); + + /** + * @brief Update peer MAC address (when identity already known but MAC rotated) + * + * @param identity 16-byte identity hash + * @param new_mac New 6-byte MAC address + * @return true if peer was found and updated + */ + bool updatePeerMac(const Bytes& identity, const Bytes& new_mac); + + //========================================================================= + // Peer Lookup + //========================================================================= + + /** + * @brief Get peer info by MAC address + * @return Pointer to PeerInfo or nullptr if not found + */ + PeerInfo* getPeerByMac(const Bytes& mac_address); + const PeerInfo* getPeerByMac(const Bytes& mac_address) const; + + /** + * @brief Get peer info by identity + * @return Pointer to PeerInfo or nullptr if not found + */ + PeerInfo* getPeerByIdentity(const Bytes& identity); + const PeerInfo* getPeerByIdentity(const Bytes& identity) const; + + /** + * @brief Get peer info by connection handle + * @return Pointer to PeerInfo or nullptr if not found + */ + PeerInfo* getPeerByHandle(uint16_t conn_handle); + const PeerInfo* getPeerByHandle(uint16_t conn_handle) const; + + /** + * @brief Get all connected peers + */ + std::vector getConnectedPeers(); + + /** + * @brief Get all peers (for iteration) + */ + std::vector getAllPeers(); + + //========================================================================= + // Connection Management + //========================================================================= + + /** + * @brief Get best peer to connect to (highest score, not blacklisted) + * @return Pointer to best peer or nullptr if none available + */ + PeerInfo* getBestConnectionCandidate(); + + /** + * @brief Check if we should initiate connection to a peer (MAC sorting rule) + * + * Lower MAC address should be the initiator (central). + * @param peer_mac The peer's MAC address + * @return true if we should initiate (our MAC < peer MAC) + */ + bool shouldInitiateConnection(const Bytes& peer_mac) const; + + /** + * @brief Static version for use without instance + */ + static bool shouldInitiateConnection(const Bytes& our_mac, const Bytes& peer_mac); + + /** + * @brief Mark peer connection as successful + */ + void connectionSucceeded(const Bytes& identifier); + + /** + * @brief Mark peer connection as failed + */ + void connectionFailed(const Bytes& identifier); + + /** + * @brief Update peer state + */ + void setPeerState(const Bytes& identifier, PeerState state); + + /** + * @brief Set peer connection handle + */ + void setPeerHandle(const Bytes& identifier, uint16_t conn_handle); + + /** + * @brief Set peer MTU + */ + void setPeerMTU(const Bytes& identifier, uint16_t mtu); + + /** + * @brief Remove a peer + */ + void removePeer(const Bytes& identifier); + + /** + * @brief Update peer RSSI + */ + void updateRssi(const Bytes& identifier, int8_t rssi); + + //========================================================================= + // Statistics + //========================================================================= + + /** + * @brief Record packet sent to peer + */ + void recordPacketSent(const Bytes& identifier); + + /** + * @brief Record packet received from peer + */ + void recordPacketReceived(const Bytes& identifier); + + /** + * @brief Update last activity time for peer + */ + void updateLastActivity(const Bytes& identifier); + + //========================================================================= + // Scoring & Blacklist + //========================================================================= + + /** + * @brief Recalculate scores for all peers + * + * Should be called periodically or after significant changes. + */ + void recalculateScores(); + + /** + * @brief Check blacklist expirations and restore peers + */ + void checkBlacklistExpirations(); + + //========================================================================= + // Counts & Limits + //========================================================================= + + /** + * @brief Get current connected peer count + */ + size_t connectedCount() const; + + /** + * @brief Get total peer count + */ + size_t totalPeerCount() const { return peersByIdentityCount() + peersByMacOnlyCount(); } + + /** + * @brief Check if we can accept more connections + */ + bool canAcceptConnection() const { return connectedCount() < Limits::MAX_PEERS; } + + /** + * @brief Clean up stale discovered peers + * @param max_age Maximum age in seconds for discovered (unconnected) peers + */ + void cleanupStalePeers(double max_age = Timing::PEER_TIMEOUT); + +private: + /** + * @brief Calculate peer score using v2.2 formula + */ + float calculateScore(const PeerInfo& peer) const; + + /** + * @brief Normalize RSSI to 0.0-1.0 range + */ + float normalizeRssi(int8_t rssi) const; + + /** + * @brief Calculate blacklist duration for given failure count + */ + double calculateBlacklistDuration(uint8_t failures) const; + + /** + * @brief Find peer by any identifier (MAC or identity) + */ + PeerInfo* findPeer(const Bytes& identifier); + + /** + * @brief Move peer from MAC-only to identity-keyed storage + */ + void promoteToIdentityKeyed(const Bytes& mac_address, const Bytes& identity); + + //========================================================================= + // Pool Helper Methods - Peers by Identity + //========================================================================= + + /** + * @brief Find slot by identity key + * @return Pointer to slot or nullptr if not found + */ + PeerByIdentitySlot* findPeerByIdentitySlot(const Bytes& identity); + const PeerByIdentitySlot* findPeerByIdentitySlot(const Bytes& identity) const; + + /** + * @brief Find an empty slot in the identity pool + * @return Pointer to empty slot or nullptr if pool is full + */ + PeerByIdentitySlot* findEmptyPeerByIdentitySlot(); + + /** + * @brief Get count of peers by identity + */ + size_t peersByIdentityCount() const; + + //========================================================================= + // Pool Helper Methods - Peers by MAC Only + //========================================================================= + + /** + * @brief Find slot by MAC key + * @return Pointer to slot or nullptr if not found + */ + PeerByMacSlot* findPeerByMacSlot(const Bytes& mac); + const PeerByMacSlot* findPeerByMacSlot(const Bytes& mac) const; + + /** + * @brief Find an empty slot in the MAC-only pool + * @return Pointer to empty slot or nullptr if pool is full + */ + PeerByMacSlot* findEmptyPeerByMacSlot(); + + /** + * @brief Get count of peers by MAC only + */ + size_t peersByMacOnlyCount() const; + + //========================================================================= + // Pool Helper Methods - MAC to Identity Mapping + //========================================================================= + + /** + * @brief Find MAC-to-identity mapping slot by MAC + * @return Pointer to slot or nullptr if not found + */ + MacToIdentitySlot* findMacToIdentitySlot(const Bytes& mac); + const MacToIdentitySlot* findMacToIdentitySlot(const Bytes& mac) const; + + /** + * @brief Find an empty slot in the MAC-to-identity pool + * @return Pointer to empty slot or nullptr if pool is full + */ + MacToIdentitySlot* findEmptyMacToIdentitySlot(); + + /** + * @brief Add or update MAC-to-identity mapping + * @return true if successful, false if pool is full + */ + bool setMacToIdentity(const Bytes& mac, const Bytes& identity); + + /** + * @brief Remove MAC-to-identity mapping + */ + void removeMacToIdentity(const Bytes& mac); + + /** + * @brief Get identity for a MAC from the mapping pool + * @return Identity or empty Bytes if not found + */ + Bytes getIdentityForMac(const Bytes& mac) const; + + //========================================================================= + // Pool Helper Methods - Handle to Peer Mapping + //========================================================================= + + /** + * @brief Set handle-to-peer mapping + */ + void setHandleToPeer(uint16_t handle, PeerInfo* peer); + + /** + * @brief Clear handle-to-peer mapping + */ + void clearHandleToPeer(uint16_t handle); + + /** + * @brief Get peer for handle + * @return Pointer to peer or nullptr if not found + */ + PeerInfo* getHandleToPeer(uint16_t handle); + const PeerInfo* getHandleToPeer(uint16_t handle) const; + + //========================================================================= + // Fixed-size Pool Storage + //========================================================================= + + // Peers with known identity (keyed by identity) + PeerByIdentitySlot _peers_by_identity_pool[PEERS_POOL_SIZE]; + + // Peers without identity yet (keyed by MAC) + PeerByMacSlot _peers_by_mac_only_pool[PEERS_POOL_SIZE]; + + // MAC to identity lookup for peers with identity + MacToIdentitySlot _mac_to_identity_pool[MAC_IDENTITY_POOL_SIZE]; + + // Connection handle to peer pointer for O(1) lookup + // Index is the connection handle (must be < MAX_CONN_HANDLES) + // nullptr means no mapping for that handle + PeerInfo* _handle_to_peer[MAX_CONN_HANDLES]; + + // Our own MAC address + Bytes _local_mac; +}; + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEPlatform.cpp b/lib/ble_interface/BLEPlatform.cpp new file mode 100644 index 0000000..1be633d --- /dev/null +++ b/lib/ble_interface/BLEPlatform.cpp @@ -0,0 +1,69 @@ +/** + * @file BLEPlatform.cpp + * @brief BLE Platform factory implementation + */ + +#include "BLEPlatform.h" +#include "Log.h" + +// Include platform implementations based on compile-time detection +#if defined(ESP32) && (defined(USE_NIMBLE) || defined(CONFIG_BT_NIMBLE_ENABLED)) +#include "platforms/NimBLEPlatform.h" +#endif + +#if defined(ESP32) && defined(USE_BLUEDROID) +#include "platforms/BluedroidPlatform.h" +#endif + +#if defined(ZEPHYR) || defined(CONFIG_BT) +// Future: #include "platforms/ZephyrPlatform.h" +#endif + +namespace RNS { namespace BLE { + +IBLEPlatform::Ptr BLEPlatformFactory::create() { + return create(getDetectedPlatform()); +} + +IBLEPlatform::Ptr BLEPlatformFactory::create(PlatformType type) { + switch (type) { +#if defined(ESP32) && (defined(USE_NIMBLE) || defined(CONFIG_BT_NIMBLE_ENABLED)) + case PlatformType::NIMBLE_ARDUINO: + INFO("BLEPlatformFactory: Creating NimBLE platform"); + return std::make_shared(); +#endif + +#if defined(ESP32) && defined(USE_BLUEDROID) + case PlatformType::ESP_IDF: + INFO("BLEPlatformFactory: Creating Bluedroid platform"); + return std::make_shared(); +#endif + +#if defined(ZEPHYR) || defined(CONFIG_BT) + case PlatformType::ZEPHYR: + // Future: return std::make_shared(); + ERROR("BLEPlatformFactory: Zephyr platform not yet implemented"); + return nullptr; +#endif + + default: + ERROR("BLEPlatformFactory: No platform available for type " + + std::to_string(static_cast(type))); + return nullptr; + } +} + +PlatformType BLEPlatformFactory::getDetectedPlatform() { +#if defined(ESP32) && defined(USE_BLUEDROID) + // Bluedroid takes priority when explicitly selected + return PlatformType::ESP_IDF; +#elif defined(ESP32) && (defined(USE_NIMBLE) || defined(CONFIG_BT_NIMBLE_ENABLED)) + return PlatformType::NIMBLE_ARDUINO; +#elif defined(ZEPHYR) || defined(CONFIG_BT) + return PlatformType::ZEPHYR; +#else + return PlatformType::NONE; +#endif +} + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEPlatform.h b/lib/ble_interface/BLEPlatform.h new file mode 100644 index 0000000..f6a3cad --- /dev/null +++ b/lib/ble_interface/BLEPlatform.h @@ -0,0 +1,367 @@ +/** + * @file BLEPlatform.h + * @brief BLE Hardware Abstraction Layer (HAL) interface + * + * Provides a platform-agnostic interface for BLE operations. Platform-specific + * implementations (NimBLE, ESP-IDF, Zephyr) implement this interface to enable + * the BLEInterface to work across different hardware. + * + * The HAL abstracts: + * - BLE stack initialization and lifecycle + * - Scanning and advertising + * - Connection management + * - GATT operations (read, write, notify) + * - Callback handling + */ +#pragma once + +#include "BLETypes.h" +#include "Bytes.h" + +#include +#include + +namespace RNS { namespace BLE { + +/** + * @brief Abstract BLE platform interface + * + * Platform-specific implementations should inherit from this class and + * implement all pure virtual methods. The factory method create() returns + * the appropriate implementation based on compile-time detection. + */ +class IBLEPlatform { +public: + using Ptr = std::shared_ptr; + + virtual ~IBLEPlatform() = default; + + //========================================================================= + // Lifecycle + //========================================================================= + + /** + * @brief Initialize the BLE stack with configuration + * + * @param config Platform configuration + * @return true if initialization successful + */ + virtual bool initialize(const PlatformConfig& config) = 0; + + /** + * @brief Start BLE operations (advertising/scanning based on role) + * @return true if started successfully + */ + virtual bool start() = 0; + + /** + * @brief Stop all BLE operations + */ + virtual void stop() = 0; + + /** + * @brief Main loop processing - must be called periodically + * + * Handles BLE events, processes queued operations, and invokes callbacks. + */ + virtual void loop() = 0; + + /** + * @brief Shutdown and cleanup the BLE stack + */ + virtual void shutdown() = 0; + + /** + * @brief Check if platform is initialized and running + */ + virtual bool isRunning() const = 0; + + //========================================================================= + // Central Mode - Scanning + //========================================================================= + + /** + * @brief Start scanning for peripherals + * + * @param duration_ms Scan duration in milliseconds (0 = continuous) + * @return true if scan started successfully + */ + virtual bool startScan(uint16_t duration_ms = 0) = 0; + + /** + * @brief Stop scanning + */ + virtual void stopScan() = 0; + + /** + * @brief Check if currently scanning + */ + virtual bool isScanning() const = 0; + + //========================================================================= + // Central Mode - Connections + //========================================================================= + + /** + * @brief Connect to a peripheral + * + * @param address Peer's BLE address + * @param timeout_ms Connection timeout in milliseconds + * @return true if connection attempt started + */ + virtual bool connect(const BLEAddress& address, uint16_t timeout_ms = 10000) = 0; + + /** + * @brief Disconnect from a peer + * + * @param conn_handle Connection handle + * @return true if disconnect initiated + */ + virtual bool disconnect(uint16_t conn_handle) = 0; + + /** + * @brief Disconnect all connections + */ + virtual void disconnectAll() = 0; + + /** + * @brief Request MTU update for a connection + * + * @param conn_handle Connection handle + * @param mtu Requested MTU + * @return true if request was sent + */ + virtual bool requestMTU(uint16_t conn_handle, uint16_t mtu) = 0; + + /** + * @brief Discover services on connected peripheral + * + * @param conn_handle Connection handle + * @return true if discovery started + */ + virtual bool discoverServices(uint16_t conn_handle) = 0; + + //========================================================================= + // Peripheral Mode - Advertising + //========================================================================= + + /** + * @brief Start advertising + * @return true if advertising started + */ + virtual bool startAdvertising() = 0; + + /** + * @brief Stop advertising + */ + virtual void stopAdvertising() = 0; + + /** + * @brief Check if currently advertising + */ + virtual bool isAdvertising() const = 0; + + /** + * @brief Update advertising data + * + * @param data Custom advertising data + * @return true if updated successfully + */ + virtual bool setAdvertisingData(const Bytes& data) = 0; + + /** + * @brief Set the identity data for the Identity characteristic + * + * @param identity 16-byte identity hash + */ + virtual void setIdentityData(const Bytes& identity) = 0; + + //========================================================================= + // GATT Operations + //========================================================================= + + /** + * @brief Write data to a connected peripheral's RX characteristic + * + * @param conn_handle Connection handle + * @param data Data to write + * @param response true for write with response, false for write without response + * @return true if write was queued/sent + */ + virtual bool write(uint16_t conn_handle, const Bytes& data, bool response = true) = 0; + + /** + * @brief Read from a characteristic + * + * @param conn_handle Connection handle + * @param char_handle Characteristic handle + * @param callback Callback invoked with result + * @return true if read was queued + */ + virtual bool read(uint16_t conn_handle, uint16_t char_handle, + std::function callback) = 0; + + /** + * @brief Enable/disable notifications on TX characteristic + * + * @param conn_handle Connection handle + * @param enable true to enable, false to disable + * @return true if operation was queued + */ + virtual bool enableNotifications(uint16_t conn_handle, bool enable) = 0; + + /** + * @brief Send notification to a connected central (peripheral mode) + * + * @param conn_handle Connection handle + * @param data Data to send + * @return true if notification was sent + */ + virtual bool notify(uint16_t conn_handle, const Bytes& data) = 0; + + /** + * @brief Send notification to all connected centrals + * + * @param data Data to broadcast + * @return true if at least one notification was sent + */ + virtual bool notifyAll(const Bytes& data) = 0; + + //========================================================================= + // Connection Management + //========================================================================= + + /** + * @brief Get all active connections + */ + virtual std::vector getConnections() const = 0; + + /** + * @brief Get connection by handle + */ + virtual ConnectionHandle getConnection(uint16_t handle) const = 0; + + /** + * @brief Get current connection count + */ + virtual size_t getConnectionCount() const = 0; + + /** + * @brief Check if connected to specific address + */ + virtual bool isConnectedTo(const BLEAddress& address) const = 0; + + //========================================================================= + // Callback Registration + //========================================================================= + + /** + * @brief Set callback for scan results + */ + virtual void setOnScanResult(Callbacks::OnScanResult callback) = 0; + + /** + * @brief Set callback for scan completion + */ + virtual void setOnScanComplete(Callbacks::OnScanComplete callback) = 0; + + /** + * @brief Set callback for outgoing connection established (central mode) + */ + virtual void setOnConnected(Callbacks::OnConnected callback) = 0; + + /** + * @brief Set callback for connection terminated + */ + virtual void setOnDisconnected(Callbacks::OnDisconnected callback) = 0; + + /** + * @brief Set callback for MTU change + */ + virtual void setOnMTUChanged(Callbacks::OnMTUChanged callback) = 0; + + /** + * @brief Set callback for service discovery completion + */ + virtual void setOnServicesDiscovered(Callbacks::OnServicesDiscovered callback) = 0; + + /** + * @brief Set callback for data received via notification (central mode) + */ + virtual void setOnDataReceived(Callbacks::OnDataReceived callback) = 0; + + /** + * @brief Set callback for notification enable/disable + */ + virtual void setOnNotifyEnabled(Callbacks::OnNotifyEnabled callback) = 0; + + /** + * @brief Set callback for incoming connection (peripheral mode) + */ + virtual void setOnCentralConnected(Callbacks::OnCentralConnected callback) = 0; + + /** + * @brief Set callback for incoming connection terminated (peripheral mode) + */ + virtual void setOnCentralDisconnected(Callbacks::OnCentralDisconnected callback) = 0; + + /** + * @brief Set callback for data received via write (peripheral mode) + */ + virtual void setOnWriteReceived(Callbacks::OnWriteReceived callback) = 0; + + /** + * @brief Set callback for read request (peripheral mode) + */ + virtual void setOnReadRequested(Callbacks::OnReadRequested callback) = 0; + + //========================================================================= + // Platform Info + //========================================================================= + + /** + * @brief Get the platform type + */ + virtual PlatformType getPlatformType() const = 0; + + /** + * @brief Get human-readable platform name + */ + virtual std::string getPlatformName() const = 0; + + /** + * @brief Get our local BLE address + */ + virtual BLEAddress getLocalAddress() const = 0; +}; + +/** + * @brief Factory for creating platform-specific BLE implementations + */ +class BLEPlatformFactory { +public: + /** + * @brief Create platform instance based on compile-time detection + * + * Returns the appropriate IBLEPlatform implementation for the current + * platform (NimBLE for ESP32, Zephyr for nRF52840, etc.) + * + * @return Shared pointer to platform instance, or nullptr if no platform available + */ + static IBLEPlatform::Ptr create(); + + /** + * @brief Create specific platform (for testing or explicit selection) + * + * @param type Platform type to create + * @return Shared pointer to platform instance, or nullptr if not available + */ + static IBLEPlatform::Ptr create(PlatformType type); + + /** + * @brief Get the detected platform type for this build + */ + static PlatformType getDetectedPlatform(); +}; + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEReassembler.cpp b/lib/ble_interface/BLEReassembler.cpp new file mode 100644 index 0000000..53c7a05 --- /dev/null +++ b/lib/ble_interface/BLEReassembler.cpp @@ -0,0 +1,326 @@ +/** + * @file BLEReassembler.cpp + * @brief BLE-Reticulum Protocol v2.2 fragment reassembler implementation + * + * Uses fixed-size pools instead of STL containers to eliminate heap fragmentation. + */ + +#include "BLEReassembler.h" +#include "Log.h" + +namespace RNS { namespace BLE { + +BLEReassembler::BLEReassembler() { + // Default timeout from protocol spec + _timeout_seconds = Timing::REASSEMBLY_TIMEOUT; + // Initialize pool + for (size_t i = 0; i < MAX_PENDING_REASSEMBLIES; i++) { + _pending_pool[i].clear(); + } +} + +BLEReassembler::PendingReassemblySlot* BLEReassembler::findSlot(const Bytes& peer_identity) { + for (size_t i = 0; i < MAX_PENDING_REASSEMBLIES; i++) { + if (_pending_pool[i].in_use && _pending_pool[i].transfer_id == peer_identity) { + return &_pending_pool[i]; + } + } + return nullptr; +} + +const BLEReassembler::PendingReassemblySlot* BLEReassembler::findSlot(const Bytes& peer_identity) const { + for (size_t i = 0; i < MAX_PENDING_REASSEMBLIES; i++) { + if (_pending_pool[i].in_use && _pending_pool[i].transfer_id == peer_identity) { + return &_pending_pool[i]; + } + } + return nullptr; +} + +BLEReassembler::PendingReassemblySlot* BLEReassembler::allocateSlot(const Bytes& peer_identity) { + // First check if slot already exists for this peer + PendingReassemblySlot* existing = findSlot(peer_identity); + if (existing) { + return existing; + } + + // Find a free slot + for (size_t i = 0; i < MAX_PENDING_REASSEMBLIES; i++) { + if (!_pending_pool[i].in_use) { + _pending_pool[i].in_use = true; + _pending_pool[i].transfer_id = peer_identity; + return &_pending_pool[i]; + } + } + return nullptr; // Pool is full +} + +size_t BLEReassembler::pendingCount() const { + size_t count = 0; + for (size_t i = 0; i < MAX_PENDING_REASSEMBLIES; i++) { + if (_pending_pool[i].in_use) { + count++; + } + } + return count; +} + +void BLEReassembler::setReassemblyCallback(ReassemblyCallback callback) { + _reassembly_callback = callback; +} + +void BLEReassembler::setTimeoutCallback(TimeoutCallback callback) { + _timeout_callback = callback; +} + +void BLEReassembler::setTimeout(double timeout_seconds) { + _timeout_seconds = timeout_seconds; +} + +bool BLEReassembler::processFragment(const Bytes& peer_identity, const Bytes& fragment) { + // Validate fragment + if (!BLEFragmenter::isValidFragment(fragment)) { + TRACE("BLEReassembler: Invalid fragment header"); + return false; + } + + // Parse header + Fragment::Type type; + uint16_t sequence; + uint16_t total_fragments; + if (!BLEFragmenter::parseHeader(fragment, type, sequence, total_fragments)) { + TRACE("BLEReassembler: Failed to parse fragment header"); + return false; + } + + double now = Utilities::OS::time(); + + // Handle START fragment - begins a new reassembly + if (type == Fragment::START) { + // Clear any existing incomplete reassembly for this peer + PendingReassemblySlot* existing = findSlot(peer_identity); + if (existing) { + TRACE("BLEReassembler: Discarding incomplete reassembly for new START"); + existing->clear(); + } + + // Start new reassembly + if (!startReassembly(peer_identity, total_fragments)) { + return false; + } + } + + // Look up pending reassembly + PendingReassemblySlot* slot = findSlot(peer_identity); + if (!slot) { + // No pending reassembly and this isn't a START + if (type != Fragment::START) { + // For single-fragment packets (type=END, total=1, seq=0), start immediately + if (type == Fragment::END && total_fragments == 1 && sequence == 0) { + if (!startReassembly(peer_identity, total_fragments)) { + return false; + } + slot = findSlot(peer_identity); + } else { + TRACE("BLEReassembler: Received fragment without START, discarding"); + return false; + } + } else { + slot = findSlot(peer_identity); + } + } + + if (!slot) { + ERROR("BLEReassembler: Failed to find/create reassembly session"); + return false; + } + + PendingReassembly& reassembly = slot->reassembly; + + // Validate total_fragments matches + if (total_fragments != reassembly.total_fragments) { + char buf[80]; + snprintf(buf, sizeof(buf), "BLEReassembler: Fragment total mismatch, expected %u got %u", + reassembly.total_fragments, total_fragments); + TRACE(buf); + return false; + } + + // Validate sequence is in range + if (sequence >= reassembly.total_fragments) { + char buf[64]; + snprintf(buf, sizeof(buf), "BLEReassembler: Sequence out of range: %u", sequence); + TRACE(buf); + return false; + } + + // Check for duplicate + if (reassembly.fragments[sequence].received) { + char buf[64]; + snprintf(buf, sizeof(buf), "BLEReassembler: Duplicate fragment %u", sequence); + TRACE(buf); + // Still update last_activity to keep session alive + reassembly.last_activity = now; + return true; // Not an error, just duplicate + } + + // Store fragment payload into fixed-size buffer + Bytes payload = BLEFragmenter::extractPayload(fragment); + if (payload.size() > MAX_FRAGMENT_PAYLOAD_SIZE) { + char buf[80]; + snprintf(buf, sizeof(buf), "BLEReassembler: Fragment payload too large: %zu > %zu", + payload.size(), MAX_FRAGMENT_PAYLOAD_SIZE); + WARNING(buf); + return false; + } + memcpy(reassembly.fragments[sequence].data, payload.data(), payload.size()); + reassembly.fragments[sequence].data_size = payload.size(); + reassembly.fragments[sequence].received = true; + reassembly.received_count++; + reassembly.last_activity = now; + + { + char buf[64]; + snprintf(buf, sizeof(buf), "BLEReassembler: Received fragment %u/%u", sequence + 1, reassembly.total_fragments); + TRACE(buf); + } + + // Check if complete + if (reassembly.received_count == reassembly.total_fragments) { + // Assemble complete packet + Bytes complete_packet = assembleFragments(reassembly); + + { + char buf[64]; + snprintf(buf, sizeof(buf), "BLEReassembler: Completed reassembly, %zu bytes", complete_packet.size()); + TRACE(buf); + } + + // Remove from pending before callback (callback might trigger new data) + Bytes identity_copy = reassembly.peer_identity; + slot->clear(); + + // Invoke callback + if (_reassembly_callback) { + _reassembly_callback(identity_copy, complete_packet); + } + } + + return true; +} + +void BLEReassembler::checkTimeouts() { + double now = Utilities::OS::time(); + + // Find and clean up expired reassemblies + for (size_t i = 0; i < MAX_PENDING_REASSEMBLIES; i++) { + if (!_pending_pool[i].in_use) { + continue; + } + + PendingReassembly& reassembly = _pending_pool[i].reassembly; + double age = now - reassembly.started_at; + + if (age > _timeout_seconds) { + { + char buf[80]; + snprintf(buf, sizeof(buf), "BLEReassembler: Timeout waiting for fragments, received %u/%u", + reassembly.received_count, reassembly.total_fragments); + WARNING(buf); + } + + // Copy identity before clearing + Bytes peer_identity = _pending_pool[i].transfer_id; + + // Clear the slot + _pending_pool[i].clear(); + + // Invoke timeout callback after clearing (callback might start new reassembly) + if (_timeout_callback) { + _timeout_callback(peer_identity, "Reassembly timeout"); + } + } + } +} + +void BLEReassembler::clearForPeer(const Bytes& peer_identity) { + PendingReassemblySlot* slot = findSlot(peer_identity); + if (slot) { + TRACE("BLEReassembler: Clearing pending reassembly for peer"); + slot->clear(); + } +} + +void BLEReassembler::clearAll() { + char buf[64]; + snprintf(buf, sizeof(buf), "BLEReassembler: Clearing all pending reassemblies (%zu sessions)", pendingCount()); + TRACE(buf); + for (size_t i = 0; i < MAX_PENDING_REASSEMBLIES; i++) { + _pending_pool[i].clear(); + } +} + +bool BLEReassembler::hasPending(const Bytes& peer_identity) const { + return findSlot(peer_identity) != nullptr; +} + +bool BLEReassembler::startReassembly(const Bytes& peer_identity, uint16_t total_fragments) { + // Validate fragment count fits in fixed-size array + if (total_fragments > MAX_FRAGMENTS_PER_REASSEMBLY) { + char buf[80]; + snprintf(buf, sizeof(buf), "BLEReassembler: Too many fragments: %u > %zu", + total_fragments, MAX_FRAGMENTS_PER_REASSEMBLY); + WARNING(buf); + return false; + } + + // Allocate a slot (reuses existing or finds free) + PendingReassemblySlot* slot = allocateSlot(peer_identity); + if (!slot) { + WARNING("BLEReassembler: Pool full, cannot start new reassembly"); + return false; + } + + double now = Utilities::OS::time(); + + // Initialize the reassembly state + PendingReassembly& reassembly = slot->reassembly; + reassembly.clear(); // Clear any old data + reassembly.peer_identity = peer_identity; + reassembly.total_fragments = total_fragments; + reassembly.received_count = 0; + reassembly.started_at = now; + reassembly.last_activity = now; + + char buf[64]; + snprintf(buf, sizeof(buf), "BLEReassembler: Starting reassembly for %u fragments", total_fragments); + TRACE(buf); + return true; +} + +Bytes BLEReassembler::assembleFragments(const PendingReassembly& reassembly) { + // Calculate total size + size_t total_size = 0; + for (size_t i = 0; i < reassembly.total_fragments; i++) { + total_size += reassembly.fragments[i].data_size; + } + + // Allocate result buffer + Bytes result(total_size); + uint8_t* ptr = result.writable(total_size); + result.resize(total_size); + + // Concatenate fragments in order + size_t offset = 0; + for (size_t i = 0; i < reassembly.total_fragments; i++) { + const FragmentInfo& frag = reassembly.fragments[i]; + if (frag.data_size > 0) { + memcpy(ptr + offset, frag.data, frag.data_size); + offset += frag.data_size; + } + } + + return result; +} + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLEReassembler.h b/lib/ble_interface/BLEReassembler.h new file mode 100644 index 0000000..eadce30 --- /dev/null +++ b/lib/ble_interface/BLEReassembler.h @@ -0,0 +1,199 @@ +/** + * @file BLEReassembler.h + * @brief BLE-Reticulum Protocol v2.2 fragment reassembler + * + * Reassembles incoming BLE fragments into complete Reticulum packets. + * Handles timeout for incomplete reassemblies and per-peer tracking. + * This class has no BLE dependencies and can be used for testing on native builds. + * + * The reassembler is keyed by peer identity (16 bytes), not MAC address, + * to survive BLE MAC address rotation. + * + * Uses fixed-size pools instead of STL containers to eliminate heap fragmentation + * on embedded systems. + */ +#pragma once + +#include "BLETypes.h" +#include "BLEFragmenter.h" +#include "Bytes.h" +#include "Utilities/OS.h" + +#include +#include +#include + +namespace RNS { namespace BLE { + +// Pool sizing constants for fixed-size allocations +static constexpr size_t MAX_PENDING_REASSEMBLIES = 8; +static constexpr size_t MAX_FRAGMENTS_PER_REASSEMBLY = 32; +static constexpr size_t MAX_FRAGMENT_PAYLOAD_SIZE = 512; + +class BLEReassembler { +public: + /** + * @brief Callback for successfully reassembled packets + * @param peer_identity The 16-byte identity of the sending peer + * @param packet The complete reassembled packet + */ + using ReassemblyCallback = std::function; + + /** + * @brief Callback for reassembly timeout/failure + * @param peer_identity The 16-byte identity of the peer + * @param reason Description of the failure + */ + using TimeoutCallback = std::function; + +public: + /** + * @brief Construct a reassembler with default timeout + */ + BLEReassembler(); + + /** + * @brief Set callback for successfully reassembled packets + */ + void setReassemblyCallback(ReassemblyCallback callback); + + /** + * @brief Set callback for reassembly timeouts/failures + */ + void setTimeoutCallback(TimeoutCallback callback); + + /** + * @brief Set the reassembly timeout + * @param timeout_seconds Seconds to wait before timing out incomplete reassembly + */ + void setTimeout(double timeout_seconds); + + /** + * @brief Process an incoming fragment + * + * @param peer_identity The 16-byte identity of the sending peer + * @param fragment The received fragment with header + * @return true if fragment was processed successfully, false on error + * + * When a packet is fully reassembled, the reassembly callback is invoked. + */ + bool processFragment(const Bytes& peer_identity, const Bytes& fragment); + + /** + * @brief Check for timed-out reassemblies and clean them up + * + * Should be called periodically from the interface loop(). + * Invokes timeout callback for each expired reassembly. + */ + void checkTimeouts(); + + /** + * @brief Get count of pending (incomplete) reassemblies + */ + size_t pendingCount() const; + + /** + * @brief Clear all pending reassemblies for a specific peer + * @param peer_identity Clear only for this peer + */ + void clearForPeer(const Bytes& peer_identity); + + /** + * @brief Clear all pending reassemblies + */ + void clearAll(); + + /** + * @brief Check if there's a pending reassembly for a peer + */ + bool hasPending(const Bytes& peer_identity) const; + +private: + /** + * @brief Information about a single received fragment (fixed-size) + */ + struct FragmentInfo { + uint8_t data[MAX_FRAGMENT_PAYLOAD_SIZE]; + size_t data_size = 0; + bool received = false; + + void clear() { + data_size = 0; + received = false; + } + }; + + /** + * @brief State for a pending (incomplete) reassembly (fixed-size) + */ + struct PendingReassembly { + Bytes peer_identity; + uint16_t total_fragments = 0; + uint16_t received_count = 0; + FragmentInfo fragments[MAX_FRAGMENTS_PER_REASSEMBLY]; + double started_at = 0.0; + double last_activity = 0.0; + + void clear() { + peer_identity = Bytes(); + total_fragments = 0; + received_count = 0; + for (size_t i = 0; i < MAX_FRAGMENTS_PER_REASSEMBLY; i++) { + fragments[i].clear(); + } + started_at = 0.0; + last_activity = 0.0; + } + }; + + /** + * @brief Slot in the fixed-size pool for pending reassemblies + */ + struct PendingReassemblySlot { + bool in_use = false; + Bytes transfer_id; // key (peer_identity) + PendingReassembly reassembly; + + void clear() { + in_use = false; + transfer_id = Bytes(); + reassembly.clear(); + } + }; + + /** + * @brief Find a slot by peer identity + * @return Pointer to slot or nullptr if not found + */ + PendingReassemblySlot* findSlot(const Bytes& peer_identity); + const PendingReassemblySlot* findSlot(const Bytes& peer_identity) const; + + /** + * @brief Allocate a new slot for a peer + * @return Pointer to slot or nullptr if pool is full + */ + PendingReassemblySlot* allocateSlot(const Bytes& peer_identity); + + /** + * @brief Concatenate all fragments in order to produce the complete packet + */ + Bytes assembleFragments(const PendingReassembly& reassembly); + + /** + * @brief Start a new reassembly session + * @return true if started, false if pool is full or too many fragments + */ + bool startReassembly(const Bytes& peer_identity, uint16_t total_fragments); + + // Fixed-size pool of pending reassemblies + PendingReassemblySlot _pending_pool[MAX_PENDING_REASSEMBLIES]; + + // Callbacks + ReassemblyCallback _reassembly_callback = nullptr; + TimeoutCallback _timeout_callback = nullptr; + + // Timeout configuration + double _timeout_seconds = Timing::REASSEMBLY_TIMEOUT; +}; + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/BLETypes.h b/lib/ble_interface/BLETypes.h new file mode 100644 index 0000000..14ade1b --- /dev/null +++ b/lib/ble_interface/BLETypes.h @@ -0,0 +1,502 @@ +/** + * @file BLETypes.h + * @brief BLE-Reticulum Protocol v2.2 types, constants, and common structures + * + * This file defines the core types used throughout the BLE interface implementation. + * It includes GATT service/characteristic UUIDs, protocol constants, enumerations, + * and data structures used by all BLE components. + */ +#pragma once + +#include "Bytes.h" +#include "Log.h" + +#include +#include +#include +#include +#include + +namespace RNS { namespace BLE { + +//============================================================================= +// Protocol Version +//============================================================================= + +static constexpr uint8_t PROTOCOL_VERSION_MAJOR = 2; +static constexpr uint8_t PROTOCOL_VERSION_MINOR = 2; + +//============================================================================= +// GATT Service and Characteristic UUIDs (BLE-Reticulum v2.2) +//============================================================================= + +namespace UUID { + // Reticulum BLE Service UUID + static constexpr const char* SERVICE = "37145b00-442d-4a94-917f-8f42c5da28e3"; + + // TX Characteristic (notify) - Data from peripheral to central + static constexpr const char* TX_CHAR = "37145b00-442d-4a94-917f-8f42c5da28e4"; + + // RX Characteristic (write) - Data from central to peripheral + static constexpr const char* RX_CHAR = "37145b00-442d-4a94-917f-8f42c5da28e5"; + + // Identity Characteristic (read) - 16-byte identity hash + static constexpr const char* IDENTITY_CHAR = "37145b00-442d-4a94-917f-8f42c5da28e6"; + + // Standard CCCD UUID for enabling notifications + static constexpr const char* CCCD = "00002902-0000-1000-8000-00805f9b34fb"; +} + +//============================================================================= +// MTU Constants +//============================================================================= + +namespace MTU { + static constexpr uint16_t REQUESTED = 517; // Request maximum MTU (BLE 5.0) + static constexpr uint16_t MINIMUM = 23; // BLE 4.0 minimum MTU + static constexpr uint16_t INITIAL = 185; // Conservative default (BLE 4.2) + static constexpr uint16_t ATT_OVERHEAD = 3; // ATT protocol header overhead +} + +//============================================================================= +// Protocol Timing Constants (v2.2 spec) +//============================================================================= + +namespace Timing { + static constexpr double KEEPALIVE_INTERVAL = 15.0; // Seconds between keepalives + static constexpr double REASSEMBLY_TIMEOUT = 30.0; // Seconds to complete reassembly + static constexpr double CONNECTION_TIMEOUT = 30.0; // Seconds to establish connection + static constexpr double HANDSHAKE_TIMEOUT = 10.0; // Seconds for identity exchange + static constexpr double SCAN_INTERVAL = 5.0; // Seconds between scans + static constexpr double PEER_TIMEOUT = 30.0; // Seconds before peer removal + static constexpr double POST_MTU_DELAY = 0.15; // Seconds after MTU negotiation + static constexpr double BLACKLIST_BASE_BACKOFF = 60.0; // Base backoff seconds +} + +//============================================================================= +// Protocol Limits +//============================================================================= + +namespace Limits { +#ifdef ARDUINO + static constexpr size_t MAX_PEERS = 3; // Reduced for MCU memory constraints + static constexpr size_t MAX_DISCOVERED_PEERS = 16; // Reduced discovery cache for MCU +#else + static constexpr size_t MAX_PEERS = 7; // Maximum simultaneous connections + static constexpr size_t MAX_DISCOVERED_PEERS = 100; // Discovery cache limit +#endif + static constexpr size_t IDENTITY_SIZE = 16; // Identity hash size (bytes) + static constexpr size_t MAC_SIZE = 6; // BLE MAC address size (bytes) + static constexpr uint8_t BLACKLIST_THRESHOLD = 3; // Failures before blacklist + static constexpr uint8_t BLACKLIST_MAX_MULTIPLIER = 8; // Max 2^n backoff multiplier +} + +//============================================================================= +// Peer Scoring Weights (v2.2 spec) +//============================================================================= + +namespace Scoring { + static constexpr float RSSI_WEIGHT = 0.60f; + static constexpr float HISTORY_WEIGHT = 0.30f; + static constexpr float RECENCY_WEIGHT = 0.10f; + static constexpr int8_t RSSI_MIN = -100; // dBm + static constexpr int8_t RSSI_MAX = -30; // dBm +} + +//============================================================================= +// Fragment Header Constants +//============================================================================= + +namespace Fragment { + static constexpr size_t HEADER_SIZE = 5; + + enum Type : uint8_t { + START = 0x01, // First fragment of multi-fragment message + CONTINUE = 0x02, // Middle fragment + END = 0x03 // Last fragment (or single fragment) + }; +} + +//============================================================================= +// Enumerations +//============================================================================= + +/** + * @brief Platform type for compile-time selection + */ +enum class PlatformType { + NONE, + NIMBLE_ARDUINO, // ESP32 with NimBLE-Arduino library + ESP_IDF, // ESP32 with ESP-IDF native BLE + ZEPHYR, // nRF52840 with Zephyr RTOS + NORDIC_SDK // nRF52840 with Nordic SDK (future) +}; + +/** + * @brief BLE role in a connection + */ +enum class Role : uint8_t { + NONE = 0x00, + CENTRAL = 0x01, // Initiates connections (GATT client) + PERIPHERAL = 0x02, // Accepts connections (GATT server) + DUAL = 0x03 // Both central and peripheral simultaneously +}; + +/** + * @brief Connection state machine states + */ +enum class ConnectionState : uint8_t { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCOVERING_SERVICES, + READY, // Fully connected, services discovered, handshake complete + DISCONNECTING +}; + +/** + * @brief Peer state for tracking + */ +enum class PeerState : uint8_t { + DISCOVERED, // Seen in scan, not connected + CONNECTING, // Connection in progress + HANDSHAKING, // Connected, awaiting identity exchange + CONNECTED, // Fully connected with identity + DISCONNECTING, // Disconnect in progress + BLACKLISTED // Temporarily blacklisted +}; + +/** + * @brief GATT operation types for queuing + */ +enum class OperationType : uint8_t { + READ, + WRITE, + WRITE_NO_RESPONSE, + NOTIFY_ENABLE, + NOTIFY_DISABLE, + MTU_REQUEST +}; + +/** + * @brief GATT operation result codes + */ +enum class OperationResult : uint8_t { + SUCCESS, + PENDING, + TIMEOUT, + DISCONNECTED, + NOT_FOUND, + NOT_SUPPORTED, + INVALID_HANDLE, + INSUFFICIENT_AUTH, + INSUFFICIENT_ENC, + BUSY, + ERROR +}; + +/** + * @brief Scan mode + */ +enum class ScanMode : uint8_t { + PASSIVE, + ACTIVE +}; + +//============================================================================= +// Data Structures +//============================================================================= + +/** + * @brief BLE address (6 bytes + type) + */ +struct BLEAddress { + uint8_t addr[6] = {0}; + uint8_t type = 0; // 0 = public, 1 = random + + BLEAddress() = default; + + BLEAddress(const uint8_t* address, uint8_t addr_type = 0) : type(addr_type) { + if (address) { + memcpy(addr, address, 6); + } + } + + bool operator==(const BLEAddress& other) const { + return memcmp(addr, other.addr, 6) == 0 && type == other.type; + } + + bool operator!=(const BLEAddress& other) const { + return !(*this == other); + } + + bool operator<(const BLEAddress& other) const { + int cmp = memcmp(addr, other.addr, 6); + if (cmp != 0) return cmp < 0; + return type < other.type; + } + + /** + * @brief Check if this address is "lower" than another (for MAC sorting) + * + * The device with the lower MAC address should initiate the connection + * as the central role. This provides deterministic connection direction. + */ + bool isLowerThan(const BLEAddress& other) const { + // Compare as 48-bit integers, MSB first (addr[0] is most significant) + for (int i = 0; i < 6; i++) { + if (addr[i] < other.addr[i]) return true; + if (addr[i] > other.addr[i]) return false; + } + return false; // Equal + } + + /** + * @brief Convert to colon-separated hex string (XX:XX:XX:XX:XX:XX) + * addr[0] is MSB (first displayed), addr[5] is LSB (last displayed) + */ + std::string toString() const { + char buf[18]; + snprintf(buf, sizeof(buf), "%02X:%02X:%02X:%02X:%02X:%02X", + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]); + return std::string(buf); + } + + /** + * @brief Parse from colon-separated hex string + * First byte in string goes to addr[0] (MSB) + */ + static BLEAddress fromString(const std::string& str) { + BLEAddress result; + if (str.length() >= 17) { + unsigned int values[6]; + if (sscanf(str.c_str(), "%02X:%02X:%02X:%02X:%02X:%02X", + &values[0], &values[1], &values[2], + &values[3], &values[4], &values[5]) == 6) { + for (int i = 0; i < 6; i++) { + result.addr[i] = static_cast(values[i]); + } + } + } + return result; + } + + /** + * @brief Convert to Bytes for storage/comparison + */ + Bytes toBytes() const { + return Bytes(addr, 6); + } + + /** + * @brief Check if address is all zeros (invalid) + */ + bool isZero() const { + for (int i = 0; i < 6; i++) { + if (addr[i] != 0) return false; + } + return true; + } +}; + +/** + * @brief Scan result from BLE discovery + */ +struct ScanResult { + BLEAddress address; + std::string name; + int8_t rssi = 0; + bool connectable = false; + Bytes advertising_data; + Bytes scan_response_data; + bool has_reticulum_service = false; // Pre-filtered for our service UUID + Bytes identity_prefix; // First 3 bytes of identity from "RNS-xxxxxx" name (Protocol v2.2) +}; + +/** + * @brief Connection handle with associated state + */ +struct ConnectionHandle { + uint16_t handle = 0xFFFF; // Platform-specific connection handle + BLEAddress peer_address; + Role local_role = Role::NONE; // Our role in this connection + ConnectionState state = ConnectionState::DISCONNECTED; + uint16_t mtu = MTU::MINIMUM; // Negotiated MTU + + // Characteristic handles (discovered after connection) + uint16_t rx_char_handle = 0; // Handle for RX characteristic + uint16_t tx_char_handle = 0; // Handle for TX characteristic + uint16_t tx_cccd_handle = 0; // Handle for TX CCCD (notifications) + uint16_t identity_handle = 0; // Handle for Identity characteristic + + bool isValid() const { return handle != 0xFFFF; } + + void reset() { + handle = 0xFFFF; + peer_address = BLEAddress(); + local_role = Role::NONE; + state = ConnectionState::DISCONNECTED; + mtu = MTU::MINIMUM; + rx_char_handle = 0; + tx_char_handle = 0; + tx_cccd_handle = 0; + identity_handle = 0; + } +}; + +/** + * @brief GATT operation for queuing + */ +struct GATTOperation { + OperationType type = OperationType::READ; + uint16_t conn_handle = 0xFFFF; + uint16_t char_handle = 0; + Bytes data; // For writes + uint32_t timeout_ms = 5000; + + // Completion callback + std::function callback; + + // Internal tracking + double queued_at = 0; + double started_at = 0; +}; + +/** + * @brief Platform configuration + */ +struct PlatformConfig { + Role role = Role::DUAL; + + // Advertising parameters (peripheral mode) + uint16_t adv_interval_min_ms = 100; + uint16_t adv_interval_max_ms = 200; + std::string device_name = "RNS-Node"; + + // Scan parameters (central mode) + // WiFi/BLE Coexistence: With software coexistence, scan interval must not exceed 160ms. + // Use lower duty cycle (25%) to give WiFi more RF access time. + // Passive scanning reduces TX interference with WiFi. + uint16_t scan_interval_ms = 120; // 120ms interval (within 160ms coex limit) + uint16_t scan_window_ms = 30; // 30ms window (25% duty cycle for WiFi breathing room) + ScanMode scan_mode = ScanMode::PASSIVE; // Passive scan reduces RF contention + uint16_t scan_duration_ms = 10000; // 0 = continuous + + // Connection parameters + uint16_t conn_interval_min_ms = 15; + uint16_t conn_interval_max_ms = 30; + uint16_t conn_latency = 0; + uint16_t supervision_timeout_ms = 4000; + + // MTU + uint16_t preferred_mtu = MTU::REQUESTED; + + // Limits + uint8_t max_connections = Limits::MAX_PEERS; +}; + +//============================================================================= +// Callback Type Definitions +//============================================================================= + +namespace Callbacks { + // Scan callbacks + using OnScanResult = std::function; + using OnScanComplete = std::function; + + // Connection callbacks (central mode - we initiated) + using OnConnected = std::function; + using OnDisconnected = std::function; + using OnMTUChanged = std::function; + using OnServicesDiscovered = std::function; + + // Data callbacks + using OnDataReceived = std::function; + using OnNotifyEnabled = std::function; + + // Peripheral-mode callbacks (they connected to us) + using OnCentralConnected = std::function; + using OnCentralDisconnected = std::function; + using OnWriteReceived = std::function; + using OnReadRequested = std::function; +} + +//============================================================================= +// Utility Functions +//============================================================================= + +/** + * @brief Get the payload size for a given MTU + * @param mtu The negotiated MTU + * @return Maximum payload size per fragment + */ +inline size_t getPayloadSize(uint16_t mtu) { + return (mtu > Fragment::HEADER_SIZE) ? (mtu - Fragment::HEADER_SIZE) : 0; +} + +/** + * @brief Check if a packet needs fragmentation + * @param data_size Size of the data to send + * @param mtu The negotiated MTU + * @return true if fragmentation is required + */ +inline bool needsFragmentation(size_t data_size, uint16_t mtu) { + return data_size > getPayloadSize(mtu); +} + +/** + * @brief Calculate number of fragments needed + * @param data_size Size of the data to fragment + * @param mtu The negotiated MTU + * @return Number of fragments needed (minimum 1) + */ +inline uint16_t calculateFragmentCount(size_t data_size, uint16_t mtu) { + size_t payload_size = getPayloadSize(mtu); + if (payload_size == 0) return 0; + return static_cast((data_size + payload_size - 1) / payload_size); +} + +/** + * @brief Convert Role to string for logging + */ +inline const char* roleToString(Role role) { + switch (role) { + case Role::NONE: return "NONE"; + case Role::CENTRAL: return "CENTRAL"; + case Role::PERIPHERAL: return "PERIPHERAL"; + case Role::DUAL: return "DUAL"; + default: return "UNKNOWN"; + } +} + +/** + * @brief Convert ConnectionState to string for logging + */ +inline const char* stateToString(ConnectionState state) { + switch (state) { + case ConnectionState::DISCONNECTED: return "DISCONNECTED"; + case ConnectionState::CONNECTING: return "CONNECTING"; + case ConnectionState::CONNECTED: return "CONNECTED"; + case ConnectionState::DISCOVERING_SERVICES: return "DISCOVERING_SERVICES"; + case ConnectionState::READY: return "READY"; + case ConnectionState::DISCONNECTING: return "DISCONNECTING"; + default: return "UNKNOWN"; + } +} + +/** + * @brief Convert PeerState to string for logging + */ +inline const char* peerStateToString(PeerState state) { + switch (state) { + case PeerState::DISCOVERED: return "DISCOVERED"; + case PeerState::CONNECTING: return "CONNECTING"; + case PeerState::HANDSHAKING: return "HANDSHAKING"; + case PeerState::CONNECTED: return "CONNECTED"; + case PeerState::DISCONNECTING: return "DISCONNECTING"; + case PeerState::BLACKLISTED: return "BLACKLISTED"; + default: return "UNKNOWN"; + } +} + +}} // namespace RNS::BLE diff --git a/lib/ble_interface/library.json b/lib/ble_interface/library.json new file mode 100644 index 0000000..d471cb8 --- /dev/null +++ b/lib/ble_interface/library.json @@ -0,0 +1,15 @@ +{ + "name": "ble_interface", + "version": "0.1.0", + "description": "BLE-Reticulum Protocol v2.2 interface for microReticulum", + "keywords": "ble, reticulum, mesh", + "license": "MIT", + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "dependencies": { + "microReticulum": "*" + }, + "build": { + "flags": "-std=gnu++11 -I../../../../deps/microReticulum/src -I../../../../deps/microReticulum/src/BLE" + } +} diff --git a/lib/ble_interface/platforms/BluedroidPlatform.cpp b/lib/ble_interface/platforms/BluedroidPlatform.cpp new file mode 100644 index 0000000..08219b8 --- /dev/null +++ b/lib/ble_interface/platforms/BluedroidPlatform.cpp @@ -0,0 +1,1963 @@ +/** + * @file BluedroidPlatform.cpp + * @brief ESP-IDF Bluedroid implementation of IBLEPlatform for ESP32 + */ + +#include "BluedroidPlatform.h" + +#if defined(ESP32) && defined(USE_BLUEDROID) + +#include "Log.h" +#include "../BLETypes.h" + +#include +#include + +namespace RNS { namespace BLE { + +// Static singleton instance for callback routing +BluedroidPlatform* BluedroidPlatform::_instance = nullptr; + +//============================================================================= +// Constructor / Destructor +//============================================================================= + +BluedroidPlatform::BluedroidPlatform() { + DEBUG("BluedroidPlatform: Constructor"); + if (_instance != nullptr) { + WARNING("BluedroidPlatform: Another instance exists - callbacks may misbehave"); + } + _instance = this; +} + +BluedroidPlatform::~BluedroidPlatform() { + DEBUG("BluedroidPlatform: Destructor"); + shutdown(); + if (_instance == this) { + _instance = nullptr; + } +} + +//============================================================================= +// Static Callback Handlers (route to instance methods) +//============================================================================= + +void BluedroidPlatform::gapEventHandler(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t* param) { + if (_instance) { + _instance->handleGapEvent(event, param); + } +} + +void BluedroidPlatform::gattsEventHandler(esp_gatts_cb_event_t event, + esp_gatt_if_t gatts_if, + esp_ble_gatts_cb_param_t* param) { + if (_instance) { + _instance->handleGattsEvent(event, gatts_if, param); + } +} + +void BluedroidPlatform::gattcEventHandler(esp_gattc_cb_event_t event, + esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t* param) { + if (_instance) { + _instance->handleGattcEvent(event, gattc_if, param); + } +} + +//============================================================================= +// Lifecycle +//============================================================================= + +bool BluedroidPlatform::initialize(const PlatformConfig& config) { + if (_initialized) { + WARNING("BluedroidPlatform: Already initialized"); + return true; + } + + INFO("BluedroidPlatform: Initializing Bluedroid BLE stack..."); + _config = config; + + if (!initBluetooth()) { + ERROR("BluedroidPlatform: Failed to initialize Bluetooth"); + return false; + } + + _initialized = true; + INFO("BluedroidPlatform: Initialization complete"); + return true; +} + +bool BluedroidPlatform::initBluetooth() { + esp_err_t ret; + + // Release classic BT memory if not needed (saves ~65KB) + ret = esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + WARNING("BluedroidPlatform: Could not release classic BT memory: " + + std::to_string(ret)); + } + + // Initialize BT controller + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + ret = esp_bt_controller_init(&bt_cfg); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Controller init failed: " + std::to_string(ret)); + return false; + } + _init_state = InitState::CONTROLLER_INIT; + + // Enable BT controller in BLE-only mode + ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Controller enable failed: " + std::to_string(ret)); + return false; + } + + // Initialize Bluedroid + ret = esp_bluedroid_init(); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Bluedroid init failed: " + std::to_string(ret)); + return false; + } + _init_state = InitState::BLUEDROID_INIT; + + // Enable Bluedroid + ret = esp_bluedroid_enable(); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Bluedroid enable failed: " + std::to_string(ret)); + return false; + } + + // Register callbacks + ret = esp_ble_gap_register_callback(gapEventHandler); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: GAP callback register failed: " + std::to_string(ret)); + return false; + } + + ret = esp_ble_gatts_register_callback(gattsEventHandler); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: GATTS callback register failed: " + std::to_string(ret)); + return false; + } + + ret = esp_ble_gattc_register_callback(gattcEventHandler); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: GATTC callback register failed: " + std::to_string(ret)); + return false; + } + _init_state = InitState::CALLBACKS_REGISTERED; + + // Set device name + ret = esp_ble_gap_set_device_name(_config.device_name.c_str()); + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Set device name failed: " + std::to_string(ret)); + } + + // Set local MTU + ret = esp_ble_gatt_set_local_mtu(_config.preferred_mtu); + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Set local MTU failed: " + std::to_string(ret)); + } + + // Register GATTS app for peripheral mode + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + ret = esp_ble_gatts_app_register(GATTS_APP_ID); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: GATTS app register failed: " + std::to_string(ret)); + return false; + } + _init_state = InitState::GATTS_REGISTERING; + DEBUG("BluedroidPlatform: GATTS app registration pending..."); + } + + // Register GATTC app for central mode + if (_config.role == Role::CENTRAL || _config.role == Role::DUAL) { + ret = esp_ble_gattc_app_register(GATTC_APP_ID); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: GATTC app register failed: " + std::to_string(ret)); + return false; + } + DEBUG("BluedroidPlatform: GATTC app registration pending..."); + } + + return true; +} + +bool BluedroidPlatform::start() { + if (!_initialized) { + ERROR("BluedroidPlatform: Cannot start - not initialized"); + return false; + } + + if (_running) { + return true; + } + + INFO("BluedroidPlatform: Starting BLE operations"); + + // Reset state to ensure clean startup + _connect_pending = false; + _connect_success = false; + _connect_error = 0; + _connect_start_time = 0; + _connect_timeout_ms = 10000; + _discovery_in_progress = 0xFFFF; + while (!_pending_discoveries.empty()) { + _pending_discoveries.pop(); + } + + // Start advertising if in peripheral or dual mode + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + if (_init_state == InitState::READY) { + startAdvertising(); + } else { + DEBUG("BluedroidPlatform: Waiting for GATTS service ready before advertising"); + } + } + + _running = true; + return true; +} + +void BluedroidPlatform::stop() { + if (!_running) return; + + INFO("BluedroidPlatform: Stopping BLE operations"); + + stopScan(); + stopAdvertising(); + disconnectAll(); + + _running = false; +} + +void BluedroidPlatform::loop() { + if (!_running) return; + + // Process operation queue + process(); + + // Check scan timeout + if (_scan_state == ScanState::ACTIVE && _scan_duration_ms > 0) { + if (millis() - _scan_start_time >= _scan_duration_ms) { + stopScan(); + } + } + + // Check connection timeout - allows retrying other peers while BLE stack + // continues its internal connection attempt (which can take 30+ seconds) + if (_connect_pending && _connect_timeout_ms > 0) { + if (millis() - _connect_start_time >= _connect_timeout_ms) { + WARNING("BluedroidPlatform: Connection timed out to " + _pending_connect_address.toString()); + _connect_pending = false; + // Note: The BLE stack may still complete the connection later, + // which will be handled normally in handleGattcConnect() + } + } +} + +void BluedroidPlatform::shutdown() { + INFO("BluedroidPlatform: Shutting down"); + + stop(); + + if (_initialized) { + esp_bluedroid_disable(); + esp_bluedroid_deinit(); + esp_bt_controller_disable(); + esp_bt_controller_deinit(); + } + + _initialized = false; + _init_state = InitState::UNINITIALIZED; + _gatts_if = ESP_GATT_IF_NONE; + _gattc_if = ESP_GATT_IF_NONE; +} + +bool BluedroidPlatform::isRunning() const { + return _running && _init_state == InitState::READY; +} + +//============================================================================= +// Scanning (Central Mode) +//============================================================================= + +bool BluedroidPlatform::startScan(uint16_t duration_ms) { + if (_scan_state != ScanState::IDLE) { + DEBUG("BluedroidPlatform: Scan already in progress"); + return false; + } + + // Skip scanning if we've reached connection limit - saves memory from scan results + if (getConnectionCount() >= _config.max_connections) { + TRACE("BluedroidPlatform: Skipping scan - at max connections (" + + std::to_string(getConnectionCount()) + "/" + + std::to_string(_config.max_connections) + ")"); + return false; + } + + // In dual mode, stop advertising before scanning + if (_adv_state == AdvState::ACTIVE) { + DEBUG("BluedroidPlatform: Stopping advertising for scan"); + stopAdvertising(); + taskYIELD(); // Allow stack to process + } + + esp_ble_scan_params_t scan_params = { + .scan_type = (_config.scan_mode == ScanMode::ACTIVE) ? + BLE_SCAN_TYPE_ACTIVE : BLE_SCAN_TYPE_PASSIVE, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, // Use public address (chip MAC) + .scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL, + .scan_interval = static_cast((_config.scan_interval_ms * 1000) / 625), + .scan_window = static_cast((_config.scan_window_ms * 1000) / 625), + .scan_duplicate = BLE_SCAN_DUPLICATE_ENABLE + }; + + esp_err_t ret = esp_ble_gap_set_scan_params(&scan_params); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Set scan params failed: " + std::to_string(ret)); + return false; + } + + _scan_duration_ms = duration_ms; + _scan_state = ScanState::SETTING_PARAMS; + DEBUG("BluedroidPlatform: Scan params set, waiting for confirmation..."); + + return true; +} + +void BluedroidPlatform::stopScan() { + if (_scan_state == ScanState::IDLE) return; + + esp_err_t ret = esp_ble_gap_stop_scanning(); + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Stop scan failed: " + std::to_string(ret)); + } + _scan_state = ScanState::STOPPING; +} + +bool BluedroidPlatform::isScanning() const { + return _scan_state == ScanState::ACTIVE; +} + +//============================================================================= +// Advertising (Peripheral Mode) +//============================================================================= + +bool BluedroidPlatform::startAdvertising() { + if (_adv_state != AdvState::IDLE) { + DEBUG("BluedroidPlatform: Advertising already active or starting"); + return false; + } + + if (_init_state != InitState::READY) { + WARNING("BluedroidPlatform: Cannot advertise - GATTS not ready"); + return false; + } + + // Skip advertising if at max connections - no point accepting more + if (getConnectionCount() >= _config.max_connections) { + TRACE("BluedroidPlatform: Skipping advertising - at max connections (" + + std::to_string(getConnectionCount()) + "/" + + std::to_string(_config.max_connections) + ")"); + return false; + } + + DEBUG("BluedroidPlatform: Starting advertising"); + buildAdvertisingData(); + _adv_state = AdvState::CONFIGURING_DATA; + + return true; +} + +void BluedroidPlatform::buildAdvertisingData() { + // Build advertising data with service UUID + esp_ble_adv_data_t adv_data = { + .set_scan_rsp = false, + .include_name = true, + .include_txpower = true, + .min_interval = static_cast((_config.adv_interval_min_ms * 1000) / 625), + .max_interval = static_cast((_config.adv_interval_max_ms * 1000) / 625), + .appearance = 0x0000, + .manufacturer_len = 0, + .p_manufacturer_data = nullptr, + .service_data_len = 0, + .p_service_data = nullptr, + .service_uuid_len = 16, + .p_service_uuid = nullptr, // Will set below + .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT) + }; + + // Parse service UUID from string to bytes + static uint8_t service_uuid_bytes[16]; + // UUID::SERVICE = "37145b00-442d-4a94-917f-8f42c5da28e3" + // UUID is stored in little-endian in advertising data + const char* uuid_str = UUID::SERVICE; + int idx = 15; // Start from end (little-endian) + for (int i = 0; i < 36 && idx >= 0; i++) { + if (uuid_str[i] == '-') continue; + unsigned int byte_val; + sscanf(&uuid_str[i], "%02x", &byte_val); + service_uuid_bytes[idx--] = static_cast(byte_val); + i++; // Skip second hex digit + } + adv_data.p_service_uuid = service_uuid_bytes; + + esp_err_t ret = esp_ble_gap_config_adv_data(&adv_data); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Config adv data failed: " + std::to_string(ret)); + _adv_state = AdvState::IDLE; + } +} + +void BluedroidPlatform::buildScanResponseData() { + esp_ble_adv_data_t scan_rsp_data = { + .set_scan_rsp = true, + .include_name = true, + .include_txpower = false, + .min_interval = 0, + .max_interval = 0, + .appearance = 0x0000, + .manufacturer_len = 0, + .p_manufacturer_data = nullptr, + .service_data_len = 0, + .p_service_data = nullptr, + .service_uuid_len = 0, + .p_service_uuid = nullptr, + .flag = 0 + }; + + esp_err_t ret = esp_ble_gap_config_adv_data(&scan_rsp_data); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Config scan response failed: " + std::to_string(ret)); + _adv_state = AdvState::IDLE; + } +} + +void BluedroidPlatform::stopAdvertising() { + if (_adv_state == AdvState::IDLE) return; + + esp_err_t ret = esp_ble_gap_stop_advertising(); + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Stop advertising failed: " + std::to_string(ret)); + } + _adv_state = AdvState::STOPPING; +} + +bool BluedroidPlatform::isAdvertising() const { + return _adv_state == AdvState::ACTIVE; +} + +bool BluedroidPlatform::setAdvertisingData(const Bytes& data) { + _custom_adv_data = data; + return true; +} + +void BluedroidPlatform::setIdentityData(const Bytes& identity) { + _identity_data = identity; + DEBUG("BluedroidPlatform: Identity data set (" + std::to_string(identity.size()) + " bytes)"); +} + +//============================================================================= +// Connection Management +//============================================================================= + +bool BluedroidPlatform::connect(const BLEAddress& address, uint16_t timeout_ms) { + // Check connection limit first - don't connect if at max + if (getConnectionCount() >= _config.max_connections) { + TRACE("BluedroidPlatform: Skipping connect - at max connections (" + + std::to_string(getConnectionCount()) + "/" + + std::to_string(_config.max_connections) + ")"); + return false; + } + + // Protect against connecting when memory is critically low + if (ESP.getFreeHeap() < 40000) { + static uint32_t last_low_mem_warn = 0; + if (millis() - last_low_mem_warn > 10000) { + WARNING("BluedroidPlatform: Skipping connection - low memory (" + + std::to_string(ESP.getFreeHeap()) + " bytes free)"); + last_low_mem_warn = millis(); + } + return false; + } + + if (_gattc_if == ESP_GATT_IF_NONE) { + ERROR("BluedroidPlatform: GATTC not registered"); + return false; + } + + if (_connect_pending) { + WARNING("BluedroidPlatform: Connection already pending"); + return false; + } + + // Bluedroid can't handle connection while service discovery is in progress + // This causes hash_map_set assertion failures in btu_start_timer + if (_discovery_in_progress != 0xFFFF) { + WARNING("BluedroidPlatform: Cannot connect while discovery in progress for " + + std::to_string(_discovery_in_progress)); + return false; + } + + esp_bd_addr_t peer_addr; + toEspBdAddr(address, peer_addr); + + _connect_pending = true; + _connect_success = false; + _connect_error = 0; + _pending_connect_address = address; + _connect_start_time = millis(); + _connect_timeout_ms = timeout_ms; + + esp_err_t ret = esp_ble_gattc_open( + _gattc_if, + peer_addr, + static_cast(address.type), + true // Direct connection + ); + + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: GATTC open failed: " + std::to_string(ret)); + _connect_pending = false; + return false; + } + + DEBUG("BluedroidPlatform: Connection initiated to " + address.toString()); + return true; +} + +bool BluedroidPlatform::disconnect(uint16_t conn_handle) { + auto* conn = findConnection(conn_handle); + if (!conn) { + WARNING("BluedroidPlatform: Connection not found: " + std::to_string(conn_handle)); + return false; + } + + esp_err_t ret; + if (conn->local_role == Role::PERIPHERAL) { + ret = esp_ble_gatts_close(_gatts_if, conn->conn_id); + } else { + ret = esp_ble_gattc_close(_gattc_if, conn->conn_id); + } + + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Disconnect failed: " + std::to_string(ret)); + return false; + } + + return true; +} + +void BluedroidPlatform::disconnectAll() { + for (auto& pair : _connections) { + disconnect(pair.first); + } +} + +bool BluedroidPlatform::requestMTU(uint16_t conn_handle, uint16_t mtu) { + auto* conn = findConnection(conn_handle); + if (!conn) return false; + + if (conn->local_role == Role::CENTRAL) { + esp_err_t ret = esp_ble_gattc_send_mtu_req(_gattc_if, conn->conn_id); + return ret == ESP_OK; + } + + // Peripheral mode MTU is negotiated by central + return true; +} + +bool BluedroidPlatform::discoverServices(uint16_t conn_handle) { + DEBUG("BluedroidPlatform: discoverServices called for handle " + std::to_string(conn_handle)); + + auto* conn = findConnection(conn_handle); + if (!conn) { + ERROR("BluedroidPlatform: discoverServices - connection not found for handle " + std::to_string(conn_handle)); + return false; + } + + // Bluedroid can only handle one GATT operation at a time + // Queue if connection is pending or another discovery is in progress + if (_connect_pending || _discovery_in_progress != 0xFFFF) { + INFO("BluedroidPlatform: GATT busy (connect_pending=" + std::string(_connect_pending ? "yes" : "no") + + " discovery=" + std::to_string(_discovery_in_progress) + "), queueing " + std::to_string(conn_handle)); + _pending_discoveries.push(conn_handle); + return true; // Queued successfully + } + + DEBUG("BluedroidPlatform: Found connection, conn_id=" + std::to_string(conn->conn_id)); + + // Parse service UUID for filter + esp_bt_uuid_t service_uuid; + service_uuid.len = ESP_UUID_LEN_128; + // Convert UUID string to bytes (little-endian) + const char* uuid_str = UUID::SERVICE; + int idx = 15; + for (int i = 0; i < 36 && idx >= 0; i++) { + if (uuid_str[i] == '-') continue; + unsigned int byte_val; + sscanf(&uuid_str[i], "%02x", &byte_val); + service_uuid.uuid.uuid128[idx--] = static_cast(byte_val); + i++; + } + + _discovery_in_progress = conn_handle; + conn->discovery_state = BluedroidConnection::DiscoveryState::SEARCHING_SERVICE; + esp_err_t ret = esp_ble_gattc_search_service(_gattc_if, conn->conn_id, &service_uuid); + + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Service search failed: " + std::to_string(ret)); + _discovery_in_progress = 0xFFFF; + return false; + } + + DEBUG("BluedroidPlatform: Service search started for conn_id=" + std::to_string(conn->conn_id)); + return true; +} + +//============================================================================= +// GATT Operations +//============================================================================= + +bool BluedroidPlatform::write(uint16_t conn_handle, const Bytes& data, bool response) { + auto* conn = findConnection(conn_handle); + if (!conn) { + WARNING("BluedroidPlatform: write - connection not found for handle " + std::to_string(conn_handle)); + return false; + } + + if (conn->rx_char_handle == 0) { + ERROR("BluedroidPlatform: RX characteristic not discovered for conn " + std::to_string(conn_handle) + + " conn_id=" + std::to_string(conn->conn_id) + + " discovery_state=" + std::to_string(static_cast(conn->discovery_state))); + return false; + } + + // Hot path - no logging to avoid heap allocation + + esp_err_t ret = esp_ble_gattc_write_char( + _gattc_if, + conn->conn_id, + conn->rx_char_handle, + data.size(), + const_cast(data.data()), + response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE + ); + + // Yield to let BLE stack process + taskYIELD(); + + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Write failed with err " + std::to_string(ret)); + } + return ret == ESP_OK; +} + +bool BluedroidPlatform::read(uint16_t conn_handle, uint16_t char_handle, + std::function callback) { + auto* conn = findConnection(conn_handle); + if (!conn) { + if (callback) callback(OperationResult::DISCONNECTED, Bytes()); + return false; + } + + // Queue the operation + GATTOperation op; + op.type = OperationType::READ; + op.conn_handle = conn_handle; + op.char_handle = char_handle; + op.callback = callback; + enqueue(op); + + return true; +} + +bool BluedroidPlatform::enableNotifications(uint16_t conn_handle, bool enable) { + auto* conn = findConnection(conn_handle); + if (!conn) { + WARNING("BluedroidPlatform: enableNotifications - connection not found"); + return false; + } + + if (conn->tx_char_handle == 0) { + ERROR("BluedroidPlatform: TX char not discovered for conn " + std::to_string(conn_handle)); + return false; + } + + INFO("BluedroidPlatform: Enabling notifications for conn " + std::to_string(conn_handle) + + " conn_id=" + std::to_string(conn->conn_id) + " tx_char=" + std::to_string(conn->tx_char_handle)); + + // Step 1: Register for notifications with the Bluedroid stack + // This tells ESP-IDF to route ESP_GATTC_NOTIFY_EVT to our handler + esp_err_t ret = esp_ble_gattc_register_for_notify( + _gattc_if, + conn->peer_addr, + conn->tx_char_handle // The characteristic we want notifications from + ); + + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Failed to register for notify: " + std::to_string(ret)); + return false; + } + INFO("BluedroidPlatform: Registered for notify OK"); + + // Step 2: Write to CCCD to enable notifications on the peripheral + if (conn->tx_cccd_handle != 0) { + DEBUG("BluedroidPlatform: Enabling notifications on CCCD handle " + + std::to_string(conn->tx_cccd_handle) + " for conn " + std::to_string(conn_handle)); + + uint16_t cccd_value = enable ? 0x0001 : 0x0000; + ret = esp_ble_gattc_write_char_descr( + _gattc_if, + conn->conn_id, + conn->tx_cccd_handle, + sizeof(cccd_value), + reinterpret_cast(&cccd_value), + ESP_GATT_WRITE_TYPE_RSP, + ESP_GATT_AUTH_REQ_NONE + ); + } + + DEBUG("BluedroidPlatform: Notifications " + std::string(enable ? "enabled" : "disabled") + + " for conn " + std::to_string(conn_handle)); + + // Yield to let Bluedroid process + taskYIELD(); + + return ret == ESP_OK; +} + +bool BluedroidPlatform::notify(uint16_t conn_handle, const Bytes& data) { + if (_gatts_if == ESP_GATT_IF_NONE || _tx_char_handle == 0) { + return false; + } + + auto* conn = findConnection(conn_handle); + if (!conn) return false; + + esp_err_t ret = esp_ble_gatts_send_indicate( + _gatts_if, + conn->conn_id, + _tx_char_handle, + data.size(), + const_cast(data.data()), + false // false = notification, true = indication + ); + + return ret == ESP_OK; +} + +bool BluedroidPlatform::notifyAll(const Bytes& data) { + if (_gatts_if == ESP_GATT_IF_NONE || _tx_char_handle == 0) { + return false; + } + + bool any_sent = false; + for (auto& pair : _connections) { + if (pair.second.local_role == Role::PERIPHERAL && + pair.second.notifications_enabled) { + if (notify(pair.first, data)) { + any_sent = true; + } + } + } + + return any_sent; +} + +bool BluedroidPlatform::executeOperation(const GATTOperation& op) { + auto* conn = findConnection(op.conn_handle); + if (!conn) { + if (op.callback) op.callback(OperationResult::DISCONNECTED, Bytes()); + return true; // Operation complete (failed) + } + + switch (op.type) { + case OperationType::READ: { + esp_err_t ret = esp_ble_gattc_read_char( + _gattc_if, + conn->conn_id, + op.char_handle, + ESP_GATT_AUTH_REQ_NONE + ); + if (ret != ESP_OK) { + WARNING("BluedroidPlatform: Read char failed: " + std::to_string(ret)); + return false; // Failed to start + } + return true; // Successfully started, will complete async + } + default: + WARNING("BluedroidPlatform: Unknown operation type"); + return false; // Unknown operation type - failed to start + } +} + +//============================================================================= +// Connection Query +//============================================================================= + +std::vector BluedroidPlatform::getConnections() const { + std::vector result; + for (const auto& pair : _connections) { + ConnectionHandle handle; + handle.handle = pair.first; + handle.peer_address = fromEspBdAddr(pair.second.peer_addr, pair.second.addr_type); + handle.local_role = pair.second.local_role; + handle.state = ConnectionState::READY; + handle.mtu = pair.second.mtu; + result.push_back(handle); + } + return result; +} + +ConnectionHandle BluedroidPlatform::getConnection(uint16_t handle) const { + auto it = _connections.find(handle); + if (it == _connections.end()) { + return ConnectionHandle(); + } + + ConnectionHandle result; + result.handle = handle; + result.peer_address = fromEspBdAddr(it->second.peer_addr, it->second.addr_type); + result.local_role = it->second.local_role; + result.state = ConnectionState::READY; + result.mtu = it->second.mtu; + result.rx_char_handle = it->second.rx_char_handle; + result.tx_char_handle = it->second.tx_char_handle; + result.tx_cccd_handle = it->second.tx_cccd_handle; + result.identity_handle = it->second.identity_char_handle; + return result; +} + +size_t BluedroidPlatform::getConnectionCount() const { + return _connections.size(); +} + +bool BluedroidPlatform::isConnectedTo(const BLEAddress& address) const { + esp_bd_addr_t esp_addr; + toEspBdAddr(address, esp_addr); + + for (const auto& pair : _connections) { + if (memcmp(pair.second.peer_addr, esp_addr, 6) == 0) { + return true; + } + } + return false; +} + +//============================================================================= +// Callback Registration +//============================================================================= + +void BluedroidPlatform::setOnScanResult(Callbacks::OnScanResult callback) { + _on_scan_result = callback; +} + +void BluedroidPlatform::setOnScanComplete(Callbacks::OnScanComplete callback) { + _on_scan_complete = callback; +} + +void BluedroidPlatform::setOnConnected(Callbacks::OnConnected callback) { + _on_connected = callback; +} + +void BluedroidPlatform::setOnDisconnected(Callbacks::OnDisconnected callback) { + _on_disconnected = callback; +} + +void BluedroidPlatform::setOnMTUChanged(Callbacks::OnMTUChanged callback) { + _on_mtu_changed = callback; +} + +void BluedroidPlatform::setOnServicesDiscovered(Callbacks::OnServicesDiscovered callback) { + _on_services_discovered = callback; +} + +void BluedroidPlatform::setOnDataReceived(Callbacks::OnDataReceived callback) { + _on_data_received = callback; +} + +void BluedroidPlatform::setOnNotifyEnabled(Callbacks::OnNotifyEnabled callback) { + _on_notify_enabled = callback; +} + +void BluedroidPlatform::setOnCentralConnected(Callbacks::OnCentralConnected callback) { + _on_central_connected = callback; +} + +void BluedroidPlatform::setOnCentralDisconnected(Callbacks::OnCentralDisconnected callback) { + _on_central_disconnected = callback; +} + +void BluedroidPlatform::setOnWriteReceived(Callbacks::OnWriteReceived callback) { + _on_write_received = callback; +} + +void BluedroidPlatform::setOnReadRequested(Callbacks::OnReadRequested callback) { + _on_read_requested = callback; +} + +//============================================================================= +// Platform Info +//============================================================================= + +BLEAddress BluedroidPlatform::getLocalAddress() const { + if (!_local_addr_valid) { + // Get local address from GAP + uint8_t addr[6]; + uint8_t addr_type = 0; + if (esp_ble_gap_get_local_used_addr(addr, &addr_type) == ESP_OK) { + memcpy(_local_addr, addr, 6); + _local_addr_valid = true; + } + // If still not valid, address will be retrieved on first advertising start + } + return fromEspBdAddr(_local_addr, BLE_ADDR_TYPE_PUBLIC); +} + +//============================================================================= +// GAP Event Handler +//============================================================================= + +void BluedroidPlatform::handleGapEvent(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t* param) { + switch (event) { + case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: + if (param->scan_param_cmpl.status == ESP_BT_STATUS_SUCCESS) { + DEBUG("BluedroidPlatform: Scan params set, starting scan"); + _scan_start_time = millis(); + uint32_t duration_sec = (_scan_duration_ms > 0) ? + (_scan_duration_ms / 1000) : 0; // 0 = continuous + esp_ble_gap_start_scanning(duration_sec); + _scan_state = ScanState::STARTING; + } else { + ERROR("BluedroidPlatform: Scan param set failed"); + _scan_state = ScanState::IDLE; + } + break; + + case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: + if (param->scan_start_cmpl.status == ESP_BT_STATUS_SUCCESS) { + DEBUG("BluedroidPlatform: Scan started"); + _scan_state = ScanState::ACTIVE; + } else { + ERROR("BluedroidPlatform: Scan start failed"); + _scan_state = ScanState::IDLE; + } + break; + + case ESP_GAP_BLE_SCAN_RESULT_EVT: + handleScanResult(param); + break; + + case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT: + DEBUG("BluedroidPlatform: Scan stopped"); + _scan_state = ScanState::IDLE; + handleScanComplete(); + break; + + case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: + if (param->adv_data_cmpl.status == ESP_BT_STATUS_SUCCESS) { + DEBUG("BluedroidPlatform: Adv data set"); + _adv_state = AdvState::CONFIGURING_SCAN_RSP; + buildScanResponseData(); + } else { + ERROR("BluedroidPlatform: Adv data set failed"); + _adv_state = AdvState::IDLE; + } + break; + + case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: + if (param->scan_rsp_data_cmpl.status == ESP_BT_STATUS_SUCCESS) { + DEBUG("BluedroidPlatform: Scan response set, starting advertising"); + _adv_state = AdvState::STARTING; + + esp_ble_adv_params_t adv_params = { + .adv_int_min = static_cast((_config.adv_interval_min_ms * 1000) / 625), + .adv_int_max = static_cast((_config.adv_interval_max_ms * 1000) / 625), + .adv_type = ADV_TYPE_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, // Use public address (chip MAC) + .peer_addr = {0}, + .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY + }; + + esp_ble_gap_start_advertising(&adv_params); + } else { + ERROR("BluedroidPlatform: Scan response set failed"); + _adv_state = AdvState::IDLE; + } + break; + + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + handleAdvStart(param->adv_start_cmpl.status); + break; + + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + handleAdvStop(); + break; + + case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: + DEBUG("BluedroidPlatform: Connection params updated"); + break; + + default: + break; + } +} + +void BluedroidPlatform::handleScanResult(esp_ble_gap_cb_param_t* param) { + if (param->scan_rst.search_evt != ESP_GAP_SEARCH_INQ_RES_EVT) { + if (param->scan_rst.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { + // Scan duration complete + _scan_state = ScanState::IDLE; + handleScanComplete(); + } + return; + } + + // Parse advertising data for service UUID + uint8_t* adv_data = param->scan_rst.ble_adv; + uint8_t adv_len = param->scan_rst.adv_data_len; + + // Look for 128-bit service UUID + bool has_service = false; + uint8_t service_uuid_bytes[16]; + const char* uuid_str = UUID::SERVICE; + int idx = 15; + for (int i = 0; i < 36 && idx >= 0; i++) { + if (uuid_str[i] == '-') continue; + unsigned int byte_val; + sscanf(&uuid_str[i], "%02x", &byte_val); + service_uuid_bytes[idx--] = static_cast(byte_val); + i++; + } + + // Parse AD structures + int pos = 0; + while (pos < adv_len) { + uint8_t len = adv_data[pos++]; + if (len == 0 || pos + len > adv_len) break; + + uint8_t type = adv_data[pos]; + if (type == ESP_BLE_AD_TYPE_128SRV_CMPL || + type == ESP_BLE_AD_TYPE_128SRV_PART) { + // Check if UUID matches + if (len >= 17 && + memcmp(&adv_data[pos + 1], service_uuid_bytes, 16) == 0) { + has_service = true; + } + } + pos += len; + } + + // Build scan result + ScanResult result; + result.address = fromEspBdAddr(param->scan_rst.bda, param->scan_rst.ble_addr_type); + result.rssi = param->scan_rst.rssi; + result.connectable = (param->scan_rst.ble_evt_type == ESP_BLE_EVT_CONN_ADV); + result.has_reticulum_service = has_service; + + // Get device name if present + uint8_t* name = esp_ble_resolve_adv_data(adv_data, ESP_BLE_AD_TYPE_NAME_CMPL, &adv_len); + if (name && adv_len > 0) { + result.name = std::string(reinterpret_cast(name), adv_len); + } + + if (_on_scan_result && result.has_reticulum_service) { + _on_scan_result(result); + } +} + +void BluedroidPlatform::handleScanComplete() { + if (_on_scan_complete) { + _on_scan_complete(); + } + + // In dual mode, restart advertising after scan + if (_config.role == Role::DUAL && _init_state == InitState::READY) { + startAdvertising(); + } +} + +void BluedroidPlatform::handleAdvStart(esp_bt_status_t status) { + if (status == ESP_BT_STATUS_SUCCESS) { + INFO("BluedroidPlatform: Advertising started"); + _adv_state = AdvState::ACTIVE; + } else { + ERROR("BluedroidPlatform: Advertising start failed: " + std::to_string(status)); + _adv_state = AdvState::IDLE; + } +} + +void BluedroidPlatform::handleAdvStop() { + DEBUG("BluedroidPlatform: Advertising stopped"); + _adv_state = AdvState::IDLE; +} + +//============================================================================= +// GATTS Event Handler +//============================================================================= + +void BluedroidPlatform::handleGattsEvent(esp_gatts_cb_event_t event, + esp_gatt_if_t gatts_if, + esp_ble_gatts_cb_param_t* param) { + switch (event) { + case ESP_GATTS_REG_EVT: + handleGattsRegister(gatts_if, param->reg.status); + break; + + case ESP_GATTS_CREATE_EVT: + handleGattsServiceCreated(param->create.service_handle); + break; + + case ESP_GATTS_ADD_CHAR_EVT: + handleGattsCharAdded(param->add_char.attr_handle, ¶m->add_char.char_uuid); + break; + + case ESP_GATTS_START_EVT: + handleGattsServiceStarted(); + break; + + case ESP_GATTS_CONNECT_EVT: + handleGattsConnect(param); + break; + + case ESP_GATTS_DISCONNECT_EVT: + handleGattsDisconnect(param); + break; + + case ESP_GATTS_WRITE_EVT: + handleGattsWrite(param); + break; + + case ESP_GATTS_READ_EVT: + handleGattsRead(param); + break; + + case ESP_GATTS_MTU_EVT: + handleGattsMtuChange(param); + break; + + case ESP_GATTS_CONF_EVT: + handleGattsConfirm(param); + break; + + default: + break; + } +} + +void BluedroidPlatform::handleGattsRegister(esp_gatt_if_t gatts_if, esp_gatt_status_t status) { + if (status != ESP_GATT_OK) { + ERROR("BluedroidPlatform: GATTS register failed: " + std::to_string(status)); + return; + } + + _gatts_if = gatts_if; + DEBUG("BluedroidPlatform: GATTS registered, if=" + std::to_string(gatts_if)); + + // Create the Reticulum service + setupGattsService(); +} + +bool BluedroidPlatform::setupGattsService() { + // Parse service UUID + esp_gatt_srvc_id_t service_id; + service_id.is_primary = true; + service_id.id.inst_id = 0; + service_id.id.uuid.len = ESP_UUID_LEN_128; + + const char* uuid_str = UUID::SERVICE; + int idx = 15; + for (int i = 0; i < 36 && idx >= 0; i++) { + if (uuid_str[i] == '-') continue; + unsigned int byte_val; + sscanf(&uuid_str[i], "%02x", &byte_val); + service_id.id.uuid.uuid.uuid128[idx--] = static_cast(byte_val); + i++; + } + + // Calculate number of handles needed: + // 1 for service, 2 per characteristic (decl + value), 1 for CCCD on TX + // Service + RX(2) + TX(2+1 CCCD) + Identity(2) = 8 handles + esp_err_t ret = esp_ble_gatts_create_service(_gatts_if, &service_id, 10); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Create service failed: " + std::to_string(ret)); + return false; + } + + _init_state = InitState::GATTS_CREATING_SERVICE; + return true; +} + +void BluedroidPlatform::handleGattsServiceCreated(uint16_t service_handle) { + _service_handle = service_handle; + DEBUG("BluedroidPlatform: Service created, handle=" + std::to_string(service_handle)); + + _init_state = InitState::GATTS_ADDING_CHARS; + _chars_added = 0; + + // Helper to parse UUID string to esp_bt_uuid_t + auto parseUuid = [](const char* uuid_str, esp_bt_uuid_t& uuid) { + uuid.len = ESP_UUID_LEN_128; + int idx = 15; + for (int i = 0; i < 36 && idx >= 0; i++) { + if (uuid_str[i] == '-') continue; + unsigned int byte_val; + sscanf(&uuid_str[i], "%02x", &byte_val); + uuid.uuid.uuid128[idx--] = static_cast(byte_val); + i++; + } + }; + + // Add RX characteristic (central writes here) + esp_bt_uuid_t rx_uuid; + parseUuid(UUID::RX_CHAR, rx_uuid); + esp_err_t ret = esp_ble_gatts_add_char( + _service_handle, + &rx_uuid, + ESP_GATT_PERM_WRITE, + ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_WRITE_NR, + nullptr, + nullptr + ); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Add RX char failed: " + std::to_string(ret)); + } + + // Add TX characteristic (we notify from here) + esp_bt_uuid_t tx_uuid; + parseUuid(UUID::TX_CHAR, tx_uuid); + ret = esp_ble_gatts_add_char( + _service_handle, + &tx_uuid, + ESP_GATT_PERM_READ, + ESP_GATT_CHAR_PROP_BIT_NOTIFY, + nullptr, + nullptr + ); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Add TX char failed: " + std::to_string(ret)); + } + + // Add Identity characteristic (central reads) + esp_bt_uuid_t id_uuid; + parseUuid(UUID::IDENTITY_CHAR, id_uuid); + ret = esp_ble_gatts_add_char( + _service_handle, + &id_uuid, + ESP_GATT_PERM_READ, + ESP_GATT_CHAR_PROP_BIT_READ, + nullptr, + nullptr + ); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Add Identity char failed: " + std::to_string(ret)); + } +} + +void BluedroidPlatform::handleGattsCharAdded(uint16_t attr_handle, esp_bt_uuid_t* char_uuid) { + // Determine which characteristic was added by comparing UUID + // For simplicity, we track by order of addition + _chars_added++; + DEBUG("BluedroidPlatform: Char added, handle=" + std::to_string(attr_handle) + + " (" + std::to_string(_chars_added) + "/" + std::to_string(CHARS_EXPECTED) + ")"); + + switch (_chars_added) { + case 1: + _rx_char_handle = attr_handle; + break; + case 2: + _tx_char_handle = attr_handle; + // TX needs CCCD for notifications - will be auto-added by stack + _tx_cccd_handle = attr_handle + 1; // CCCD is typically handle+1 + break; + case 3: + _identity_char_handle = attr_handle; + break; + } + + if (_chars_added >= CHARS_EXPECTED) { + // All characteristics added, start service + _init_state = InitState::GATTS_STARTING_SERVICE; + esp_err_t ret = esp_ble_gatts_start_service(_service_handle); + if (ret != ESP_OK) { + ERROR("BluedroidPlatform: Start service failed: " + std::to_string(ret)); + } + } +} + +void BluedroidPlatform::handleGattsServiceStarted() { + INFO("BluedroidPlatform: GATTS service started"); + + // Check if GATTC is also ready (for dual mode) + if (_config.role == Role::PERIPHERAL || + (_config.role == Role::DUAL && _gattc_if != ESP_GATT_IF_NONE)) { + _init_state = InitState::READY; + INFO("BluedroidPlatform: Ready for connections"); + + // Start advertising if running + if (_running) { + startAdvertising(); + } + } +} + +void BluedroidPlatform::handleGattsConnect(esp_ble_gatts_cb_param_t* param) { + uint16_t conn_handle = allocateConnHandle(); + + BluedroidConnection conn; + conn.conn_id = param->connect.conn_id; + memcpy(conn.peer_addr, param->connect.remote_bda, 6); + conn.addr_type = BLE_ADDR_TYPE_PUBLIC; // Could be determined from GAP + conn.local_role = Role::PERIPHERAL; + conn.mtu = MTU::MINIMUM; + + _connections[conn_handle] = conn; + + // Log the mapping for debugging + char addr_str[18]; + snprintf(addr_str, sizeof(addr_str), "%02X:%02X:%02X:%02X:%02X:%02X", + param->connect.remote_bda[0], param->connect.remote_bda[1], param->connect.remote_bda[2], + param->connect.remote_bda[3], param->connect.remote_bda[4], param->connect.remote_bda[5]); + INFO("BluedroidPlatform: GATTS Connected handle=" + std::to_string(conn_handle) + + " conn_id=" + std::to_string(param->connect.conn_id) + + " addr=" + std::string(addr_str)); + + // Stop advertising if we've hit max connections + if (getConnectionCount() >= _config.max_connections) { + DEBUG("BluedroidPlatform: At max connections, stopping advertising"); + stopAdvertising(); + } + + if (_on_central_connected) { + ConnectionHandle ch = getConnection(conn_handle); + _on_central_connected(ch); + } +} + +void BluedroidPlatform::handleGattsDisconnect(esp_ble_gatts_cb_param_t* param) { + // Find connection by conn_id + uint16_t conn_handle = 0xFFFF; + for (auto& pair : _connections) { + if (pair.second.conn_id == param->disconnect.conn_id) { + conn_handle = pair.first; + break; + } + } + + if (conn_handle != 0xFFFF) { + INFO("BluedroidPlatform: Central disconnected, handle=" + std::to_string(conn_handle)); + + if (_on_central_disconnected) { + ConnectionHandle ch = getConnection(conn_handle); + _on_central_disconnected(ch); + } + + _connections.erase(conn_handle); + } + + // In dual mode, restart advertising + if (_config.role == Role::DUAL || _config.role == Role::PERIPHERAL) { + if (_running) { + startAdvertising(); + } + } +} + +void BluedroidPlatform::handleGattsWrite(esp_ble_gatts_cb_param_t* param) { + // Hot path - no logging here to avoid blocking main loop + + if (!param->write.is_prep) { + // Regular write (not prepare write) + Bytes data(param->write.value, param->write.len); + + // Find connection + uint16_t conn_handle = 0xFFFF; + for (auto& pair : _connections) { + if (pair.second.conn_id == param->write.conn_id) { + conn_handle = pair.first; + break; + } + } + + if (param->write.handle == _rx_char_handle) { + // Data write to RX characteristic + if (_on_write_received && conn_handle != 0xFFFF) { + ConnectionHandle ch = getConnection(conn_handle); + _on_write_received(ch, data); + } else { + WARNING("BluedroidPlatform: No callback or invalid handle for RX write"); + } + } else if (param->write.handle == _tx_cccd_handle) { + // Notification enable/disable + bool enabled = (param->write.len >= 2 && param->write.value[0] != 0); + if (conn_handle != 0xFFFF) { + auto* conn = findConnection(conn_handle); + if (conn) { + conn->notifications_enabled = enabled; + } + if (_on_notify_enabled) { + ConnectionHandle ch = getConnection(conn_handle); + _on_notify_enabled(ch, enabled); + } + } + } + + // Send response if needed + if (param->write.need_rsp) { + esp_ble_gatts_send_response(_gatts_if, param->write.conn_id, + param->write.trans_id, ESP_GATT_OK, nullptr); + } + } +} + +void BluedroidPlatform::handleGattsRead(esp_ble_gatts_cb_param_t* param) { + // Hot path - no logging here to avoid blocking main loop + + esp_gatt_rsp_t rsp; + memset(&rsp, 0, sizeof(rsp)); + + if (param->read.handle == _identity_char_handle) { + // Return identity data + rsp.attr_value.handle = param->read.handle; + rsp.attr_value.len = _identity_data.size(); + if (rsp.attr_value.len > ESP_GATT_MAX_ATTR_LEN) { + rsp.attr_value.len = ESP_GATT_MAX_ATTR_LEN; + } + memcpy(rsp.attr_value.value, _identity_data.data(), rsp.attr_value.len); + + esp_ble_gatts_send_response(_gatts_if, param->read.conn_id, + param->read.trans_id, ESP_GATT_OK, &rsp); + } else { + // Unknown characteristic - send error + esp_ble_gatts_send_response(_gatts_if, param->read.conn_id, + param->read.trans_id, ESP_GATT_READ_NOT_PERMIT, nullptr); + } +} + +void BluedroidPlatform::handleGattsMtuChange(esp_ble_gatts_cb_param_t* param) { + uint16_t conn_handle = 0xFFFF; + for (auto& pair : _connections) { + if (pair.second.conn_id == param->mtu.conn_id) { + conn_handle = pair.first; + pair.second.mtu = param->mtu.mtu; + break; + } + } + + DEBUG("BluedroidPlatform: MTU changed to " + std::to_string(param->mtu.mtu)); + + if (_on_mtu_changed && conn_handle != 0xFFFF) { + ConnectionHandle ch = getConnection(conn_handle); + _on_mtu_changed(ch, param->mtu.mtu); + } +} + +void BluedroidPlatform::handleGattsConfirm(esp_ble_gatts_cb_param_t* param) { + // Notification confirmation (indication acknowledgment) + // For notifications (not indications), this may not be called +} + +//============================================================================= +// GATTC Event Handler +//============================================================================= + +void BluedroidPlatform::handleGattcEvent(esp_gattc_cb_event_t event, + esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t* param) { + switch (event) { + case ESP_GATTC_REG_EVT: + handleGattcRegister(gattc_if, param->reg.status); + break; + + case ESP_GATTC_OPEN_EVT: + handleGattcConnect(param); + break; + + case ESP_GATTC_CLOSE_EVT: + case ESP_GATTC_DISCONNECT_EVT: + handleGattcDisconnect(param); + break; + + case ESP_GATTC_SEARCH_RES_EVT: + DEBUG("BluedroidPlatform: SEARCH_RES_EVT received, conn_id=" + + std::to_string(param->search_res.conn_id)); + handleGattcSearchResult(param); + break; + + case ESP_GATTC_SEARCH_CMPL_EVT: + DEBUG("BluedroidPlatform: SEARCH_CMPL_EVT received, conn_id=" + + std::to_string(param->search_cmpl.conn_id) + + " status=" + std::to_string(param->search_cmpl.status)); + handleGattcSearchComplete(param); + break; + + case ESP_GATTC_NOTIFY_EVT: + handleGattcNotify(param); + break; + + case ESP_GATTC_WRITE_CHAR_EVT: + case ESP_GATTC_WRITE_DESCR_EVT: + handleGattcWrite(param); + break; + + case ESP_GATTC_READ_CHAR_EVT: + handleGattcRead(param); + break; + + case ESP_GATTC_REG_FOR_NOTIFY_EVT: + if (param->reg_for_notify.status == ESP_GATT_OK) { + DEBUG("BluedroidPlatform: Registered for notifications on handle " + + std::to_string(param->reg_for_notify.handle)); + } else { + WARNING("BluedroidPlatform: Failed to register for notify: " + + std::to_string(param->reg_for_notify.status)); + } + break; + + case ESP_GATTC_CFG_MTU_EVT: + // MTU exchange complete (when we're central) + if (param->cfg_mtu.status == ESP_GATT_OK) { + INFO("BluedroidPlatform: GATTC MTU configured to " + std::to_string(param->cfg_mtu.mtu)); + // Find connection and update MTU + for (auto& pair : _connections) { + if (pair.second.conn_id == param->cfg_mtu.conn_id) { + pair.second.mtu = param->cfg_mtu.mtu; + if (_on_mtu_changed) { + ConnectionHandle ch = getConnection(pair.first); + _on_mtu_changed(ch, param->cfg_mtu.mtu); + } + break; + } + } + } else { + WARNING("BluedroidPlatform: MTU config failed: " + std::to_string(param->cfg_mtu.status)); + } + break; + + default: + break; + } +} + +void BluedroidPlatform::handleGattcRegister(esp_gatt_if_t gattc_if, esp_gatt_status_t status) { + if (status != ESP_GATT_OK) { + ERROR("BluedroidPlatform: GATTC register failed: " + std::to_string(status)); + return; + } + + _gattc_if = gattc_if; + DEBUG("BluedroidPlatform: GATTC registered, if=" + std::to_string(gattc_if)); + + // If in dual mode and GATTS is ready, mark as fully ready + if (_config.role == Role::CENTRAL || + (_config.role == Role::DUAL && _init_state == InitState::READY)) { + // Already ready or central-only mode + } else if (_init_state >= InitState::GATTS_STARTING_SERVICE) { + // GATTS service started, now we're fully ready + _init_state = InitState::READY; + INFO("BluedroidPlatform: Ready for connections (GATTC registered)"); + } +} + +void BluedroidPlatform::handleGattcConnect(esp_ble_gattc_cb_param_t* param) { + _connect_pending = false; + + if (param->open.status != ESP_GATT_OK) { + ERROR("BluedroidPlatform: Connection failed: " + std::to_string(param->open.status)); + _connect_success = false; + _connect_error = param->open.status; + // Try to start queued discoveries since connection finished + if (!_pending_discoveries.empty() && _discovery_in_progress == 0xFFFF) { + uint16_t next_handle = _pending_discoveries.front(); + _pending_discoveries.pop(); + DEBUG("BluedroidPlatform: Starting queued discovery for handle " + std::to_string(next_handle)); + discoverServices(next_handle); + } + return; + } + + _connect_success = true; + uint16_t conn_handle = allocateConnHandle(); + + BluedroidConnection conn; + conn.conn_id = param->open.conn_id; + memcpy(conn.peer_addr, param->open.remote_bda, 6); + conn.addr_type = BLE_ADDR_TYPE_PUBLIC; + conn.local_role = Role::CENTRAL; + conn.mtu = param->open.mtu; + + _connections[conn_handle] = conn; + + // Log the mapping for debugging + char addr_str[18]; + snprintf(addr_str, sizeof(addr_str), "%02X:%02X:%02X:%02X:%02X:%02X", + param->open.remote_bda[0], param->open.remote_bda[1], param->open.remote_bda[2], + param->open.remote_bda[3], param->open.remote_bda[4], param->open.remote_bda[5]); + INFO("BluedroidPlatform: GATTC Connected handle=" + std::to_string(conn_handle) + + " conn_id=" + std::to_string(param->open.conn_id) + + " addr=" + std::string(addr_str)); + + if (_on_connected) { + ConnectionHandle ch = getConnection(conn_handle); + _on_connected(ch); + } + + // Request MTU update + esp_ble_gattc_send_mtu_req(_gattc_if, param->open.conn_id); +} + +void BluedroidPlatform::handleGattcDisconnect(esp_ble_gattc_cb_param_t* param) { + _connect_pending = false; + + uint16_t conn_handle = 0xFFFF; + for (auto& pair : _connections) { + if (pair.second.conn_id == param->close.conn_id) { + conn_handle = pair.first; + break; + } + } + + if (conn_handle != 0xFFFF) { + INFO("BluedroidPlatform: Peripheral disconnected, handle=" + std::to_string(conn_handle)); + + // If this connection was doing discovery, clear the flag and start next + if (_discovery_in_progress == conn_handle) { + _discovery_in_progress = 0xFFFF; + if (!_pending_discoveries.empty()) { + uint16_t next_handle = _pending_discoveries.front(); + _pending_discoveries.pop(); + DEBUG("BluedroidPlatform: Starting queued discovery for handle " + std::to_string(next_handle)); + discoverServices(next_handle); + } + } + + // Remove this connection from pending discoveries queue + std::queue temp_queue; + while (!_pending_discoveries.empty()) { + uint16_t h = _pending_discoveries.front(); + _pending_discoveries.pop(); + if (h != conn_handle) { + temp_queue.push(h); + } + } + _pending_discoveries = std::move(temp_queue); + + if (_on_disconnected) { + ConnectionHandle ch = getConnection(conn_handle); + _on_disconnected(ch, param->close.reason); + } + + _connections.erase(conn_handle); + } +} + +void BluedroidPlatform::handleGattcSearchResult(esp_ble_gattc_cb_param_t* param) { + // Find connection - ONLY search central (GATTC) connections + BluedroidConnection* conn = nullptr; + for (auto& pair : _connections) { + if (pair.second.conn_id == param->search_res.conn_id && + pair.second.local_role == Role::CENTRAL) { + conn = &pair.second; + break; + } + } + + if (!conn) return; + + conn->service_start_handle = param->search_res.start_handle; + conn->service_end_handle = param->search_res.end_handle; + DEBUG("BluedroidPlatform: Found service, handles " + + std::to_string(conn->service_start_handle) + "-" + + std::to_string(conn->service_end_handle)); +} + +void BluedroidPlatform::handleGattcSearchComplete(esp_ble_gattc_cb_param_t* param) { + // Find connection - ONLY search central (GATTC) connections + BluedroidConnection* conn = nullptr; + uint16_t conn_handle = 0xFFFF; + for (auto& pair : _connections) { + if (pair.second.conn_id == param->search_cmpl.conn_id && + pair.second.local_role == Role::CENTRAL) { + conn = &pair.second; + conn_handle = pair.first; + break; + } + } + + if (!conn) { + ERROR("BluedroidPlatform: handleGattcSearchComplete - connection not found for conn_id=" + + std::to_string(param->search_cmpl.conn_id)); + // Clear discovery state and try next + _discovery_in_progress = 0xFFFF; + if (!_pending_discoveries.empty()) { + uint16_t next_handle = _pending_discoveries.front(); + _pending_discoveries.pop(); + discoverServices(next_handle); + } + return; + } + + DEBUG("BluedroidPlatform: handleGattcSearchComplete for conn_handle=" + std::to_string(conn_handle) + + " service_start=" + std::to_string(conn->service_start_handle)); + + if (conn->service_start_handle == 0) { + ERROR("BluedroidPlatform: Service not found for conn " + std::to_string(conn_handle)); + // Clear discovery state and try next + _discovery_in_progress = 0xFFFF; + if (_on_services_discovered) { + ConnectionHandle ch = getConnection(conn_handle); + _on_services_discovered(ch, false); + } + if (!_pending_discoveries.empty()) { + uint16_t next_handle = _pending_discoveries.front(); + _pending_discoveries.pop(); + discoverServices(next_handle); + } + return; + } + + // Get all characteristics in the service + conn->discovery_state = BluedroidConnection::DiscoveryState::GETTING_CHARS; + + // Get characteristic count first + uint16_t count = 0; + esp_gatt_status_t status = esp_ble_gattc_get_attr_count( + _gattc_if, + param->search_cmpl.conn_id, + ESP_GATT_DB_CHARACTERISTIC, + conn->service_start_handle, + conn->service_end_handle, + 0, + &count + ); + + if (status != ESP_GATT_OK || count == 0) { + ERROR("BluedroidPlatform: No characteristics found, status=" + std::to_string(status) + + " count=" + std::to_string(count)); + conn->discovery_state = BluedroidConnection::DiscoveryState::COMPLETE; + // Clear discovery state and try next + _discovery_in_progress = 0xFFFF; + if (_on_services_discovered) { + ConnectionHandle ch = getConnection(conn_handle); + _on_services_discovered(ch, false); + } + if (!_pending_discoveries.empty()) { + uint16_t next_handle = _pending_discoveries.front(); + _pending_discoveries.pop(); + discoverServices(next_handle); + } + return; + } + + { + char buf[80]; + snprintf(buf, sizeof(buf), "BluedroidPlatform: Found %u characteristics for conn_handle=%u", count, conn_handle); + DEBUG(buf); + } + + // Get all characteristics - use fixed stack buffer to avoid heap fragmentation + // We only need RX, TX, Identity chars so 16 is plenty (most services have <10) + static const uint16_t MAX_CHARS = 16; + esp_gattc_char_elem_t chars[MAX_CHARS]; + if (count > MAX_CHARS) { + count = MAX_CHARS; // Limit to what we can handle + } + status = esp_ble_gattc_get_all_char( + _gattc_if, + param->search_cmpl.conn_id, + conn->service_start_handle, + conn->service_end_handle, + chars, + &count, + 0 + ); + + if (status == ESP_GATT_OK) { + // Parse UUID helper (same logic as buildAdvertisingData) + auto uuidMatches = [](const esp_bt_uuid_t& uuid, const char* uuid_str) -> bool { + if (uuid.len != ESP_UUID_LEN_128) return false; + uint8_t expected[16]; + int idx = 15; + for (int i = 0; i < 36 && idx >= 0; i++) { + if (uuid_str[i] == '-') continue; + unsigned int byte_val; + sscanf(&uuid_str[i], "%02x", &byte_val); + expected[idx--] = static_cast(byte_val); + i++; + } + return memcmp(uuid.uuid.uuid128, expected, 16) == 0; + }; + + char buf[64]; + for (uint16_t i = 0; i < count; i++) { + if (uuidMatches(chars[i].uuid, UUID::RX_CHAR)) { + conn->rx_char_handle = chars[i].char_handle; + snprintf(buf, sizeof(buf), "BluedroidPlatform: Found RX char, handle=%u", conn->rx_char_handle); + DEBUG(buf); + } else if (uuidMatches(chars[i].uuid, UUID::TX_CHAR)) { + conn->tx_char_handle = chars[i].char_handle; + snprintf(buf, sizeof(buf), "BluedroidPlatform: Found TX char, handle=%u", conn->tx_char_handle); + DEBUG(buf); + + // Get descriptor for TX (CCCD) + uint16_t desc_count = 1; + esp_gattc_descr_elem_t descr; + esp_bt_uuid_t cccd_uuid; + cccd_uuid.len = ESP_UUID_LEN_16; + cccd_uuid.uuid.uuid16 = 0x2902; // CCCD UUID + + if (esp_ble_gattc_get_descr_by_char_handle( + _gattc_if, param->search_cmpl.conn_id, + chars[i].char_handle, cccd_uuid, + &descr, &desc_count) == ESP_GATT_OK && desc_count > 0) { + conn->tx_cccd_handle = descr.handle; + snprintf(buf, sizeof(buf), "BluedroidPlatform: Found TX CCCD, handle=%u", conn->tx_cccd_handle); + DEBUG(buf); + } + } else if (uuidMatches(chars[i].uuid, UUID::IDENTITY_CHAR)) { + conn->identity_char_handle = chars[i].char_handle; + snprintf(buf, sizeof(buf), "BluedroidPlatform: Found Identity char, handle=%u", conn->identity_char_handle); + DEBUG(buf); + } + } + } + + // chars is stack-allocated, no delete needed + + conn->discovery_state = BluedroidConnection::DiscoveryState::COMPLETE; + + bool success = (conn->rx_char_handle != 0 && conn->tx_char_handle != 0); + { + char buf[128]; + snprintf(buf, sizeof(buf), "BluedroidPlatform: Service discovery complete for conn %u RX=%u TX=%u CCCD=%u Identity=%u", + conn_handle, conn->rx_char_handle, conn->tx_char_handle, conn->tx_cccd_handle, conn->identity_char_handle); + INFO(buf); + } + + if (_on_services_discovered) { + ConnectionHandle ch = getConnection(conn_handle); + ch.rx_char_handle = conn->rx_char_handle; + ch.tx_char_handle = conn->tx_char_handle; + ch.tx_cccd_handle = conn->tx_cccd_handle; + ch.identity_handle = conn->identity_char_handle; + _on_services_discovered(ch, success); + } + + // Clear discovery in progress and start next queued discovery + _discovery_in_progress = 0xFFFF; + if (!_pending_discoveries.empty()) { + taskYIELD(); // Let stack process before next discovery + uint16_t next_handle = _pending_discoveries.front(); + _pending_discoveries.pop(); + DEBUG("BluedroidPlatform: Starting queued discovery for handle " + std::to_string(next_handle)); + discoverServices(next_handle); + } +} + +void BluedroidPlatform::handleGattcNotify(esp_ble_gattc_cb_param_t* param) { + // Hot path - no logging to avoid heap allocation on every packet + + // Find connection - ONLY search central (GATTC) connections + uint16_t conn_handle = 0xFFFF; + for (auto& pair : _connections) { + if (pair.second.conn_id == param->notify.conn_id && + pair.second.local_role == Role::CENTRAL) { + conn_handle = pair.first; + break; + } + } + + if (conn_handle == 0xFFFF) { + WARNING("BluedroidPlatform: No CENTRAL connection found for notify conn_id=" + + std::to_string(param->notify.conn_id)); + return; + } + + Bytes data(param->notify.value, param->notify.value_len); + + if (_on_data_received) { + ConnectionHandle ch = getConnection(conn_handle); + _on_data_received(ch, data); + } +} + +void BluedroidPlatform::handleGattcWrite(esp_ble_gattc_cb_param_t* param) { + // Only log failures - hot path, avoid string allocation on success + if (param->write.status != ESP_GATT_OK) { + WARNING("BluedroidPlatform: Write FAILED conn_id=" + std::to_string(param->write.conn_id) + + " status=" + std::to_string(param->write.status)); + } +} + +void BluedroidPlatform::handleGattcRead(esp_ble_gattc_cb_param_t* param) { + if (param->read.status != ESP_GATT_OK) { + WARNING("BluedroidPlatform: Read failed: " + std::to_string(param->read.status)); + complete(OperationResult::ERROR, Bytes()); + return; + } + + // Extract read data + Bytes data(param->read.value, param->read.value_len); + DEBUG("BluedroidPlatform: Read complete, " + std::to_string(data.size()) + " bytes"); + + // Complete the pending operation with the data + complete(OperationResult::SUCCESS, data); +} + +//============================================================================= +// Address Conversion +//============================================================================= + +BLEAddress BluedroidPlatform::fromEspBdAddr(const esp_bd_addr_t addr, + esp_ble_addr_type_t type) { + BLEAddress result; + memcpy(result.addr, addr, 6); + result.type = type; + return result; +} + +void BluedroidPlatform::toEspBdAddr(const BLEAddress& addr, esp_bd_addr_t out_addr) { + memcpy(out_addr, addr.addr, 6); +} + +//============================================================================= +// Connection Handle Management +//============================================================================= + +uint16_t BluedroidPlatform::allocateConnHandle() { + return _next_conn_handle++; +} + +void BluedroidPlatform::freeConnHandle(uint16_t handle) { + // Handle reuse not implemented - with 16-bit handles, unlikely to overflow +} + +BluedroidPlatform::BluedroidConnection* BluedroidPlatform::findConnection(uint16_t conn_handle) { + auto it = _connections.find(conn_handle); + if (it != _connections.end()) { + return &it->second; + } + return nullptr; +} + +BluedroidPlatform::BluedroidConnection* BluedroidPlatform::findConnectionByAddress( + const esp_bd_addr_t addr) { + for (auto& pair : _connections) { + if (memcmp(pair.second.peer_addr, addr, 6) == 0) { + return &pair.second; + } + } + return nullptr; +} + +}} // namespace RNS::BLE + +#endif // ESP32 && USE_BLUEDROID diff --git a/lib/ble_interface/platforms/BluedroidPlatform.h b/lib/ble_interface/platforms/BluedroidPlatform.h new file mode 100644 index 0000000..9e81237 --- /dev/null +++ b/lib/ble_interface/platforms/BluedroidPlatform.h @@ -0,0 +1,330 @@ +/** + * @file BluedroidPlatform.h + * @brief ESP-IDF Bluedroid implementation of IBLEPlatform for ESP32 + * + * This implementation uses the ESP-IDF Bluedroid stack to provide BLE + * functionality on ESP32 devices. It supports both central and peripheral + * modes simultaneously (dual-mode operation). + * + * Alternative to NimBLEPlatform to work around state machine bugs in NimBLE + * that cause rc=530/rc=21 errors during dual-role operation. + */ +#pragma once + +#include "../BLEPlatform.h" +#include "../BLEOperationQueue.h" + +// Only compile for ESP32 with Bluedroid +#if defined(ESP32) && defined(USE_BLUEDROID) + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace RNS { namespace BLE { + +/** + * @brief Bluedroid (ESP-IDF) implementation of IBLEPlatform + */ +class BluedroidPlatform : public IBLEPlatform, public BLEOperationQueue { +public: + BluedroidPlatform(); + virtual ~BluedroidPlatform(); + + //========================================================================= + // IBLEPlatform Implementation + //========================================================================= + + // Lifecycle + bool initialize(const PlatformConfig& config) override; + bool start() override; + void stop() override; + void loop() override; + void shutdown() override; + bool isRunning() const override; + + // Central mode - Scanning + bool startScan(uint16_t duration_ms = 0) override; + void stopScan() override; + bool isScanning() const override; + + // Central mode - Connections + bool connect(const BLEAddress& address, uint16_t timeout_ms = 10000) override; + bool disconnect(uint16_t conn_handle) override; + void disconnectAll() override; + bool requestMTU(uint16_t conn_handle, uint16_t mtu) override; + bool discoverServices(uint16_t conn_handle) override; + + // Peripheral mode + bool startAdvertising() override; + void stopAdvertising() override; + bool isAdvertising() const override; + bool setAdvertisingData(const Bytes& data) override; + void setIdentityData(const Bytes& identity) override; + + // GATT Operations + bool write(uint16_t conn_handle, const Bytes& data, bool response = true) override; + bool read(uint16_t conn_handle, uint16_t char_handle, + std::function callback) override; + bool enableNotifications(uint16_t conn_handle, bool enable) override; + bool notify(uint16_t conn_handle, const Bytes& data) override; + bool notifyAll(const Bytes& data) override; + + // Connection management + std::vector getConnections() const override; + ConnectionHandle getConnection(uint16_t handle) const override; + size_t getConnectionCount() const override; + bool isConnectedTo(const BLEAddress& address) const override; + + // Callback registration + void setOnScanResult(Callbacks::OnScanResult callback) override; + void setOnScanComplete(Callbacks::OnScanComplete callback) override; + void setOnConnected(Callbacks::OnConnected callback) override; + void setOnDisconnected(Callbacks::OnDisconnected callback) override; + void setOnMTUChanged(Callbacks::OnMTUChanged callback) override; + void setOnServicesDiscovered(Callbacks::OnServicesDiscovered callback) override; + void setOnDataReceived(Callbacks::OnDataReceived callback) override; + void setOnNotifyEnabled(Callbacks::OnNotifyEnabled callback) override; + void setOnCentralConnected(Callbacks::OnCentralConnected callback) override; + void setOnCentralDisconnected(Callbacks::OnCentralDisconnected callback) override; + void setOnWriteReceived(Callbacks::OnWriteReceived callback) override; + void setOnReadRequested(Callbacks::OnReadRequested callback) override; + + // Platform info + PlatformType getPlatformType() const override { return PlatformType::ESP_IDF; } + std::string getPlatformName() const override { return "Bluedroid"; } + BLEAddress getLocalAddress() const override; + +protected: + // BLEOperationQueue implementation + bool executeOperation(const GATTOperation& op) override; + +private: + //========================================================================= + // Static Callback Handlers (Bluedroid requires static functions) + //========================================================================= + + static void gapEventHandler(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t* param); + static void gattsEventHandler(esp_gatts_cb_event_t event, + esp_gatt_if_t gatts_if, + esp_ble_gatts_cb_param_t* param); + static void gattcEventHandler(esp_gattc_cb_event_t event, + esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t* param); + + // Singleton instance for static callback routing + static BluedroidPlatform* _instance; + + //========================================================================= + // State Machines + //========================================================================= + + enum class InitState { + UNINITIALIZED, + CONTROLLER_INIT, + BLUEDROID_INIT, + CALLBACKS_REGISTERED, + GATTS_REGISTERING, + GATTS_CREATING_SERVICE, + GATTS_ADDING_CHARS, + GATTS_STARTING_SERVICE, + GATTC_REGISTERING, + READY + }; + + enum class ScanState { + IDLE, + SETTING_PARAMS, + STARTING, + ACTIVE, + STOPPING + }; + + enum class AdvState { + IDLE, + CONFIGURING_DATA, + CONFIGURING_SCAN_RSP, + STARTING, + ACTIVE, + STOPPING + }; + + //========================================================================= + // Internal Event Handlers + //========================================================================= + + // GAP events + void handleGapEvent(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t* param); + void handleScanResult(esp_ble_gap_cb_param_t* param); + void handleScanComplete(); + void handleAdvStart(esp_bt_status_t status); + void handleAdvStop(); + + // GATTS events (peripheral/server) + void handleGattsEvent(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, + esp_ble_gatts_cb_param_t* param); + void handleGattsRegister(esp_gatt_if_t gatts_if, esp_gatt_status_t status); + void handleGattsServiceCreated(uint16_t service_handle); + void handleGattsCharAdded(uint16_t attr_handle, esp_bt_uuid_t* char_uuid); + void handleGattsServiceStarted(); + void handleGattsConnect(esp_ble_gatts_cb_param_t* param); + void handleGattsDisconnect(esp_ble_gatts_cb_param_t* param); + void handleGattsWrite(esp_ble_gatts_cb_param_t* param); + void handleGattsRead(esp_ble_gatts_cb_param_t* param); + void handleGattsMtuChange(esp_ble_gatts_cb_param_t* param); + void handleGattsConfirm(esp_ble_gatts_cb_param_t* param); + + // GATTC events (central/client) + void handleGattcEvent(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t* param); + void handleGattcRegister(esp_gatt_if_t gattc_if, esp_gatt_status_t status); + void handleGattcConnect(esp_ble_gattc_cb_param_t* param); + void handleGattcDisconnect(esp_ble_gattc_cb_param_t* param); + void handleGattcSearchResult(esp_ble_gattc_cb_param_t* param); + void handleGattcSearchComplete(esp_ble_gattc_cb_param_t* param); + void handleGattcGetChar(esp_ble_gattc_cb_param_t* param); + void handleGattcNotify(esp_ble_gattc_cb_param_t* param); + void handleGattcWrite(esp_ble_gattc_cb_param_t* param); + void handleGattcRead(esp_ble_gattc_cb_param_t* param); + + //========================================================================= + // Setup Methods + //========================================================================= + + bool initBluetooth(); + bool setupGattsService(); + void buildAdvertisingData(); + void buildScanResponseData(); + + //========================================================================= + // Address Conversion + //========================================================================= + + static BLEAddress fromEspBdAddr(const esp_bd_addr_t addr, esp_ble_addr_type_t type); + static void toEspBdAddr(const BLEAddress& addr, esp_bd_addr_t out_addr); + + //========================================================================= + // Connection Management + //========================================================================= + + struct BluedroidConnection { + uint16_t conn_id = 0xFFFF; + esp_bd_addr_t peer_addr = {0}; + esp_ble_addr_type_t addr_type = BLE_ADDR_TYPE_PUBLIC; + Role local_role = Role::NONE; + uint16_t mtu = MTU::MINIMUM; + bool notifications_enabled = false; + + // Client-mode discovery handles (when we connect to a peripheral) + uint16_t service_start_handle = 0; + uint16_t service_end_handle = 0; + uint16_t rx_char_handle = 0; + uint16_t tx_char_handle = 0; + uint16_t tx_cccd_handle = 0; + uint16_t identity_char_handle = 0; + + // Discovery state + enum class DiscoveryState { + NONE, + SEARCHING_SERVICE, + GETTING_CHARS, + GETTING_DESCRIPTORS, + COMPLETE + } discovery_state = DiscoveryState::NONE; + }; + + uint16_t allocateConnHandle(); + void freeConnHandle(uint16_t handle); + BluedroidConnection* findConnection(uint16_t conn_id); + BluedroidConnection* findConnectionByAddress(const esp_bd_addr_t addr); + + //========================================================================= + // Member Variables + //========================================================================= + + // Configuration + PlatformConfig _config; + bool _initialized = false; + bool _running = false; + Bytes _identity_data; + Bytes _custom_adv_data; + + // State machines + InitState _init_state = InitState::UNINITIALIZED; + ScanState _scan_state = ScanState::IDLE; + AdvState _adv_state = AdvState::IDLE; + + // GATT interfaces (from registration events) + esp_gatt_if_t _gatts_if = ESP_GATT_IF_NONE; + esp_gatt_if_t _gattc_if = ESP_GATT_IF_NONE; + + // GATTS handles (server/peripheral mode) + uint16_t _service_handle = 0; + uint16_t _rx_char_handle = 0; // RX characteristic (central writes here) + uint16_t _tx_char_handle = 0; // TX characteristic (we notify from here) + uint16_t _tx_cccd_handle = 0; // TX CCCD (for notification enable/disable) + uint16_t _identity_char_handle = 0; // Identity characteristic (central reads) + + // Service creation state tracking + uint8_t _chars_added = 0; + static constexpr uint8_t CHARS_EXPECTED = 3; // RX, TX, Identity + + // Scan timing + uint16_t _scan_duration_ms = 0; + unsigned long _scan_start_time = 0; + + // Connection tracking + std::map _connections; + uint16_t _next_conn_handle = 1; + + // Pending connection state + volatile bool _connect_pending = false; + volatile bool _connect_success = false; + volatile int _connect_error = 0; + BLEAddress _pending_connect_address; + unsigned long _connect_start_time = 0; + uint16_t _connect_timeout_ms = 10000; + + // Service discovery serialization (Bluedroid can only handle one at a time) + uint16_t _discovery_in_progress = 0xFFFF; // conn_handle of active discovery, or 0xFFFF if none + std::queue _pending_discoveries; // queued conn_handles waiting for discovery + + // Local address cache + mutable esp_bd_addr_t _local_addr = {0}; + mutable bool _local_addr_valid = false; + + // App IDs for GATT profiles + static constexpr uint16_t GATTS_APP_ID = 0; + static constexpr uint16_t GATTC_APP_ID = 1; + + //========================================================================= + // Callbacks (application-level) + //========================================================================= + + Callbacks::OnScanResult _on_scan_result; + Callbacks::OnScanComplete _on_scan_complete; + Callbacks::OnConnected _on_connected; + Callbacks::OnDisconnected _on_disconnected; + Callbacks::OnMTUChanged _on_mtu_changed; + Callbacks::OnServicesDiscovered _on_services_discovered; + Callbacks::OnDataReceived _on_data_received; + Callbacks::OnNotifyEnabled _on_notify_enabled; + Callbacks::OnCentralConnected _on_central_connected; + Callbacks::OnCentralDisconnected _on_central_disconnected; + Callbacks::OnWriteReceived _on_write_received; + Callbacks::OnReadRequested _on_read_requested; +}; + +}} // namespace RNS::BLE + +#endif // ESP32 && USE_BLUEDROID diff --git a/lib/ble_interface/platforms/NimBLEPlatform.cpp b/lib/ble_interface/platforms/NimBLEPlatform.cpp new file mode 100644 index 0000000..7438ffc --- /dev/null +++ b/lib/ble_interface/platforms/NimBLEPlatform.cpp @@ -0,0 +1,2120 @@ +/** + * @file NimBLEPlatform.cpp + * @brief NimBLE-Arduino implementation for ESP32 + */ + +#include "NimBLEPlatform.h" + +#if defined(ESP32) && (defined(USE_NIMBLE) || defined(CONFIG_BT_NIMBLE_ENABLED)) + +#include "Log.h" +#include + +// WiFi coexistence: Check if WiFi is available and connected +// This is used to add extra delays before BLE connection attempts +#if __has_include() + #include + #define HAS_WIFI_COEX 1 +#else + #define HAS_WIFI_COEX 0 +#endif + +// NimBLE low-level GAP functions for checking stack state and native connections +extern "C" { + #include "nimble/nimble/host/include/host/ble_gap.h" + #include "nimble/nimble/host/include/host/ble_hs.h" + + int ble_gap_adv_active(void); + int ble_gap_disc_active(void); + int ble_gap_conn_active(void); +} + +namespace RNS { namespace BLE { + +//============================================================================= +// Static Member Initialization +//============================================================================= + +// Unclean shutdown flag - persists across soft reboot on ESP32 +// RTC_NOINIT_ATTR places in RTC slow memory which survives soft reset +#ifdef ESP32 +RTC_NOINIT_ATTR +#endif +bool NimBLEPlatform::_unclean_shutdown = false; + +//============================================================================= +// State Name Helpers (for logging) +//============================================================================= + +const char* masterStateName(MasterState state) { + switch (state) { + case MasterState::IDLE: return "IDLE"; + case MasterState::SCAN_STARTING: return "SCAN_STARTING"; + case MasterState::SCANNING: return "SCANNING"; + case MasterState::SCAN_STOPPING: return "SCAN_STOPPING"; + case MasterState::CONN_STARTING: return "CONN_STARTING"; + case MasterState::CONNECTING: return "CONNECTING"; + case MasterState::CONN_CANCELING: return "CONN_CANCELING"; + default: return "UNKNOWN"; + } +} + +const char* slaveStateName(SlaveState state) { + switch (state) { + case SlaveState::IDLE: return "IDLE"; + case SlaveState::ADV_STARTING: return "ADV_STARTING"; + case SlaveState::ADVERTISING: return "ADVERTISING"; + case SlaveState::ADV_STOPPING: return "ADV_STOPPING"; + default: return "UNKNOWN"; + } +} + +const char* gapStateName(GAPState state) { + switch (state) { + case GAPState::UNINITIALIZED: return "UNINITIALIZED"; + case GAPState::INITIALIZING: return "INITIALIZING"; + case GAPState::READY: return "READY"; + case GAPState::MASTER_PRIORITY: return "MASTER_PRIORITY"; + case GAPState::SLAVE_PRIORITY: return "SLAVE_PRIORITY"; + case GAPState::TRANSITIONING: return "TRANSITIONING"; + case GAPState::ERROR_RECOVERY: return "ERROR_RECOVERY"; + default: return "UNKNOWN"; + } +} + +//============================================================================= +// Constructor / Destructor +//============================================================================= + +NimBLEPlatform::NimBLEPlatform() { + // Initialize connection mutex + _conn_mutex = xSemaphoreCreateMutex(); +} + +NimBLEPlatform::~NimBLEPlatform() { + shutdown(); + if (_conn_mutex) { + vSemaphoreDelete(_conn_mutex); + _conn_mutex = nullptr; + } +} + +//============================================================================= +// Lifecycle +//============================================================================= + +bool NimBLEPlatform::initialize(const PlatformConfig& config) { + if (_initialized) { + WARNING("NimBLEPlatform: Already initialized"); + return true; + } + + _config = config; + + // Initialize NimBLE + NimBLEDevice::init(_config.device_name); + + // Address type for ESP32-S3: + // - BLE_OWN_ADDR_PUBLIC fails with error 13 (ETIMEOUT) for client connections + // - BLE_OWN_ADDR_RPA_PUBLIC_DEFAULT also fails with error 13 + // - BLE_OWN_ADDR_RANDOM works for client connections + // Using RANDOM address allows connections to work. Role negotiation is handled + // by always initiating connections and using identity-based duplicate detection. + NimBLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM); + + // Set power level (ESP32) + NimBLEDevice::setPower(ESP_PWR_LVL_P9); + + // Set MTU + NimBLEDevice::setMTU(_config.preferred_mtu); + + // Setup server (peripheral mode) + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + if (!setupServer()) { + ERROR("NimBLEPlatform: Failed to setup server"); + return false; + } + } + + // Setup scan (central mode) + if (_config.role == Role::CENTRAL || _config.role == Role::DUAL) { + if (!setupScan()) { + ERROR("NimBLEPlatform: Failed to setup scan"); + return false; + } + } + + _initialized = true; + + // Set GAP state to READY + portENTER_CRITICAL(&_state_mux); + _gap_state = GAPState::READY; + portEXIT_CRITICAL(&_state_mux); + + INFO("NimBLEPlatform: Initialized, role: " + std::string(roleToString(_config.role))); + + return true; +} + +bool NimBLEPlatform::start() { + if (!_initialized) { + ERROR("NimBLEPlatform: Not initialized"); + return false; + } + + if (_running) { + return true; + } + + // Start advertising if peripheral mode + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + if (!startAdvertising()) { + WARNING("NimBLEPlatform: Failed to start advertising"); + } + } + + _running = true; + INFO("NimBLEPlatform: Started"); + + return true; +} + +void NimBLEPlatform::stop() { + if (!_running) { + return; + } + + stopScan(); + stopAdvertising(); + disconnectAll(); + + _running = false; + INFO("NimBLEPlatform: Stopped"); +} + +void NimBLEPlatform::loop() { + if (!_running) { + return; + } + + // Check if continuous scan should stop + portENTER_CRITICAL(&_state_mux); + MasterState ms = _master_state; + portEXIT_CRITICAL(&_state_mux); + + if (ms == MasterState::SCANNING && _scan_stop_time > 0 && millis() >= _scan_stop_time) { + DEBUG("NimBLEPlatform: Stopping scan after timeout"); + stopScan(); + + if (_on_scan_complete) { + _on_scan_complete(); + } + } + + // Process operation queue + BLEOperationQueue::process(); +} + +void NimBLEPlatform::shutdown() { + INFO("NimBLEPlatform: Beginning graceful shutdown"); + + // CONC-H4: Graceful shutdown timeout for active write operations + const uint32_t SHUTDOWN_TIMEOUT_MS = 10000; + uint32_t start = millis(); + + // Stop accepting new operations by transitioning GAP state + // This prevents new connections/operations from starting + portENTER_CRITICAL(&_state_mux); + GAPState current_gap = _gap_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_gap == GAPState::READY) { + transitionGAPState(GAPState::READY, GAPState::TRANSITIONING); + } + + // Wait for active write operations to complete + while (hasActiveWriteOperations() && (millis() - start) < SHUTDOWN_TIMEOUT_MS) { + DEBUG("NimBLEPlatform: Waiting for " + std::to_string(_active_write_count.load()) + + " active write operation(s)"); + // DELAY RATIONALE: Shutdown wait polling - check every 100ms for write completion + delay(100); + } + + // Check if we timed out + if (hasActiveWriteOperations()) { + WARNING("NimBLEPlatform: Shutdown timeout (" + + std::to_string(SHUTDOWN_TIMEOUT_MS) + "ms) with " + + std::to_string(_active_write_count.load()) + " active writes - forcing close"); + _unclean_shutdown = true; + } else { + DEBUG("NimBLEPlatform: All operations complete, proceeding with clean shutdown"); + } + + // Stop advertising and scanning + stop(); + + // Disconnect and cleanup clients with mutex protection + if (xSemaphoreTake(_conn_mutex, pdMS_TO_TICKS(1000))) { + for (auto& kv : _clients) { + if (kv.second) { + NimBLEDevice::deleteClient(kv.second); + } + } + _clients.clear(); + _connections.clear(); + _discovered_devices.clear(); + _discovered_order.clear(); + xSemaphoreGive(_conn_mutex); + } else { + WARNING("NimBLEPlatform: Could not acquire mutex for cleanup - forcing cleanup"); + // Force cleanup anyway to prevent leaks + for (auto& kv : _clients) { + if (kv.second) { + NimBLEDevice::deleteClient(kv.second); + } + } + _clients.clear(); + _connections.clear(); + _discovered_devices.clear(); + _discovered_order.clear(); + } + + // Deinit NimBLE stack + if (_initialized) { + NimBLEDevice::deinit(true); + _initialized = false; + } + + _server = nullptr; + _service = nullptr; + _rx_char = nullptr; + _tx_char = nullptr; + _identity_char = nullptr; + _scan = nullptr; + _advertising_obj = nullptr; + + INFO("NimBLEPlatform: Shutdown complete" + + std::string(wasCleanShutdown() ? "" : " (unclean - verify on boot)")); +} + +bool NimBLEPlatform::isRunning() const { + return _running; +} + +//============================================================================= +// BLE Stack Recovery +//============================================================================= + +bool NimBLEPlatform::recoverBLEStack() { + // CONC-M4: Enhanced soft reset with graceful shutdown + INFO("NimBLEPlatform: Soft reset requested"); + + // Track consecutive recovery attempts using existing member variable + _lightweight_reset_fails++; + WARNING("NimBLEPlatform: Performing soft BLE reset (attempt " + + std::to_string(_lightweight_reset_fails) + ")..."); + + // If we've had too many consecutive recovery attempts without success, + // the BLE stack is truly stuck. Reboot is the only reliable fix. + if (_lightweight_reset_fails >= 5) { + ERROR("NimBLEPlatform: BLE stack unrecoverable after " + + std::to_string(_lightweight_reset_fails) + " attempts - rebooting device"); + // DELAY RATIONALE: Stack init settling - allow log message to flush before reboot + delay(100); + ESP.restart(); + return false; // Won't reach here + } + + // CONC-M4: Use graceful shutdown to wait for active operations + // This ensures write operations complete before we reset state + // Save config before shutdown clears it + PlatformConfig saved_config = _config; + + // Perform graceful shutdown (waits for writes, cleans up properly) + shutdown(); + + // Brief delay for NimBLE host task to process shutdown + // DELAY RATIONALE: Soft reset processing - allow stack to fully quiesce after deinit + delay(100); + + // Reinitialize with saved config + if (!initialize(saved_config)) { + WARNING("NimBLEPlatform: Soft reset reinitialization failed"); + // Log detailed state for debugging + ERROR("NimBLEPlatform: Soft reset failed - stack may need hard recovery (ESP.restart)"); + // Return false - caller can decide to trigger ESP.restart() if needed + return false; + } + + // Restart the platform + if (!start()) { + WARNING("NimBLEPlatform: Soft reset start failed"); + return false; + } + + // Reset failure counters on successful reset + _lightweight_reset_fails = 0; + _scan_fail_count = 0; + + INFO("NimBLEPlatform: Soft reset complete"); + return true; +} + +//============================================================================= +// State Machine Implementation +//============================================================================= + +bool NimBLEPlatform::transitionMasterState(MasterState expected, MasterState new_state) { + bool ok = false; + portENTER_CRITICAL(&_state_mux); + if (_master_state == expected) { + _master_state = new_state; + ok = true; + } + portEXIT_CRITICAL(&_state_mux); + if (ok) { + DEBUG("NimBLEPlatform: Master state: " + std::string(masterStateName(expected)) + + " -> " + std::string(masterStateName(new_state))); + } + return ok; +} + +bool NimBLEPlatform::transitionSlaveState(SlaveState expected, SlaveState new_state) { + bool ok = false; + portENTER_CRITICAL(&_state_mux); + if (_slave_state == expected) { + _slave_state = new_state; + ok = true; + } + portEXIT_CRITICAL(&_state_mux); + if (ok) { + DEBUG("NimBLEPlatform: Slave state: " + std::string(slaveStateName(expected)) + + " -> " + std::string(slaveStateName(new_state))); + } + return ok; +} + +bool NimBLEPlatform::transitionGAPState(GAPState expected, GAPState new_state) { + bool ok = false; + portENTER_CRITICAL(&_state_mux); + if (_gap_state == expected) { + _gap_state = new_state; + ok = true; + } + portEXIT_CRITICAL(&_state_mux); + if (ok) { + DEBUG("NimBLEPlatform: GAP state: " + std::string(gapStateName(expected)) + + " -> " + std::string(gapStateName(new_state))); + } + return ok; +} + +bool NimBLEPlatform::canStartScan() const { + bool ok = false; + portENTER_CRITICAL(&_state_mux); + ok = (_gap_state == GAPState::READY || _gap_state == GAPState::MASTER_PRIORITY) + && _master_state == MasterState::IDLE + && !ble_gap_disc_active() + && !ble_gap_conn_active(); // Also check no connection in progress + portEXIT_CRITICAL(&_state_mux); + return ok; +} + +bool NimBLEPlatform::canStartAdvertising() const { + bool ok = false; + portENTER_CRITICAL(&_state_mux); + ok = (_gap_state == GAPState::READY || _gap_state == GAPState::SLAVE_PRIORITY) + && _slave_state == SlaveState::IDLE + && !ble_gap_adv_active(); + portEXIT_CRITICAL(&_state_mux); + return ok; +} + +bool NimBLEPlatform::canConnect() const { + bool ok = false; + portENTER_CRITICAL(&_state_mux); + ok = (_gap_state == GAPState::READY || _gap_state == GAPState::MASTER_PRIORITY) + && _master_state == MasterState::IDLE + && !ble_gap_conn_active(); + portEXIT_CRITICAL(&_state_mux); + return ok; +} + +bool NimBLEPlatform::pauseSlaveForMaster() { + // Check if slave is currently advertising + portENTER_CRITICAL(&_state_mux); + SlaveState current_slave = _slave_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_slave == SlaveState::IDLE) { + DEBUG("NimBLEPlatform: Slave already idle, no pause needed"); + return true; // Already idle + } + + if (current_slave == SlaveState::ADVERTISING) { + // Transition to stopping + if (!transitionSlaveState(SlaveState::ADVERTISING, SlaveState::ADV_STOPPING)) { + WARNING("NimBLEPlatform: Failed to transition slave to ADV_STOPPING"); + return false; + } + + // Stop advertising + if (_advertising_obj) { + _advertising_obj->stop(); + } + + // Also stop at low level + if (ble_gap_adv_active()) { + ble_gap_adv_stop(); + } + + // Wait for advertising to stop + uint32_t start = millis(); + while (ble_gap_adv_active() && millis() - start < 2000) { + // DELAY RATIONALE: Advertising stop polling - check completion every NimBLE scheduler tick (~10ms) + delay(10); + } + + if (ble_gap_adv_active()) { + ERROR("NimBLEPlatform: Advertising didn't stop within 2s"); + // Force state to IDLE anyway + portENTER_CRITICAL(&_state_mux); + _slave_state = SlaveState::IDLE; + portEXIT_CRITICAL(&_state_mux); + return false; + } + + // Transition to IDLE + portENTER_CRITICAL(&_state_mux); + _slave_state = SlaveState::IDLE; + portEXIT_CRITICAL(&_state_mux); + + _slave_paused_for_master = true; + DEBUG("NimBLEPlatform: Slave paused for master operation"); + return true; + } + + // In other states (ADV_STARTING, ADV_STOPPING), wait for completion + uint32_t start = millis(); + while (millis() - start < 2000) { + portENTER_CRITICAL(&_state_mux); + current_slave = _slave_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_slave == SlaveState::IDLE) { + _slave_paused_for_master = true; + return true; + } + // DELAY RATIONALE: Slave state polling - check completion every NimBLE scheduler tick (~10ms) + delay(10); + } + + WARNING("NimBLEPlatform: Timed out waiting for slave to become idle"); + return false; +} + +void NimBLEPlatform::resumeSlave() { + // Atomically check and clear the paused flag to prevent race conditions + bool should_resume = false; + portENTER_CRITICAL(&_state_mux); + if (_slave_paused_for_master) { + _slave_paused_for_master = false; + should_resume = true; + } + portEXIT_CRITICAL(&_state_mux); + + if (!should_resume) { + return; + } + + // Only restart advertising if in peripheral/dual mode + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + DEBUG("NimBLEPlatform: Resuming slave (restarting advertising)"); + startAdvertising(); + } +} + +void NimBLEPlatform::enterErrorRecovery() { + WARNING("NimBLEPlatform: Entering error recovery"); + + // Reset all states atomically + portENTER_CRITICAL(&_state_mux); + _gap_state = GAPState::ERROR_RECOVERY; + _master_state = MasterState::IDLE; + _slave_state = SlaveState::IDLE; + portEXIT_CRITICAL(&_state_mux); + + // Force stop all operations at low level first + if (ble_gap_disc_active()) { + ble_gap_disc_cancel(); + } + if (ble_gap_adv_active()) { + ble_gap_adv_stop(); + } + + // Stop high level objects + if (_scan) { + _scan->stop(); + } + if (_advertising_obj) { + _advertising_obj->stop(); + } + + _scan_stop_time = 0; + _slave_paused_for_master = false; + + // Wait for host to sync after any reset operation + // This is critical - the host needs time to fully reset and resync with controller + if (!ble_hs_synced()) { + DEBUG("NimBLEPlatform: Waiting for host sync during recovery..."); + uint32_t sync_start = millis(); + while (!ble_hs_synced() && (millis() - sync_start) < 3000) { + // DELAY RATIONALE: Error recovery before retry + // After BLE operation failure, NimBLE stack needs processing time before retry. + // Without delay: immediate retry fails, rapid retries can trigger assertions. + // 50ms = 5 NimBLE scheduler ticks, empirically chosen balance of recovery vs latency. + delay(50); + } + if (ble_hs_synced()) { + DEBUG("NimBLEPlatform: Host sync restored after " + + std::to_string(millis() - sync_start) + "ms"); + } else { + ERROR("NimBLEPlatform: Host sync failed during recovery"); + // Continue anyway - may recover on next attempt + } + } + + // DELAY RATIONALE: Connect attempt recovery - ESP32-S3 settling time after host sync + delay(50); + + // Re-acquire scan object to reset NimBLE internal state + // This is necessary because NimBLE scan object can get into stuck state + _scan = NimBLEDevice::getScan(); + if (_scan) { + _scan->setScanCallbacks(this, false); + _scan->setActiveScan(_config.scan_mode == ScanMode::ACTIVE); + _scan->setInterval(_config.scan_interval_ms); + _scan->setWindow(_config.scan_window_ms); + _scan->setFilterPolicy(BLE_HCI_SCAN_FILT_NO_WL); + _scan->setDuplicateFilter(true); + _scan->clearResults(); + } + + // Verify GAP is truly idle + if (!ble_gap_disc_active() && !ble_gap_adv_active() && !ble_gap_conn_active()) { + portENTER_CRITICAL(&_state_mux); + _gap_state = GAPState::READY; + portEXIT_CRITICAL(&_state_mux); + INFO("NimBLEPlatform: Error recovery complete, GAP ready"); + } else { + ERROR("NimBLEPlatform: GAP still busy after recovery attempt"); + } + + // Restart advertising if in peripheral/dual mode + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + DEBUG("NimBLEPlatform: Restarting advertising after recovery"); + startAdvertising(); + } +} + +//============================================================================= +// Central Mode - Scanning +//============================================================================= + +bool NimBLEPlatform::startScan(uint16_t duration_ms) { + if (!_scan) { + ERROR("NimBLEPlatform: Scan not initialized"); + return false; + } + + // Check current master state + portENTER_CRITICAL(&_state_mux); + MasterState current_master = _master_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_master == MasterState::SCANNING) { + _scan_fail_count = 0; // Reset on successful state + return true; + } + + // Log GAP hardware state before checking + DEBUG("NimBLEPlatform: Pre-scan GAP state: disc=" + std::to_string(ble_gap_disc_active()) + + " adv=" + std::to_string(ble_gap_adv_active()) + + " conn=" + std::to_string(ble_gap_conn_active())); + + // Verify we can start scan + if (!canStartScan()) { + DEBUG("NimBLEPlatform: Cannot start scan - state check failed" + + std::string(" master=") + masterStateName(current_master) + + " gap_disc=" + std::to_string(ble_gap_disc_active()) + + " gap_conn=" + std::to_string(ble_gap_conn_active())); + return false; + } + + // Pause slave (advertising) for master operation + if (!pauseSlaveForMaster()) { + WARNING("NimBLEPlatform: Failed to pause slave for scan"); + // Try to restart advertising in case it was stopped but flag wasn't set + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + startAdvertising(); + } + return false; + } + + // DELAY RATIONALE: MTU negotiation settling - allow stack to stabilize before scan start + delay(20); + + // Transition to SCAN_STARTING + if (!transitionMasterState(MasterState::IDLE, MasterState::SCAN_STARTING)) { + WARNING("NimBLEPlatform: Failed to transition to SCAN_STARTING"); + resumeSlave(); + return false; + } + + // Set GAP to master priority + portENTER_CRITICAL(&_state_mux); + _gap_state = GAPState::MASTER_PRIORITY; + portEXIT_CRITICAL(&_state_mux); + + uint32_t duration_sec = (duration_ms == 0) ? 0 : (duration_ms / 1000); + if (duration_sec < 1) duration_sec = 1; // Minimum 1 second + + // Clear results and reconfigure scan before starting + _scan->clearResults(); + _scan->setActiveScan(_config.scan_mode == ScanMode::ACTIVE); + _scan->setInterval(_config.scan_interval_ms); + _scan->setWindow(_config.scan_window_ms); + + DEBUG("NimBLEPlatform: Starting scan with duration=" + std::to_string(duration_sec) + "s"); + + // NimBLE 2.x: use 0 for continuous scanning (we'll stop it manually in loop()) + bool started = _scan->start(0, false); + + if (started) { + // Transition to SCANNING + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::SCANNING; + portEXIT_CRITICAL(&_state_mux); + + _scan_fail_count = 0; + _lightweight_reset_fails = 0; + _scan_stop_time = millis() + duration_ms; + DEBUG("NimBLEPlatform: Scan started, will stop in " + std::to_string(duration_ms) + "ms"); + return true; + } + + // Scan failed + ERROR("NimBLEPlatform: Failed to start scan"); + + // Reset state + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::IDLE; + _gap_state = GAPState::READY; + portEXIT_CRITICAL(&_state_mux); + + _scan_fail_count++; + if (_scan_fail_count >= SCAN_FAIL_RECOVERY_THRESHOLD) { + WARNING("NimBLEPlatform: Too many scan failures, entering error recovery"); + enterErrorRecovery(); + } + + resumeSlave(); + return false; +} + +void NimBLEPlatform::stopScan() { + portENTER_CRITICAL(&_state_mux); + MasterState current_master = _master_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_master != MasterState::SCANNING && current_master != MasterState::SCAN_STARTING) { + return; + } + + // Transition to SCAN_STOPPING + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::SCAN_STOPPING; + portEXIT_CRITICAL(&_state_mux); + + DEBUG("NimBLEPlatform: stopScan() called"); + + if (_scan) { + _scan->stop(); + } + + // Wait for scan to actually stop + uint32_t start = millis(); + while (ble_gap_disc_active() && millis() - start < 1000) { + // DELAY RATIONALE: Scan stop polling - check completion every NimBLE scheduler tick (~10ms) + delay(10); + } + + // Transition to IDLE + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::IDLE; + _gap_state = GAPState::READY; + portEXIT_CRITICAL(&_state_mux); + + _scan_stop_time = 0; + DEBUG("NimBLEPlatform: Scan stopped"); + + // Resume slave if it was paused + resumeSlave(); +} + +bool NimBLEPlatform::isScanning() const { + portENTER_CRITICAL(&_state_mux); + bool scanning = (_master_state == MasterState::SCANNING || + _master_state == MasterState::SCAN_STARTING); + portEXIT_CRITICAL(&_state_mux); + return scanning; +} + +//============================================================================= +// Central Mode - Connections +//============================================================================= + +bool NimBLEPlatform::connect(const BLEAddress& address, uint16_t timeout_ms) { + NimBLEAddress nimAddr = toNimBLE(address); + + // Rate limit connections to avoid overwhelming the BLE stack + // Non-blocking: return false if too soon, caller can retry later + static unsigned long last_connect_time = 0; + unsigned long now = millis(); + if (now - last_connect_time < 300) { // Reduced from 500ms + DEBUG("NimBLEPlatform: Connection rate limited, try again later"); + return false; // Non-blocking: fail fast instead of delay + } + last_connect_time = millis(); + + // Check if already connected + if (isConnectedTo(address)) { + WARNING("NimBLEPlatform: Already connected to " + address.toString()); + return false; + } + + // Check connection limit + if (getConnectionCount() >= _config.max_connections) { + WARNING("NimBLEPlatform: Connection limit reached"); + return false; + } + + // Verify we can connect using state machine + if (!canConnect()) { + portENTER_CRITICAL(&_state_mux); + MasterState ms = _master_state; + GAPState gs = _gap_state; + portEXIT_CRITICAL(&_state_mux); + WARNING("NimBLEPlatform: Cannot connect - state check failed" + + std::string(" master=") + masterStateName(ms) + + " gap=" + gapStateName(gs)); + return false; + } + + // Stop scanning if active + portENTER_CRITICAL(&_state_mux); + MasterState current_master = _master_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_master == MasterState::SCANNING) { + DEBUG("NimBLEPlatform: Stopping scan before connect"); + stopScan(); + } + + // Pause slave (advertising) for master operation + if (!pauseSlaveForMaster()) { + WARNING("NimBLEPlatform: Failed to pause slave for connect"); + // Try to restart advertising in case it was stopped but flag wasn't set + if (_config.role == Role::PERIPHERAL || _config.role == Role::DUAL) { + startAdvertising(); + } + return false; + } + + // Transition to CONN_STARTING + if (!transitionMasterState(MasterState::IDLE, MasterState::CONN_STARTING)) { + WARNING("NimBLEPlatform: Failed to transition to CONN_STARTING"); + resumeSlave(); + return false; + } + + // Set GAP to master priority + portENTER_CRITICAL(&_state_mux); + _gap_state = GAPState::MASTER_PRIORITY; + portEXIT_CRITICAL(&_state_mux); + + // DELAY RATIONALE: Service discovery settling - allow stack to finalize after advertising stop + delay(20); + + // Verify GAP is truly idle + if (ble_gap_disc_active() || ble_gap_adv_active()) { + ERROR("NimBLEPlatform: GAP not idle before connect, entering error recovery"); + enterErrorRecovery(); + resumeSlave(); + return false; + } + + // Check if there's still a pending connection + if (ble_gap_conn_active()) { + WARNING("NimBLEPlatform: Connection still pending in GAP, waiting..."); + uint32_t start = millis(); + while (ble_gap_conn_active() && millis() - start < 1000) { + // DELAY RATIONALE: Service discovery polling - check completion per scheduler tick + delay(10); + } + if (ble_gap_conn_active()) { + ERROR("NimBLEPlatform: GAP connection still active after timeout"); + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::IDLE; + _gap_state = GAPState::READY; + portEXIT_CRITICAL(&_state_mux); + resumeSlave(); + return false; + } + } + + // Delete any existing clients for this address to ensure clean state + NimBLEClient* existingClient = NimBLEDevice::getClientByPeerAddress(nimAddr); + while (existingClient) { + DEBUG("NimBLEPlatform: Deleting existing client for " + address.toString()); + if (existingClient->isConnected()) { + existingClient->disconnect(); + } + NimBLEDevice::deleteClient(existingClient); + existingClient = NimBLEDevice::getClientByPeerAddress(nimAddr); + } + + DEBUG("NimBLEPlatform: Connecting to " + address.toString() + + " timeout=" + std::to_string(timeout_ms / 1000) + "s"); + + // Transition to CONNECTING + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::CONNECTING; + portEXIT_CRITICAL(&_state_mux); + + // Use native NimBLE connection + bool connected = connectNative(address, timeout_ms); + + if (!connected) { + ERROR("NimBLEPlatform: Native connection failed to " + address.toString()); + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::IDLE; + _gap_state = GAPState::READY; + portEXIT_CRITICAL(&_state_mux); + resumeSlave(); + return false; + } + + // Connection succeeded - transition states + portENTER_CRITICAL(&_state_mux); + _master_state = MasterState::IDLE; + _gap_state = GAPState::READY; + portEXIT_CRITICAL(&_state_mux); + + // Remove from discovered devices cache + std::string addrKey = nimAddr.toString().c_str(); + if (xSemaphoreTake(_conn_mutex, pdMS_TO_TICKS(100))) { + auto cachedIt = _discovered_devices.find(addrKey); + if (cachedIt != _discovered_devices.end()) { + // Also remove from order tracking + auto orderIt = std::find(_discovered_order.begin(), + _discovered_order.end(), addrKey); + if (orderIt != _discovered_order.end()) { + _discovered_order.erase(orderIt); + } + _discovered_devices.erase(cachedIt); + } + xSemaphoreGive(_conn_mutex); + } else { + // CONC-M5: Log timeout failures + WARNING("NimBLEPlatform: conn_mutex timeout (100ms) during cache update"); + } + + DEBUG("NimBLEPlatform: Connection established successfully"); + + // Resume slave operations + resumeSlave(); + + return true; +} + +//============================================================================= +// Native NimBLE Connection (bypasses NimBLE-Arduino wrapper) +//============================================================================= + +int NimBLEPlatform::nativeGapEventHandler(struct ble_gap_event* event, void* arg) { + NimBLEPlatform* platform = static_cast(arg); + + switch (event->type) { + case BLE_GAP_EVENT_CONNECT: + DEBUG("NimBLEPlatform::nativeGapEventHandler: BLE_GAP_EVENT_CONNECT status=" + + std::to_string(event->connect.status) + + " handle=" + std::to_string(event->connect.conn_handle)); + + platform->_native_connect_result = event->connect.status; + if (event->connect.status == 0) { + platform->_native_connect_success = true; + platform->_native_connect_handle = event->connect.conn_handle; + // Reset failure counters on successful connection + platform->_conn_establish_fail_count = 0; + } else { + platform->_native_connect_success = false; + } + platform->_native_connect_pending = false; + break; + + case BLE_GAP_EVENT_DISCONNECT: { + uint16_t disc_handle = event->disconnect.conn.conn_handle; + int disc_reason = event->disconnect.reason; + + DEBUG("NimBLEPlatform::nativeGapEventHandler: BLE_GAP_EVENT_DISCONNECT reason=" + + std::to_string(disc_reason) + + " handle=" + std::to_string(disc_handle)); + + // If we were still waiting for connection, this is a failure + if (platform->_native_connect_pending) { + platform->_native_connect_result = disc_reason; + platform->_native_connect_success = false; + platform->_native_connect_pending = false; + + // Track connection establishment failures (574 = BLE_ERR_CONN_ESTABLISHMENT) + if (disc_reason == 574) { + platform->_conn_establish_fail_count++; + WARNING("NimBLEPlatform: Connection establishment failed (574), count=" + + std::to_string(platform->_conn_establish_fail_count)); + + // If too many consecutive failures, trigger recovery + if (platform->_conn_establish_fail_count >= CONN_ESTABLISH_FAIL_THRESHOLD) { + WARNING("NimBLEPlatform: Too many connection establishment failures, entering recovery"); + platform->_conn_establish_fail_count = 0; + platform->enterErrorRecovery(); + } + } + } + + // Clean up established connections (handles MAC rotation, out of range, etc.) + auto conn_it = platform->_connections.find(disc_handle); + if (conn_it != platform->_connections.end()) { + ConnectionHandle conn = conn_it->second; + platform->_connections.erase(conn_it); + + INFO("NimBLEPlatform: Native connection lost to " + conn.peer_address.toString() + + " reason=" + std::to_string(disc_reason)); + + // Clean up client object + auto client_it = platform->_clients.find(disc_handle); + if (client_it != platform->_clients.end()) { + if (client_it->second) { + NimBLEDevice::deleteClient(client_it->second); + } + platform->_clients.erase(client_it); + } + + // Clear operation queue for this connection + platform->clearForConnection(disc_handle); + + // Notify higher layers + if (platform->_on_disconnected) { + platform->_on_disconnected(conn, static_cast(disc_reason)); + } + + // Restart advertising if in peripheral/dual mode and not currently advertising + if ((platform->_config.role == Role::PERIPHERAL || platform->_config.role == Role::DUAL) && + !platform->isAdvertising()) { + platform->startAdvertising(); + } + } + break; + } + + default: + DEBUG("NimBLEPlatform::nativeGapEventHandler: event type=" + std::to_string(event->type)); + break; + } + + return 0; +} + +bool NimBLEPlatform::connectNative(const BLEAddress& address, uint16_t timeout_ms) { + DEBUG("NimBLEPlatform::connectNative: Starting native connection to " + address.toString()); + + // Verify host-controller sync before connection attempt + if (!ble_hs_synced()) { + WARNING("NimBLEPlatform::connectNative: Host not synced, waiting for sync"); + uint32_t sync_start = millis(); + while (!ble_hs_synced() && (millis() - sync_start) < 1000) { + // DELAY RATIONALE: Notification send retry - respect scheduler tick for queue processing + delay(10); + } + if (!ble_hs_synced()) { + ERROR("NimBLEPlatform::connectNative: Host sync timeout, entering error recovery"); + enterErrorRecovery(); + return false; + } + DEBUG("NimBLEPlatform::connectNative: Host sync restored"); + } + + // Validate address type + // BLE_ADDR_PUBLIC = 0, BLE_ADDR_RANDOM = 1, BLE_ADDR_PUBLIC_ID = 2, BLE_ADDR_RANDOM_ID = 3 + if (address.type > 3) { + ERROR("NimBLEPlatform::connectNative: Invalid address type " + std::to_string(address.type)); + return false; + } + + DEBUG("NimBLEPlatform::connectNative: Connecting to " + address.toString() + + " type=" + std::to_string(address.type) + + (address.type == 0 ? " (public)" : address.type == 1 ? " (random)" : " (other)")); + + // Build the peer address structure + ble_addr_t peer_addr; + peer_addr.type = address.type; + // NimBLE stores addresses in little-endian: val[0]=LSB, val[5]=MSB + // Our BLEAddress stores in big-endian display order: addr[0]=MSB, addr[5]=LSB + for (int i = 0; i < 6; i++) { + peer_addr.val[i] = address.addr[5 - i]; + } + + DEBUG("NimBLEPlatform::connectNative: peer_addr type=" + std::to_string(peer_addr.type) + + " val=" + std::to_string(peer_addr.val[5]) + ":" + + std::to_string(peer_addr.val[4]) + ":" + + std::to_string(peer_addr.val[3]) + ":" + + std::to_string(peer_addr.val[2]) + ":" + + std::to_string(peer_addr.val[1]) + ":" + + std::to_string(peer_addr.val[0])); + + // Connection parameters - use reasonable defaults + struct ble_gap_conn_params conn_params; + memset(&conn_params, 0, sizeof(conn_params)); + conn_params.scan_itvl = 16; // 10ms in 0.625ms units + conn_params.scan_window = 16; // 10ms in 0.625ms units + conn_params.itvl_min = 24; // 30ms in 1.25ms units + conn_params.itvl_max = 40; // 50ms in 1.25ms units + conn_params.latency = 0; + conn_params.supervision_timeout = 256; // 2560ms in 10ms units + conn_params.min_ce_len = 0; + conn_params.max_ce_len = 0; + + // Reset connection state + _native_connect_pending = true; + _native_connect_success = false; + _native_connect_result = 0; + _native_connect_handle = 0; + _native_connect_address = address; + + // Use RANDOM own address type (same as what NimBLEDevice is configured with) + uint8_t own_addr_type = BLE_OWN_ADDR_RANDOM; + + DEBUG("NimBLEPlatform::connectNative: Trying with own_addr_type=" + std::to_string(own_addr_type)); + + int rc = ble_gap_connect(own_addr_type, + &peer_addr, + timeout_ms, + &conn_params, + nativeGapEventHandler, + this); + + DEBUG("NimBLEPlatform::connectNative: ble_gap_connect returned " + std::to_string(rc)); + + if (rc != 0) { + ERROR("NimBLEPlatform::connectNative: ble_gap_connect failed with rc=" + std::to_string(rc)); + _native_connect_pending = false; + + // Special handling for host desync (rc=22 / BLE_HS_ENOTSYNCED) + if (rc == 22) { // BLE_HS_ENOTSYNCED + ERROR("NimBLEPlatform::connectNative: Host desync detected (rc=22), scheduling host reset"); + // Schedule a host reset to resynchronize with controller + ble_hs_sched_reset(0); + // DELAY RATIONALE: Soft reset processing - allow stack to process host reset + delay(50); + enterErrorRecovery(); + } + + return false; + } + + // Wait for connection to complete + unsigned long start = millis(); + while (_native_connect_pending && (millis() - start) < timeout_ms) { + // DELAY RATIONALE: Reset wait polling - check connection completion per scheduler tick + delay(10); + } + + if (_native_connect_pending) { + // Timeout - try to cancel but only if connection is still pending at GAP level + WARNING("NimBLEPlatform::connectNative: Connection timed out, cancelling"); + // Check if we're actually connecting before cancelling + if (ble_gap_conn_active()) { + int rc = ble_gap_conn_cancel(); + if (rc != 0 && rc != BLE_HS_EALREADY) { + DEBUG("NimBLEPlatform::connectNative: ble_gap_conn_cancel returned " + std::to_string(rc)); + } + } + // DELAY RATIONALE: Stack stabilization after connection cancel + delay(10); + _native_connect_pending = false; + return false; + } + + if (!_native_connect_success) { + ERROR("NimBLEPlatform::connectNative: Connection failed with result=" + + std::to_string(_native_connect_result)); + return false; + } + + // Copy volatile to local variable + uint16_t conn_handle = _native_connect_handle; + + INFO("NimBLEPlatform::connectNative: Connection succeeded! handle=" + + std::to_string(conn_handle)); + + // Now create an NimBLEClient for this connection to use for GATT operations + // The connection already exists, so we just need to wrap it + NimBLEClient* client = NimBLEDevice::getClientByHandle(conn_handle); + if (!client) { + // Create a new client and associate it with this connection + NimBLEAddress nimAddr(peer_addr); + client = NimBLEDevice::createClient(nimAddr); + if (client) { + client->setClientCallbacks(this, false); + } + } + + if (client) { + // Track the connection + ConnectionHandle conn; + conn.handle = conn_handle; + conn.peer_address = address; + conn.local_role = Role::CENTRAL; + conn.state = ConnectionState::CONNECTED; + conn.mtu = client->getMTU(); + + _connections[conn_handle] = conn; + _clients[conn_handle] = client; + + if (_on_connected) { + _on_connected(conn); + } + } + + return true; +} + +bool NimBLEPlatform::disconnect(uint16_t conn_handle) { + auto conn_it = _connections.find(conn_handle); + if (conn_it == _connections.end()) { + return false; + } + + ConnectionHandle& conn = conn_it->second; + + if (conn.local_role == Role::CENTRAL) { + // We are central - disconnect client + auto client_it = _clients.find(conn_handle); + if (client_it != _clients.end() && client_it->second) { + client_it->second->disconnect(); + return true; + } + } else { + // We are peripheral - disconnect via server + if (_server) { + _server->disconnect(conn_handle); + return true; + } + } + + return false; +} + +void NimBLEPlatform::disconnectAll() { + // Disconnect all clients (central mode) + for (auto& kv : _clients) { + if (kv.second && kv.second->isConnected()) { + kv.second->disconnect(); + } + } + + // Disconnect all server connections (peripheral mode) + if (_server) { + std::vector handles; + for (const auto& kv : _connections) { + if (kv.second.local_role == Role::PERIPHERAL) { + handles.push_back(kv.first); + } + } + for (uint16_t handle : handles) { + _server->disconnect(handle); + } + } +} + +bool NimBLEPlatform::requestMTU(uint16_t conn_handle, uint16_t mtu) { + auto client_it = _clients.find(conn_handle); + if (client_it == _clients.end() || !client_it->second) { + return false; + } + + // NimBLE handles MTU exchange automatically, but we can try to update + // The MTU change callback will be invoked + return true; +} + +bool NimBLEPlatform::discoverServices(uint16_t conn_handle) { + auto client_it = _clients.find(conn_handle); + if (client_it == _clients.end() || !client_it->second) { + return false; + } + + NimBLEClient* client = client_it->second; + + // Get our service + NimBLERemoteService* service = client->getService(UUID::SERVICE); + if (!service) { + ERROR("NimBLEPlatform: Service not found"); + if (_on_services_discovered) { + ConnectionHandle conn = getConnection(conn_handle); + _on_services_discovered(conn, false); + } + return false; + } + + // Get characteristics + NimBLERemoteCharacteristic* rxChar = service->getCharacteristic(UUID::RX_CHAR); + NimBLERemoteCharacteristic* txChar = service->getCharacteristic(UUID::TX_CHAR); + NimBLERemoteCharacteristic* idChar = service->getCharacteristic(UUID::IDENTITY_CHAR); + + if (!rxChar || !txChar) { + ERROR("NimBLEPlatform: Required characteristics not found"); + if (_on_services_discovered) { + ConnectionHandle conn = getConnection(conn_handle); + _on_services_discovered(conn, false); + } + return false; + } + + // Update connection with characteristic handles + auto conn_it = _connections.find(conn_handle); + if (conn_it != _connections.end()) { + conn_it->second.rx_char_handle = rxChar->getHandle(); + conn_it->second.tx_char_handle = txChar->getHandle(); + if (idChar) { + conn_it->second.identity_handle = idChar->getHandle(); + } + conn_it->second.state = ConnectionState::READY; + } + + DEBUG("NimBLEPlatform: Services discovered for " + std::to_string(conn_handle)); + + if (_on_services_discovered) { + ConnectionHandle conn = getConnection(conn_handle); + _on_services_discovered(conn, true); + } + + return true; +} + +//============================================================================= +// Peripheral Mode +//============================================================================= + +bool NimBLEPlatform::startAdvertising() { + if (!_advertising_obj) { + if (!setupAdvertising()) { + return false; + } + } + + // Check current slave state + portENTER_CRITICAL(&_state_mux); + SlaveState current_slave = _slave_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_slave == SlaveState::ADVERTISING) { + return true; + } + + // Check if we can start advertising + if (!canStartAdvertising()) { + DEBUG("NimBLEPlatform: Cannot start advertising - state check failed" + + std::string(" slave=") + slaveStateName(current_slave) + + " gap_adv=" + std::to_string(ble_gap_adv_active())); + return false; + } + + // Transition to ADV_STARTING + if (!transitionSlaveState(SlaveState::IDLE, SlaveState::ADV_STARTING)) { + WARNING("NimBLEPlatform: Failed to transition to ADV_STARTING"); + return false; + } + + if (_advertising_obj->start()) { + // Transition to ADVERTISING + portENTER_CRITICAL(&_state_mux); + _slave_state = SlaveState::ADVERTISING; + portEXIT_CRITICAL(&_state_mux); + + DEBUG("NimBLEPlatform: Advertising started"); + return true; + } + + // Failed to start + portENTER_CRITICAL(&_state_mux); + _slave_state = SlaveState::IDLE; + portEXIT_CRITICAL(&_state_mux); + + ERROR("NimBLEPlatform: Failed to start advertising"); + return false; +} + +void NimBLEPlatform::stopAdvertising() { + portENTER_CRITICAL(&_state_mux); + SlaveState current_slave = _slave_state; + portEXIT_CRITICAL(&_state_mux); + + if (current_slave != SlaveState::ADVERTISING && current_slave != SlaveState::ADV_STARTING) { + return; + } + + // Transition to ADV_STOPPING + portENTER_CRITICAL(&_state_mux); + _slave_state = SlaveState::ADV_STOPPING; + portEXIT_CRITICAL(&_state_mux); + + DEBUG("NimBLEPlatform: stopAdvertising() called"); + + if (_advertising_obj) { + _advertising_obj->stop(); + } + + // Also stop at low level + if (ble_gap_adv_active()) { + ble_gap_adv_stop(); + } + + // Wait for advertising to actually stop + uint32_t start = millis(); + while (ble_gap_adv_active() && millis() - start < 1000) { + // DELAY RATIONALE: Loop iteration throttle - prevent tight loop CPU consumption + delay(10); + } + + // Transition to IDLE + portENTER_CRITICAL(&_state_mux); + _slave_state = SlaveState::IDLE; + portEXIT_CRITICAL(&_state_mux); + + DEBUG("NimBLEPlatform: Advertising stopped"); +} + +bool NimBLEPlatform::isAdvertising() const { + portENTER_CRITICAL(&_state_mux); + bool advertising = (_slave_state == SlaveState::ADVERTISING || + _slave_state == SlaveState::ADV_STARTING); + portEXIT_CRITICAL(&_state_mux); + return advertising; +} + +bool NimBLEPlatform::setAdvertisingData(const Bytes& data) { + // Custom advertising data not directly supported by high-level API + // Use the service UUID instead + return true; +} + +void NimBLEPlatform::setIdentityData(const Bytes& identity) { + _identity_data = identity; + + if (_identity_char && identity.size() > 0) { + _identity_char->setValue(identity.data(), identity.size()); + DEBUG("NimBLEPlatform: Identity data set"); + } + + // Update device name to include identity prefix (Protocol v2.2) + // Format: "RNS-" + first 3 bytes of identity as hex (6 chars) + // This allows peers to recognize us across MAC rotations + if (identity.size() >= 3 && _advertising_obj) { + char name[11]; // "RNS-" (4) + 6 hex chars + null + snprintf(name, sizeof(name), "RNS-%02x%02x%02x", + identity.data()[0], identity.data()[1], identity.data()[2]); + + _advertising_obj->setName(name); + DEBUG("NimBLEPlatform: Updated advertised name to " + std::string(name)); + + // Restart advertising if currently active to apply new name + if (isAdvertising()) { + stopAdvertising(); + startAdvertising(); + } + } +} + +//============================================================================= +// GATT Operations +//============================================================================= + +bool NimBLEPlatform::write(uint16_t conn_handle, const Bytes& data, bool response) { + auto conn_it = _connections.find(conn_handle); + if (conn_it == _connections.end()) { + return false; + } + + ConnectionHandle& conn = conn_it->second; + + if (conn.local_role == Role::CENTRAL) { + // We are central - write to peripheral's RX characteristic + auto client_it = _clients.find(conn_handle); + if (client_it == _clients.end() || !client_it->second) { + return false; + } + + NimBLEClient* client = client_it->second; + NimBLERemoteService* service = client->getService(UUID::SERVICE); + if (!service) return false; + + NimBLERemoteCharacteristic* rxChar = service->getCharacteristic(UUID::RX_CHAR); + if (!rxChar) return false; + + // CONC-H4: Track active write for graceful shutdown + beginWriteOperation(); + bool result = rxChar->writeValue(data.data(), data.size(), response); + endWriteOperation(); + return result; + } else { + // We are peripheral - this shouldn't be used, use notify instead + WARNING("NimBLEPlatform: write() called in peripheral mode, use notify()"); + return false; + } +} + +bool NimBLEPlatform::read(uint16_t conn_handle, uint16_t char_handle, + std::function callback) { + auto client_it = _clients.find(conn_handle); + if (client_it == _clients.end() || !client_it->second) { + if (callback) callback(OperationResult::NOT_FOUND, Bytes()); + return false; + } + + NimBLEClient* client = client_it->second; + NimBLERemoteService* service = client->getService(UUID::SERVICE); + if (!service) { + if (callback) callback(OperationResult::NOT_FOUND, Bytes()); + return false; + } + + // Find characteristic by handle + NimBLERemoteCharacteristic* chr = nullptr; + if (char_handle == _connections[conn_handle].identity_handle) { + chr = service->getCharacteristic(UUID::IDENTITY_CHAR); + } + + if (!chr) { + if (callback) callback(OperationResult::NOT_FOUND, Bytes()); + return false; + } + + NimBLEAttValue value = chr->readValue(); + if (callback) { + Bytes result(value.data(), value.size()); + callback(OperationResult::SUCCESS, result); + } + + return true; +} + +bool NimBLEPlatform::enableNotifications(uint16_t conn_handle, bool enable) { + auto client_it = _clients.find(conn_handle); + if (client_it == _clients.end() || !client_it->second) { + return false; + } + + NimBLEClient* client = client_it->second; + NimBLERemoteService* service = client->getService(UUID::SERVICE); + if (!service) return false; + + NimBLERemoteCharacteristic* txChar = service->getCharacteristic(UUID::TX_CHAR); + if (!txChar) return false; + + if (enable) { + // Subscribe to notifications + auto notifyCb = [this, conn_handle](NimBLERemoteCharacteristic* pChar, + uint8_t* pData, size_t length, bool isNotify) { + if (_on_data_received) { + ConnectionHandle conn = getConnection(conn_handle); + Bytes data(pData, length); + _on_data_received(conn, data); + } + }; + + return txChar->subscribe(true, notifyCb); + } else { + return txChar->unsubscribe(); + } +} + +bool NimBLEPlatform::notify(uint16_t conn_handle, const Bytes& data) { + if (!_tx_char) { + return false; + } + + _tx_char->setValue(data.data(), data.size()); + return _tx_char->notify(true); +} + +bool NimBLEPlatform::notifyAll(const Bytes& data) { + if (!_tx_char) { + return false; + } + + _tx_char->setValue(data.data(), data.size()); + return _tx_char->notify(true); // Notifies all subscribed clients +} + +//============================================================================= +// Connection Management +//============================================================================= + +std::vector NimBLEPlatform::getConnections() const { + std::vector result; + for (const auto& kv : _connections) { + result.push_back(kv.second); + } + return result; +} + +ConnectionHandle NimBLEPlatform::getConnection(uint16_t handle) const { + auto it = _connections.find(handle); + if (it != _connections.end()) { + return it->second; + } + return ConnectionHandle(); +} + +size_t NimBLEPlatform::getConnectionCount() const { + return _connections.size(); +} + +bool NimBLEPlatform::isConnectedTo(const BLEAddress& address) const { + for (const auto& kv : _connections) { + if (kv.second.peer_address == address) { + return true; + } + } + return false; +} + +bool NimBLEPlatform::isDeviceConnected(const std::string& addrKey) const { + for (const auto& [handle, conn] : _connections) { + if (conn.peer_address.toString() == addrKey) { + return true; + } + } + return false; +} + +//============================================================================= +// Callback Registration +//============================================================================= + +void NimBLEPlatform::setOnScanResult(Callbacks::OnScanResult callback) { + _on_scan_result = callback; +} + +void NimBLEPlatform::setOnScanComplete(Callbacks::OnScanComplete callback) { + _on_scan_complete = callback; +} + +void NimBLEPlatform::setOnConnected(Callbacks::OnConnected callback) { + _on_connected = callback; +} + +void NimBLEPlatform::setOnDisconnected(Callbacks::OnDisconnected callback) { + _on_disconnected = callback; +} + +void NimBLEPlatform::setOnMTUChanged(Callbacks::OnMTUChanged callback) { + _on_mtu_changed = callback; +} + +void NimBLEPlatform::setOnServicesDiscovered(Callbacks::OnServicesDiscovered callback) { + _on_services_discovered = callback; +} + +void NimBLEPlatform::setOnDataReceived(Callbacks::OnDataReceived callback) { + _on_data_received = callback; +} + +void NimBLEPlatform::setOnNotifyEnabled(Callbacks::OnNotifyEnabled callback) { + _on_notify_enabled = callback; +} + +void NimBLEPlatform::setOnCentralConnected(Callbacks::OnCentralConnected callback) { + _on_central_connected = callback; +} + +void NimBLEPlatform::setOnCentralDisconnected(Callbacks::OnCentralDisconnected callback) { + _on_central_disconnected = callback; +} + +void NimBLEPlatform::setOnWriteReceived(Callbacks::OnWriteReceived callback) { + _on_write_received = callback; +} + +void NimBLEPlatform::setOnReadRequested(Callbacks::OnReadRequested callback) { + _on_read_requested = callback; +} + +BLEAddress NimBLEPlatform::getLocalAddress() const { + return fromNimBLE(NimBLEDevice::getAddress()); +} + +//============================================================================= +// NimBLE Server Callbacks (Peripheral mode) +//============================================================================= + +void NimBLEPlatform::onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) { + uint16_t conn_handle = connInfo.getConnHandle(); + + ConnectionHandle conn; + conn.handle = conn_handle; + conn.peer_address = fromNimBLE(connInfo.getAddress()); + conn.local_role = Role::PERIPHERAL; // We are peripheral, they are central + conn.state = ConnectionState::CONNECTED; + conn.mtu = MTU::MINIMUM; + + _connections[conn_handle] = conn; + + DEBUG("NimBLEPlatform: Central connected: " + conn.peer_address.toString()); + + if (_on_central_connected) { + _on_central_connected(conn); + } + + // Continue advertising to accept more connections + if (_config.role == Role::DUAL && getConnectionCount() < _config.max_connections) { + startAdvertising(); + } +} + +void NimBLEPlatform::onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) { + uint16_t conn_handle = connInfo.getConnHandle(); + + auto it = _connections.find(conn_handle); + if (it != _connections.end()) { + ConnectionHandle conn = it->second; + _connections.erase(it); + + DEBUG("NimBLEPlatform: Central disconnected: " + conn.peer_address.toString() + + " reason: " + std::to_string(reason)); + + if (_on_central_disconnected) { + _on_central_disconnected(conn); + } + } + + // Clear operation queue for this connection + BLEOperationQueue::clearForConnection(conn_handle); +} + +void NimBLEPlatform::onMTUChange(uint16_t MTU, NimBLEConnInfo& connInfo) { + uint16_t conn_handle = connInfo.getConnHandle(); + updateConnectionMTU(conn_handle, MTU); + + DEBUG("NimBLEPlatform: MTU changed to " + std::to_string(MTU) + + " for connection " + std::to_string(conn_handle)); + + if (_on_mtu_changed) { + ConnectionHandle conn = getConnection(conn_handle); + _on_mtu_changed(conn, MTU); + } +} + +//============================================================================= +// NimBLE Characteristic Callbacks +//============================================================================= + +void NimBLEPlatform::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { + uint16_t conn_handle = connInfo.getConnHandle(); + + NimBLEAttValue value = pCharacteristic->getValue(); + Bytes data(value.data(), value.size()); + + DEBUG("NimBLEPlatform::onWrite: Received " + std::to_string(data.size()) + " bytes from conn " + std::to_string(conn_handle)); + + if (_on_write_received) { + DEBUG("NimBLEPlatform::onWrite: Getting connection handle"); + ConnectionHandle conn = getConnection(conn_handle); + DEBUG("NimBLEPlatform::onWrite: Calling callback, peer=" + conn.peer_address.toString()); + _on_write_received(conn, data); + DEBUG("NimBLEPlatform::onWrite: Callback returned"); + } else { + DEBUG("NimBLEPlatform::onWrite: No callback registered"); + } +} + +void NimBLEPlatform::onRead(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { + // Identity characteristic read - return stored identity + if (pCharacteristic == _identity_char && _identity_data.size() > 0) { + pCharacteristic->setValue(_identity_data.data(), _identity_data.size()); + } +} + +void NimBLEPlatform::onSubscribe(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo, + uint16_t subValue) { + uint16_t conn_handle = connInfo.getConnHandle(); + bool enabled = (subValue > 0); + + DEBUG("NimBLEPlatform: Notifications " + std::string(enabled ? "enabled" : "disabled") + + " for connection " + std::to_string(conn_handle)); + + if (_on_notify_enabled) { + ConnectionHandle conn = getConnection(conn_handle); + _on_notify_enabled(conn, enabled); + } +} + +//============================================================================= +// NimBLE Client Callbacks (Central mode) +//============================================================================= + +void NimBLEPlatform::onConnect(NimBLEClient* pClient) { + uint16_t conn_handle = pClient->getConnHandle(); + BLEAddress peer_addr = fromNimBLE(pClient->getPeerAddress()); + + ConnectionHandle conn; + conn.handle = conn_handle; + conn.peer_address = peer_addr; + conn.local_role = Role::CENTRAL; // We are central + conn.state = ConnectionState::CONNECTED; + conn.mtu = pClient->getMTU(); + + _connections[conn_handle] = conn; + _clients[conn_handle] = pClient; + + DEBUG("NimBLEPlatform: Connected to peripheral: " + peer_addr.toString() + + " handle=" + std::to_string(conn_handle) + " mtu=" + std::to_string(conn.mtu)); + + // Signal async connect completion + _async_connect_pending = false; + _async_connect_failed = false; + + if (_on_connected) { + _on_connected(conn); + } +} + +void NimBLEPlatform::onConnectFail(NimBLEClient* pClient, int reason) { + BLEAddress peer_addr = fromNimBLE(pClient->getPeerAddress()); + ERROR("NimBLEPlatform: onConnectFail to " + peer_addr.toString() + + " reason=" + std::to_string(reason)); + + // Signal async connect failure + _async_connect_pending = false; + _async_connect_failed = true; + _async_connect_error = reason; +} + +void NimBLEPlatform::onDisconnect(NimBLEClient* pClient, int reason) { + uint16_t conn_handle = pClient->getConnHandle(); + + auto it = _connections.find(conn_handle); + if (it != _connections.end()) { + ConnectionHandle conn = it->second; + _connections.erase(it); + + DEBUG("NimBLEPlatform: Disconnected from peripheral: " + conn.peer_address.toString() + + " reason: " + std::to_string(reason)); + + if (_on_disconnected) { + _on_disconnected(conn, static_cast(reason)); + } + } + + // Remove client + _clients.erase(conn_handle); + NimBLEDevice::deleteClient(pClient); + + // Clear operation queue + BLEOperationQueue::clearForConnection(conn_handle); +} + +//============================================================================= +// NimBLE Scan Callbacks +//============================================================================= + +void NimBLEPlatform::onResult(const NimBLEAdvertisedDevice* advertisedDevice) { + // Check if device has our service UUID + bool hasService = advertisedDevice->isAdvertisingService(BLEUUID(UUID::SERVICE)); + + // Debug: log RNS device scan results with address type + if (hasService) { + DEBUG("NimBLEPlatform: RNS device found: " + std::string(advertisedDevice->getAddress().toString().c_str()) + + " type=" + std::to_string(advertisedDevice->getAddress().getType()) + + " RSSI=" + std::to_string(advertisedDevice->getRSSI()) + + " name=" + advertisedDevice->getName()); + + // Cache the full device info for later connection + // Using string key since NimBLEAdvertisedDevice stores all connection metadata + std::string addrKey = advertisedDevice->getAddress().toString().c_str(); + + // Bounded cache with connected device protection (CONC-M6) + static constexpr size_t MAX_DISCOVERED_DEVICES = 16; + while (_discovered_devices.size() >= MAX_DISCOVERED_DEVICES) { + bool evicted = false; + // Find oldest non-connected device using insertion order + for (auto it = _discovered_order.begin(); it != _discovered_order.end(); ++it) { + if (!isDeviceConnected(*it)) { + _discovered_devices.erase(*it); + _discovered_order.erase(it); + evicted = true; + break; + } + } + if (!evicted) { + // All cached devices are connected - don't cache new one + WARNING("NimBLEPlatform: Cannot cache device - all slots hold connected devices"); + return; + } + } + + // Track insertion order for new devices + auto existing = _discovered_devices.find(addrKey); + if (existing == _discovered_devices.end()) { + // New device - add to order tracking + _discovered_order.push_back(addrKey); + } + _discovered_devices[addrKey] = *advertisedDevice; + TRACE("NimBLEPlatform: Cached device for connection: " + addrKey + + " (cache size: " + std::to_string(_discovered_devices.size()) + ")"); + } + + if (hasService && _on_scan_result) { + ScanResult result; + result.address = fromNimBLE(advertisedDevice->getAddress()); + result.name = advertisedDevice->getName(); + result.rssi = advertisedDevice->getRSSI(); + result.connectable = advertisedDevice->isConnectable(); + result.has_reticulum_service = true; + + // Extract identity prefix from device name (Protocol v2.2) + // Format: "RNS-xxxxxx" where xxxxxx is 6 hex chars (3 bytes of identity) + std::string name = advertisedDevice->getName(); + if (name.size() >= 10 && name.substr(0, 4) == "RNS-") { + std::string hexPart = name.substr(4, 6); + if (hexPart.size() == 6) { + // Parse hex to bytes + uint8_t prefix[3]; + bool valid = true; + for (int i = 0; i < 3 && valid; i++) { + unsigned int val; + if (sscanf(hexPart.c_str() + i*2, "%02x", &val) == 1) { + prefix[i] = static_cast(val); + } else { + valid = false; + } + } + if (valid) { + result.identity_prefix = Bytes(prefix, 3); + DEBUG("NimBLEPlatform: Extracted identity prefix from name: " + hexPart); + } + } + } + + _on_scan_result(result); + } +} + +void NimBLEPlatform::onScanEnd(const NimBLEScanResults& results, int reason) { + // Check if we were actively scanning + portENTER_CRITICAL(&_state_mux); + MasterState prev_master = _master_state; + bool was_scanning = (prev_master == MasterState::SCANNING || + prev_master == MasterState::SCAN_STARTING || + prev_master == MasterState::SCAN_STOPPING); + // Transition to IDLE + if (was_scanning) { + _master_state = MasterState::IDLE; + _gap_state = GAPState::READY; + } + portEXIT_CRITICAL(&_state_mux); + + _scan_stop_time = 0; + + DEBUG("NimBLEPlatform: onScanEnd callback, reason=" + std::to_string(reason) + + " found=" + std::to_string(results.getCount()) + " devices" + + " was_scanning=" + std::string(was_scanning ? "yes" : "no")); + + // Only process if we were actively scanning (not a spurious callback) + if (!was_scanning) { + return; + } + + // Resume slave if it was paused for this scan + resumeSlave(); + + if (_on_scan_complete) { + _on_scan_complete(); + } +} + +//============================================================================= +// BLEOperationQueue Implementation +//============================================================================= + +bool NimBLEPlatform::executeOperation(const GATTOperation& op) { + // Most operations are executed directly in NimBLE + // This is a placeholder for more complex queued operations + return true; +} + +//============================================================================= +// Private Methods +//============================================================================= + +bool NimBLEPlatform::setupServer() { + _server = NimBLEDevice::createServer(); + if (!_server) { + ERROR("NimBLEPlatform: Failed to create server"); + return false; + } + + _server->setCallbacks(this); + + // Create Reticulum service + _service = _server->createService(UUID::SERVICE); + if (!_service) { + ERROR("NimBLEPlatform: Failed to create service"); + return false; + } + + // Create RX characteristic (write from central) + _rx_char = _service->createCharacteristic( + UUID::RX_CHAR, + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR + ); + _rx_char->setValue((uint8_t*)"\x00", 1); // Initialize to 0x00 + _rx_char->setCallbacks(this); + + // Create TX characteristic (notify/indicate to central) + // Note: indicate property required for compatibility with ble-reticulum/Columba + _tx_char = _service->createCharacteristic( + UUID::TX_CHAR, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::INDICATE + ); + _tx_char->setValue((uint8_t*)"\x00", 1); // Initialize to 0x00 (matches Columba) + _tx_char->setCallbacks(this); + + // Create Identity characteristic (read only) + _identity_char = _service->createCharacteristic( + UUID::IDENTITY_CHAR, + NIMBLE_PROPERTY::READ + ); + _identity_char->setCallbacks(this); + + // Start service + _service->start(); + + return setupAdvertising(); +} + +bool NimBLEPlatform::setupAdvertising() { + _advertising_obj = NimBLEDevice::getAdvertising(); + if (!_advertising_obj) { + ERROR("NimBLEPlatform: Failed to get advertising"); + return false; + } + + // CRITICAL: Reset advertising state before configuring + // Without this, the advertising data may not be properly updated on ESP32-S3 + _advertising_obj->reset(); + + _advertising_obj->setMinInterval(_config.adv_interval_min_ms * 1000 / 625); // Convert to 0.625ms units + _advertising_obj->setMaxInterval(_config.adv_interval_max_ms * 1000 / 625); + + // NimBLE 2.x: Use addServiceUUID to include service in advertising packet + // The name goes in scan response automatically when enableScanResponse is used + _advertising_obj->addServiceUUID(NimBLEUUID(UUID::SERVICE)); + _advertising_obj->setName(_config.device_name); + + DEBUG("NimBLEPlatform: Advertising configured with service UUID: " + std::string(UUID::SERVICE)); + + return true; +} + +bool NimBLEPlatform::setupScan() { + _scan = NimBLEDevice::getScan(); + if (!_scan) { + ERROR("NimBLEPlatform: Failed to get scan"); + return false; + } + + _scan->setScanCallbacks(this, false); + _scan->setActiveScan(_config.scan_mode == ScanMode::ACTIVE); + _scan->setInterval(_config.scan_interval_ms); + _scan->setWindow(_config.scan_window_ms); + _scan->setFilterPolicy(BLE_HCI_SCAN_FILT_NO_WL); + _scan->setDuplicateFilter(true); // Filter duplicates within a scan window + // Don't call setMaxResults - let NimBLE use defaults + + DEBUG("NimBLEPlatform: Scan configured - interval=" + std::to_string(_config.scan_interval_ms) + + " window=" + std::to_string(_config.scan_window_ms)); + + return true; +} + +BLEAddress NimBLEPlatform::fromNimBLE(const NimBLEAddress& addr) { + BLEAddress result; + const ble_addr_t* base = addr.getBase(); + if (base) { + // NimBLE stores addresses in little-endian: val[0]=LSB, val[5]=MSB + // Our BLEAddress stores in big-endian display order: addr[0]=MSB, addr[5]=LSB + // Need to reverse the byte order + for (int i = 0; i < 6; i++) { + result.addr[i] = base->val[5 - i]; + } + } + result.type = addr.getType(); + return result; +} + +NimBLEAddress NimBLEPlatform::toNimBLE(const BLEAddress& addr) { + // Use NimBLEAddress string constructor - it parses "XX:XX:XX:XX:XX:XX" format + // and handles the byte order internally + std::string addrStr = addr.toString(); + NimBLEAddress nimAddr(addrStr.c_str(), addr.type); + DEBUG("NimBLEPlatform::toNimBLE: input=" + addrStr + + " type=" + std::to_string(addr.type) + + " -> nimAddr=" + std::string(nimAddr.toString().c_str()) + + " nimType=" + std::to_string(nimAddr.getType())); + return nimAddr; +} + +NimBLEClient* NimBLEPlatform::findClient(uint16_t conn_handle) { + auto it = _clients.find(conn_handle); + return (it != _clients.end()) ? it->second : nullptr; +} + +NimBLEClient* NimBLEPlatform::findClient(const BLEAddress& address) { + for (const auto& kv : _clients) { + if (kv.second && fromNimBLE(kv.second->getPeerAddress()) == address) { + return kv.second; + } + } + return nullptr; +} + +uint16_t NimBLEPlatform::allocateConnHandle() { + return _next_conn_handle++; +} + +void NimBLEPlatform::freeConnHandle(uint16_t handle) { + // No-op for simple allocator +} + +void NimBLEPlatform::updateConnectionMTU(uint16_t conn_handle, uint16_t mtu) { + auto it = _connections.find(conn_handle); + if (it != _connections.end()) { + it->second.mtu = mtu; + } +} + +}} // namespace RNS::BLE + +#endif // ESP32 && USE_NIMBLE diff --git a/lib/ble_interface/platforms/NimBLEPlatform.h b/lib/ble_interface/platforms/NimBLEPlatform.h new file mode 100644 index 0000000..2b620d1 --- /dev/null +++ b/lib/ble_interface/platforms/NimBLEPlatform.h @@ -0,0 +1,380 @@ +/** + * @file NimBLEPlatform.h + * @brief NimBLE-Arduino implementation of IBLEPlatform for ESP32 + * + * This implementation uses the NimBLE-Arduino library to provide BLE + * functionality on ESP32 devices. It supports both central and peripheral + * modes simultaneously (dual-mode operation). + */ +#pragma once + +#include "../BLEPlatform.h" +#include "../BLEOperationQueue.h" + +// Only compile for ESP32 with NimBLE +#if defined(ESP32) && (defined(USE_NIMBLE) || defined(CONFIG_BT_NIMBLE_ENABLED)) + +#include +#include +#include + +// Undefine NimBLE's backward compatibility macros to avoid conflict with our types +#undef BLEAddress + +#include +#include +#include + +namespace RNS { namespace BLE { + +//============================================================================= +// State Machine Enums for Dual-Role BLE Operation +//============================================================================= + +/** + * @brief Master role states (Central - scanning/connecting) + */ +enum class MasterState : uint8_t { + IDLE, ///< No master operations + SCAN_STARTING, ///< Gap scan start requested + SCANNING, ///< Actively scanning + SCAN_STOPPING, ///< Gap scan stop requested + CONN_STARTING, ///< Connection initiation requested + CONNECTING, ///< Connection in progress + CONN_CANCELING ///< Connection cancel requested +}; + +/** + * @brief Slave role states (Peripheral - advertising) + */ +enum class SlaveState : uint8_t { + IDLE, ///< Not advertising + ADV_STARTING, ///< Gap adv start requested + ADVERTISING, ///< Actively advertising + ADV_STOPPING ///< Gap adv stop requested +}; + +/** + * @brief GAP coordinator state (overall BLE subsystem) + */ +enum class GAPState : uint8_t { + UNINITIALIZED, ///< BLE not started + INITIALIZING, ///< NimBLE init in progress + READY, ///< Idle, ready for operations + MASTER_PRIORITY, ///< Master operation in progress, slave paused + SLAVE_PRIORITY, ///< Slave operation in progress, master paused + TRANSITIONING, ///< State change in progress + ERROR_RECOVERY ///< Recovering from error +}; + +// State name helpers for logging +const char* masterStateName(MasterState state); +const char* slaveStateName(SlaveState state); +const char* gapStateName(GAPState state); + +/** + * @brief NimBLE-Arduino implementation of IBLEPlatform + */ +class NimBLEPlatform : public IBLEPlatform, + public BLEOperationQueue, + public NimBLEServerCallbacks, + public NimBLECharacteristicCallbacks, + public NimBLEClientCallbacks, + public NimBLEScanCallbacks { +public: + NimBLEPlatform(); + virtual ~NimBLEPlatform(); + + //========================================================================= + // IBLEPlatform Implementation + //========================================================================= + + // Lifecycle + bool initialize(const PlatformConfig& config) override; + bool start() override; + void stop() override; + void loop() override; + void shutdown() override; + bool isRunning() const override; + + // Central mode - Scanning + bool startScan(uint16_t duration_ms = 0) override; + void stopScan() override; + bool isScanning() const override; + + // Central mode - Connections + bool connect(const BLEAddress& address, uint16_t timeout_ms = 10000) override; + bool disconnect(uint16_t conn_handle) override; + void disconnectAll() override; + bool requestMTU(uint16_t conn_handle, uint16_t mtu) override; + bool discoverServices(uint16_t conn_handle) override; + + // Peripheral mode + bool startAdvertising() override; + void stopAdvertising() override; + bool isAdvertising() const override; + bool setAdvertisingData(const Bytes& data) override; + void setIdentityData(const Bytes& identity) override; + + // GATT Operations + bool write(uint16_t conn_handle, const Bytes& data, bool response = true) override; + bool read(uint16_t conn_handle, uint16_t char_handle, + std::function callback) override; + bool enableNotifications(uint16_t conn_handle, bool enable) override; + bool notify(uint16_t conn_handle, const Bytes& data) override; + bool notifyAll(const Bytes& data) override; + + // Connection management + std::vector getConnections() const override; + ConnectionHandle getConnection(uint16_t handle) const override; + size_t getConnectionCount() const override; + bool isConnectedTo(const BLEAddress& address) const override; + + // Callback registration + void setOnScanResult(Callbacks::OnScanResult callback) override; + void setOnScanComplete(Callbacks::OnScanComplete callback) override; + void setOnConnected(Callbacks::OnConnected callback) override; + void setOnDisconnected(Callbacks::OnDisconnected callback) override; + void setOnMTUChanged(Callbacks::OnMTUChanged callback) override; + void setOnServicesDiscovered(Callbacks::OnServicesDiscovered callback) override; + void setOnDataReceived(Callbacks::OnDataReceived callback) override; + void setOnNotifyEnabled(Callbacks::OnNotifyEnabled callback) override; + void setOnCentralConnected(Callbacks::OnCentralConnected callback) override; + void setOnCentralDisconnected(Callbacks::OnCentralDisconnected callback) override; + void setOnWriteReceived(Callbacks::OnWriteReceived callback) override; + void setOnReadRequested(Callbacks::OnReadRequested callback) override; + + // Platform info + PlatformType getPlatformType() const override { return PlatformType::NIMBLE_ARDUINO; } + std::string getPlatformName() const override { return "NimBLE-Arduino"; } + BLEAddress getLocalAddress() const override; + + //========================================================================= + // NimBLEServerCallbacks (Peripheral mode) + //========================================================================= + + void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override; + void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override; + void onMTUChange(uint16_t MTU, NimBLEConnInfo& connInfo) override; + + //========================================================================= + // NimBLECharacteristicCallbacks + //========================================================================= + + void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override; + void onRead(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override; + void onSubscribe(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo, + uint16_t subValue) override; + + //========================================================================= + // NimBLEClientCallbacks (Central mode) + //========================================================================= + + void onConnect(NimBLEClient* pClient) override; + void onConnectFail(NimBLEClient* pClient, int reason) override; + void onDisconnect(NimBLEClient* pClient, int reason) override; + + //========================================================================= + // NimBLEScanCallbacks (Scanning) + //========================================================================= + + void onResult(const NimBLEAdvertisedDevice* advertisedDevice) override; + void onScanEnd(const NimBLEScanResults& results, int reason) override; + +protected: + // BLEOperationQueue implementation + bool executeOperation(const GATTOperation& op) override; + +private: + // Setup methods + bool setupServer(); + bool setupAdvertising(); + bool setupScan(); + + // Address conversion + static BLEAddress fromNimBLE(const NimBLEAddress& addr); + static NimBLEAddress toNimBLE(const BLEAddress& addr); + + // Find client by connection handle or address + NimBLEClient* findClient(uint16_t conn_handle); + NimBLEClient* findClient(const BLEAddress& address); + + // Connection handle management + uint16_t allocateConnHandle(); + void freeConnHandle(uint16_t handle); + + // Update connection info + void updateConnectionMTU(uint16_t conn_handle, uint16_t mtu); + + // Check if a device address is currently connected + bool isDeviceConnected(const std::string& addrKey) const; + + //========================================================================= + // State Machine Infrastructure + //========================================================================= + + // State variables (protected by spinlock) + mutable portMUX_TYPE _state_mux = portMUX_INITIALIZER_UNLOCKED; + MasterState _master_state = MasterState::IDLE; + SlaveState _slave_state = SlaveState::IDLE; + GAPState _gap_state = GAPState::UNINITIALIZED; + + // Mutex for connection map access (longer operations) + SemaphoreHandle_t _conn_mutex = nullptr; + + // State transition helpers (atomic compare-and-swap) + bool transitionMasterState(MasterState expected, MasterState new_state); + bool transitionSlaveState(SlaveState expected, SlaveState new_state); + bool transitionGAPState(GAPState expected, GAPState new_state); + + // State verification methods + bool canStartScan() const; + bool canStartAdvertising() const; + bool canConnect() const; + + // Operation coordination + bool pauseSlaveForMaster(); + void resumeSlave(); + void enterErrorRecovery(); + + // Track if slave was paused for a master operation + bool _slave_paused_for_master = false; + + //========================================================================= + // Configuration + //========================================================================= + PlatformConfig _config; + bool _initialized = false; + bool _running = false; + Bytes _identity_data; + unsigned long _scan_stop_time = 0; // millis() when to stop continuous scan + + // BLE stack recovery + uint8_t _scan_fail_count = 0; + uint8_t _lightweight_reset_fails = 0; + uint8_t _conn_establish_fail_count = 0; // rc=574 connection establishment failures + unsigned long _last_full_recovery_time = 0; + static constexpr uint8_t SCAN_FAIL_RECOVERY_THRESHOLD = 5; + static constexpr uint8_t LIGHTWEIGHT_RESET_MAX_FAILS = 3; + static constexpr uint8_t CONN_ESTABLISH_FAIL_THRESHOLD = 3; // Threshold for rc=574 + static constexpr unsigned long FULL_RECOVERY_COOLDOWN_MS = 60000; // 60 seconds + bool recoverBLEStack(); + + // NimBLE objects + NimBLEServer* _server = nullptr; + NimBLEService* _service = nullptr; + NimBLECharacteristic* _rx_char = nullptr; + NimBLECharacteristic* _tx_char = nullptr; + NimBLECharacteristic* _identity_char = nullptr; + NimBLEScan* _scan = nullptr; + NimBLEAdvertising* _advertising_obj = nullptr; + + // Client connections (as central) + std::map _clients; + + // Connection tracking + std::map _connections; + + // Cached scan results for connection (stores full device info from scan) + // Key: MAC address as string (e.g., "b8:27:eb:43:04:bc") + std::map _discovered_devices; + + // Insertion-order tracking for FIFO eviction of discovered devices + std::vector _discovered_order; + + // Connection handle allocator (NimBLE uses its own, we wrap for consistency) + uint16_t _next_conn_handle = 1; + + // VOLATILE RATIONALE: NimBLE callback synchronization flags + // + // These volatile flags synchronize between: + // 1. NimBLE host task (callback context - runs asynchronously like ISR) + // 2. BLE task (loop() context - application thread) + // + // Volatile is appropriate because: + // - Single-word reads/writes are atomic on ESP32 (32-bit aligned) + // - These are simple status flags, not complex state + // - Mutex would cause priority inversion in callback context + // - Memory barriers not needed - flag semantics sufficient + // + // Alternative rejected: Mutex acquisition in NimBLE callbacks can cause + // priority inversion or deadlock since callbacks run in host task context. + // + // Reference: ESP32 Technical Reference Manual, Section 5.4 (Memory Consistency) + + // Async connection tracking (NimBLEClientCallbacks) + volatile bool _async_connect_pending = false; + volatile bool _async_connect_failed = false; + volatile int _async_connect_error = 0; + + // VOLATILE RATIONALE: Native GAP handler callback flags + // Same rationale as above - nativeGapEventHandler runs in NimBLE host task. + // These track connection state during ble_gap_connect() operations. + volatile bool _native_connect_pending = false; + volatile bool _native_connect_success = false; + volatile int _native_connect_result = 0; + volatile uint16_t _native_connect_handle = 0; + BLEAddress _native_connect_address; + + // Native GAP event handler + static int nativeGapEventHandler(struct ble_gap_event* event, void* arg); + bool connectNative(const BLEAddress& address, uint16_t timeout_ms); + + // Callbacks + Callbacks::OnScanResult _on_scan_result; + Callbacks::OnScanComplete _on_scan_complete; + Callbacks::OnConnected _on_connected; + Callbacks::OnDisconnected _on_disconnected; + Callbacks::OnMTUChanged _on_mtu_changed; + Callbacks::OnServicesDiscovered _on_services_discovered; + Callbacks::OnDataReceived _on_data_received; + Callbacks::OnNotifyEnabled _on_notify_enabled; + Callbacks::OnCentralConnected _on_central_connected; + Callbacks::OnCentralDisconnected _on_central_disconnected; + Callbacks::OnWriteReceived _on_write_received; + Callbacks::OnReadRequested _on_read_requested; + + //========================================================================= + // BLE Shutdown Safety (CONC-H4, CONC-M4) + //========================================================================= + + // Unclean shutdown flag - set if forced shutdown occurred with active operations + // Uses RTC_NOINIT_ATTR on ESP32 for persistence across soft reboot + static bool _unclean_shutdown; + + // Active write operation tracking (atomic for callback safety) + std::atomic _active_write_count{0}; + +public: + /** + * Check if there are active write operations in progress. + * Write operations are critical - interrupting can corrupt peer state. + */ + bool hasActiveWriteOperations() const { return _active_write_count.load() > 0; } + + /** + * Check if last shutdown was clean. + * Returns false if BLE was force-closed with active operations. + */ + static bool wasCleanShutdown() { return !_unclean_shutdown; } + + /** + * Clear unclean shutdown flag (call after boot verification). + */ + static void clearUncleanShutdownFlag() { _unclean_shutdown = false; } + +private: + /** + * Mark a write operation as starting (call before characteristic write). + */ + void beginWriteOperation() { _active_write_count.fetch_add(1); } + + /** + * Mark a write operation as complete (call after write callback). + */ + void endWriteOperation() { _active_write_count.fetch_sub(1); } +}; + +}} // namespace RNS::BLE + +#endif // ESP32 && USE_NIMBLE diff --git a/lib/lv_conf.h b/lib/lv_conf.h new file mode 100644 index 0000000..428fd31 --- /dev/null +++ b/lib/lv_conf.h @@ -0,0 +1,188 @@ +/** + * @file lv_conf.h + * Configuration file for LittlevGL v8.3.x + * For T-Deck Plus LXMF Messenger + */ + +#ifndef LV_CONF_H +#define LV_CONF_H + +#include + +/*==================== + COLOR SETTINGS + *====================*/ +#define LV_COLOR_DEPTH 16 +#define LV_COLOR_16_SWAP 1 /* Swap bytes for ST7789 (big-endian) */ +#define LV_COLOR_MIX_ROUND_OFS 128 /* Round to nearest color for better anti-aliasing */ + +/*==================== + MEMORY SETTINGS + *====================*/ +/* Hybrid allocator: small allocations use fast internal RAM, + * large allocations (>1KB) use PSRAM to preserve internal heap. + * This balances UI performance with memory availability. */ +#define LV_MEM_CUSTOM 1 +#define LV_MEM_CUSTOM_INCLUDE "lv_mem_hybrid.h" +#define LV_MEM_CUSTOM_ALLOC lv_mem_hybrid_alloc +#define LV_MEM_CUSTOM_FREE lv_mem_hybrid_free +#define LV_MEM_CUSTOM_REALLOC lv_mem_hybrid_realloc + +/*==================== + HAL SETTINGS + *====================*/ +/* Default display refresh period in milliseconds */ +#define LV_DISP_DEF_REFR_PERIOD 16 /* ~60 FPS */ + +/* Input device read period in milliseconds */ +#define LV_INDEV_DEF_READ_PERIOD 10 + +/* Use a custom tick source that tells LVGL elapsed time */ +#define LV_TICK_CUSTOM 1 +#if LV_TICK_CUSTOM + #define LV_TICK_CUSTOM_INCLUDE + #define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) +#endif + +/*==================== + FEATURE CONFIGURATION + *====================*/ +/* Enable GPU support if available (ESP32-S3 doesn't have LVGL GPU) */ +#define LV_USE_GPU_ESP32 0 + +/* Drawing */ +#define LV_DRAW_COMPLEX 1 +#define LV_SHADOW_CACHE_SIZE 0 +#define LV_CIRCLE_CACHE_SIZE 4 +#define LV_IMG_CACHE_DEF_SIZE 0 +#define LV_GRADIENT_MAX_STOPS 2 +#define LV_GRAD_CACHE_DEF_SIZE 0 +#define LV_DITHER_GRADIENT 0 +#define LV_DISP_ROT_MAX_BUF (10*1024) + +/*==================== + LOG SETTINGS + *====================*/ +#define LV_USE_LOG 0 + +/*==================== + FONT SETTINGS + *====================*/ +#define LV_FONT_MONTSERRAT_8 0 +#define LV_FONT_MONTSERRAT_10 0 +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_FONT_MONTSERRAT_18 0 +#define LV_FONT_MONTSERRAT_20 0 +#define LV_FONT_MONTSERRAT_22 0 +#define LV_FONT_MONTSERRAT_24 0 +#define LV_FONT_MONTSERRAT_26 0 +#define LV_FONT_MONTSERRAT_28 0 +#define LV_FONT_MONTSERRAT_30 0 +#define LV_FONT_MONTSERRAT_32 0 +#define LV_FONT_MONTSERRAT_34 0 +#define LV_FONT_MONTSERRAT_36 0 +#define LV_FONT_MONTSERRAT_38 0 +#define LV_FONT_MONTSERRAT_40 0 +#define LV_FONT_MONTSERRAT_42 0 +#define LV_FONT_MONTSERRAT_44 0 +#define LV_FONT_MONTSERRAT_46 0 +#define LV_FONT_MONTSERRAT_48 0 + +#define LV_FONT_DEFAULT &lv_font_montserrat_14 + +#define LV_FONT_FMT_TXT_LARGE 0 +#define LV_USE_FONT_COMPRESSED 0 +#define LV_USE_FONT_SUBPX 0 + +/*==================== + TEXT SETTINGS + *====================*/ +#define LV_TXT_ENC LV_TXT_ENC_UTF8 +#define LV_TXT_BREAK_CHARS " ,.;:-_" +#define LV_TXT_LINE_BREAK_LONG_LEN 0 +#define LV_TXT_LINE_BREAK_LONG_PRE_MIN_LEN 3 +#define LV_TXT_LINE_BREAK_LONG_POST_MIN_LEN 3 +#define LV_TXT_COLOR_CMD "#" +#define LV_USE_BIDI 0 +#define LV_USE_ARABIC_PERSIAN_CHARS 0 + +/*==================== + WIDGET USAGE + *====================*/ +#define LV_USE_ARC 1 +#define LV_USE_BAR 1 +#define LV_USE_BTN 1 +#define LV_USE_BTNMATRIX 1 +#define LV_USE_CANVAS 1 /* Required for QR code */ +#define LV_USE_CHECKBOX 1 +#define LV_USE_DROPDOWN 1 +#define LV_USE_IMG 1 +#define LV_USE_LABEL 1 +#define LV_LABEL_TEXT_SELECTION 1 +#define LV_LABEL_LONG_TXT_HINT 1 +#define LV_USE_LINE 1 +#define LV_USE_ROLLER 1 +#define LV_ROLLER_INF_PAGES 7 +#define LV_USE_SLIDER 1 +#define LV_USE_SWITCH 1 +#define LV_USE_TEXTAREA 1 +#define LV_TEXTAREA_DEF_PWD_SHOW_TIME 1500 +#define LV_USE_TABLE 1 + +/*==================== + EXTRA WIDGETS + *====================*/ +#define LV_USE_ANIMIMG 0 +#define LV_USE_CALENDAR 0 +#define LV_USE_CHART 0 +#define LV_USE_COLORWHEEL 0 +#define LV_USE_IMGBTN 1 +#define LV_USE_KEYBOARD 1 +#define LV_USE_LED 0 +#define LV_USE_LIST 1 +#define LV_USE_MENU 0 +#define LV_USE_METER 0 +#define LV_USE_MSGBOX 1 +#define LV_USE_SPAN 0 +#define LV_USE_SPINBOX 0 +#define LV_USE_SPINNER 1 +#define LV_USE_TABVIEW 0 +#define LV_USE_TILEVIEW 0 +#define LV_USE_WIN 0 + +/*==================== + 3RD PARTY LIBRARIES + *====================*/ +#define LV_USE_QRCODE 1 + +/*==================== + THEMES + *====================*/ +#define LV_USE_THEME_DEFAULT 1 +#define LV_THEME_DEFAULT_DARK 1 +#define LV_THEME_DEFAULT_GROW 1 +#define LV_THEME_DEFAULT_TRANSITION_TIME 80 +#define LV_USE_THEME_BASIC 1 +#define LV_USE_THEME_MONO 0 + +/*==================== + LAYOUTS + *====================*/ +#define LV_USE_FLEX 1 +#define LV_USE_GRID 1 + +/*==================== + OTHER SETTINGS + *====================*/ +#define LV_USE_ASSERT_NULL 1 +#define LV_USE_ASSERT_MALLOC 1 +#define LV_USE_ASSERT_STYLE 0 +#define LV_USE_ASSERT_MEM_INTEGRITY 0 +#define LV_USE_ASSERT_OBJ 0 + +#define LV_SPRINTF_CUSTOM 0 +#define LV_SPRINTF_USE_FLOAT 0 + +#endif /* LV_CONF_H */ diff --git a/lib/lv_mem_hybrid.h b/lib/lv_mem_hybrid.h new file mode 100644 index 0000000..62f127b --- /dev/null +++ b/lib/lv_mem_hybrid.h @@ -0,0 +1,93 @@ +/** + * @file lv_mem_hybrid.h + * @brief Hybrid memory allocator for LVGL + * + * Uses internal RAM for small allocations (fast, good for UI responsiveness) + * and PSRAM for large allocations (preserves internal heap for BLE/network). + */ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Threshold: allocations larger than this go to PSRAM */ +#define LV_MEM_HYBRID_PSRAM_THRESHOLD 1024 + +/* Track which allocations went to PSRAM vs internal RAM */ +/* We use a simple heuristic: PSRAM addresses are above 0x3C000000 on ESP32-S3 */ +#define IS_PSRAM_ADDR(ptr) ((uintptr_t)(ptr) >= 0x3C000000) + +static inline void* lv_mem_hybrid_alloc(size_t size) { + void* ptr; + + if (size >= LV_MEM_HYBRID_PSRAM_THRESHOLD) { + /* Large allocation -> PSRAM */ + ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr) return ptr; + /* Fall back to internal if PSRAM fails */ + } + + /* Small allocation or PSRAM fallback -> internal RAM */ + ptr = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (ptr) return ptr; + + /* Last resort: try PSRAM for small allocs if internal is exhausted */ + return heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +} + +static inline void lv_mem_hybrid_free(void* ptr) { + if (ptr) { + heap_caps_free(ptr); + } +} + +static inline void* lv_mem_hybrid_realloc(void* ptr, size_t size) { + if (ptr == NULL) { + return lv_mem_hybrid_alloc(size); + } + + if (size == 0) { + lv_mem_hybrid_free(ptr); + return NULL; + } + + /* Determine where the original allocation was */ + if (IS_PSRAM_ADDR(ptr)) { + /* Was in PSRAM, keep it there */ + void* new_ptr = heap_caps_realloc(ptr, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (new_ptr) return new_ptr; + /* Fall back to internal if PSRAM realloc fails */ + new_ptr = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (new_ptr) { + /* Copy and free old - can't use realloc across memory types */ + /* We don't know old size, so this is a best-effort fallback */ + heap_caps_free(ptr); + return new_ptr; + } + return NULL; + } else { + /* Was in internal RAM */ + if (size >= LV_MEM_HYBRID_PSRAM_THRESHOLD) { + /* Growing to large size - move to PSRAM */ + void* new_ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (new_ptr) { + heap_caps_free(ptr); + return new_ptr; + } + /* Fall back to internal realloc */ + } + /* Keep in internal RAM */ + void* new_ptr = heap_caps_realloc(ptr, size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (new_ptr) return new_ptr; + /* Fall back to PSRAM */ + return heap_caps_realloc(ptr, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + } +} + +#ifdef __cplusplus +} +#endif diff --git a/lib/sx1262_interface/SX1262Interface.cpp b/lib/sx1262_interface/SX1262Interface.cpp new file mode 100644 index 0000000..701db72 --- /dev/null +++ b/lib/sx1262_interface/SX1262Interface.cpp @@ -0,0 +1,288 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "SX1262Interface.h" +#include "Log.h" +#include "Utilities/OS.h" + +#ifdef ARDUINO +#include +#endif + +using namespace RNS; + +#ifdef ARDUINO +// Static members for SPI mutex (shared with display) +SemaphoreHandle_t SX1262Interface::_spi_mutex = nullptr; +bool SX1262Interface::_mutex_initialized = false; +#endif + +SX1262Interface::SX1262Interface(const char* name) : InterfaceImpl(name) { + _IN = true; + _OUT = true; + _HW_MTU = HW_MTU; + + // Calculate bitrate from modulation parameters (matching Python RNS formula) + // bitrate = sf * ((4.0/cr) / (2^sf / (bw/1000))) * 1000 + _bitrate = (double)_config.spreading_factor * + ((4.0 / _config.coding_rate) / + (pow(2, _config.spreading_factor) / (_config.bandwidth / 1000.0))) * 1000.0; +} + +SX1262Interface::~SX1262Interface() { + stop(); +} + +void SX1262Interface::set_config(const SX1262Config& config) { + _config = config; + + // Recalculate bitrate + _bitrate = (double)_config.spreading_factor * + ((4.0 / _config.coding_rate) / + (pow(2, _config.spreading_factor) / (_config.bandwidth / 1000.0))) * 1000.0; +} + +std::string SX1262Interface::toString() const { + return "SX1262Interface[" + _name + "]"; +} + +bool SX1262Interface::start() { + _online = false; + +#ifdef ARDUINO + INFO("SX1262Interface: Initializing..."); + INFO(" Frequency: " + std::to_string(_config.frequency) + " MHz"); + INFO(" Bandwidth: " + std::to_string(_config.bandwidth) + " kHz"); + INFO(" SF: " + std::to_string(_config.spreading_factor)); + INFO(" CR: 4/" + std::to_string(_config.coding_rate)); + INFO(" TX Power: " + std::to_string(_config.tx_power) + " dBm"); + + // Initialize SPI mutex if not already done + if (!_mutex_initialized) { + _spi_mutex = xSemaphoreCreateMutex(); + if (_spi_mutex == nullptr) { + ERROR("SX1262Interface: Failed to create SPI mutex"); + return false; + } + _mutex_initialized = true; + DEBUG("SX1262Interface: SPI mutex created"); + } + + // Acquire SPI mutex + if (xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + ERROR("SX1262Interface: Failed to acquire SPI mutex for init"); + return false; + } + + // Set radio CS high to avoid conflicts + pinMode(SX1262Pins::CS, OUTPUT); + digitalWrite(SX1262Pins::CS, HIGH); + + // Create SPI instance for LoRa using HSPI (same bus as display) + // Display uses HSPI without MISO (write-only), we add MISO for radio reads + // SCK=40, MISO=38, MOSI=41 + _lora_spi = new SPIClass(HSPI); + _lora_spi->begin(40, SX1262Pins::SPI_MISO, 41, SX1262Pins::CS); + DEBUG("SX1262Interface: HSPI initialized (SCK=40, MISO=38, MOSI=41, CS=9)"); + + // Create RadioLib module and radio with our SPI instance + _module = new Module(SX1262Pins::CS, SX1262Pins::DIO1, SX1262Pins::RST, SX1262Pins::BUSY, *_lora_spi); + _radio = new SX1262(_module); + + // Initialize radio with configuration + int16_t state = _radio->begin( + _config.frequency, + _config.bandwidth, + _config.spreading_factor, + _config.coding_rate, + _config.sync_word, + _config.tx_power, + _config.preamble_length + ); + + if (state != RADIOLIB_ERR_NONE) { + ERROR("SX1262Interface: Radio init failed, code " + std::to_string(state)); + xSemaphoreGive(_spi_mutex); + delete _radio; + delete _module; + _radio = nullptr; + _module = nullptr; + return false; + } + + // Enable CRC for error detection + state = _radio->setCRC(true); + if (state != RADIOLIB_ERR_NONE) { + WARNING("SX1262Interface: Failed to enable CRC, code " + std::to_string(state)); + } + + // Use explicit header mode (includes length in LoRa header) + state = _radio->explicitHeader(); + if (state != RADIOLIB_ERR_NONE) { + WARNING("SX1262Interface: Failed to set explicit header, code " + std::to_string(state)); + } + + xSemaphoreGive(_spi_mutex); + + // Start listening for packets + start_receive(); + + _online = true; + INFO("SX1262Interface: Initialized successfully"); + INFO(" Bitrate: " + std::to_string(Utilities::OS::round(_bitrate / 1000.0, 2)) + " kbps"); + + return true; +#else + ERROR("SX1262Interface: Not supported on this platform"); + return false; +#endif +} + +void SX1262Interface::stop() { +#ifdef ARDUINO + if (_radio != nullptr) { + if (_spi_mutex != nullptr && xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + _radio->standby(); + xSemaphoreGive(_spi_mutex); + } + + delete _radio; + delete _module; + _radio = nullptr; + _module = nullptr; + } +#endif + + _online = false; + INFO("SX1262Interface: Stopped"); +} + +void SX1262Interface::start_receive() { +#ifdef ARDUINO + if (_radio == nullptr) return; + + if (xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + int16_t state = _radio->startReceive(); + xSemaphoreGive(_spi_mutex); + + if (state != RADIOLIB_ERR_NONE) { + ERROR("SX1262Interface: Failed to start receive, code " + std::to_string(state)); + } + } +#endif +} + +void SX1262Interface::loop() { + if (!_online) return; + +#ifdef ARDUINO + if (_radio == nullptr) return; + + // Try to acquire SPI mutex (non-blocking to avoid stalling display) + if (xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(5)) != pdTRUE) { + return; // Display is using SPI, try again later + } + + // Check IRQ status to see if a packet was actually received + uint16_t irqStatus = _radio->getIrqStatus(); + + // Only process if RX_DONE flag is set (0x0002 for SX126x) + if (!(irqStatus & 0x0002)) { + xSemaphoreGive(_spi_mutex); + return; // No new packet + } + + // Read the received packet (this also clears IRQ internally) + int16_t state = _radio->readData(_rx_buffer.writable(HW_MTU), HW_MTU); + + // Immediately restart receive to clear IRQ flags and prepare for next packet + _radio->startReceive(); + + if (state == RADIOLIB_ERR_NONE) { + // Got a packet + size_t len = _radio->getPacketLength(); + if (len > 1) { // Must have at least header + data + _rx_buffer.resize(len); + + // Get signal quality + _last_rssi = _radio->getRSSI(); + _last_snr = _radio->getSNR(); + + xSemaphoreGive(_spi_mutex); + + // RNode packet format: [1-byte random header][payload] + // Skip header byte, pass payload to transport + Bytes payload = _rx_buffer.mid(1); + + DEBUG("SX1262Interface: Received " + std::to_string(len) + " bytes, " + + "RSSI=" + std::to_string((int)_last_rssi) + " dBm, " + + "SNR=" + std::to_string((int)_last_snr) + " dB"); + + on_incoming(payload); + return; + } + } else if (state != RADIOLIB_ERR_RX_TIMEOUT) { + // An error occurred (not just timeout) + ERROR("SX1262Interface: Receive error, code " + std::to_string(state)); + } + + xSemaphoreGive(_spi_mutex); +#endif +} + +void SX1262Interface::send_outgoing(const Bytes& data) { + if (!_online) return; + +#ifdef ARDUINO + if (_radio == nullptr) return; + + DEBUG(toString() + ": Sending " + std::to_string(data.size()) + " bytes"); + + // Build packet with random header (RNode-compatible format) + // Header: upper 4 bits random, lower 4 bits reserved + uint8_t header = Cryptography::randomnum(256) & 0xF0; + + size_t len = 1 + data.size(); + if (len > HW_MTU) { + ERROR("SX1262Interface: Packet too large (" + std::to_string(len) + " > " + std::to_string(HW_MTU) + ")"); + return; + } + + uint8_t* buf = new uint8_t[len]; + buf[0] = header; + memcpy(buf + 1, data.data(), data.size()); + + // Acquire SPI mutex + if (xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + ERROR("SX1262Interface: Failed to acquire SPI mutex for TX"); + delete[] buf; + return; + } + + _transmitting = true; + + // Transmit (blocking) + int16_t state = _radio->transmit(buf, len); + + _transmitting = false; + xSemaphoreGive(_spi_mutex); + delete[] buf; + + if (state == RADIOLIB_ERR_NONE) { + DEBUG("SX1262Interface: Sent " + std::to_string(len) + " bytes"); + // Perform post-send housekeeping + InterfaceImpl::handle_outgoing(data); + } else { + ERROR("SX1262Interface: Transmit failed, code " + std::to_string(state)); + } + + // Return to receive mode + start_receive(); +#endif +} + +void SX1262Interface::on_incoming(const Bytes& data) { + DEBUG(toString() + ": Incoming " + std::to_string(data.size()) + " bytes"); + // Pass received data to transport + InterfaceImpl::handle_incoming(data); +} diff --git a/lib/sx1262_interface/SX1262Interface.h b/lib/sx1262_interface/SX1262Interface.h new file mode 100644 index 0000000..e63f573 --- /dev/null +++ b/lib/sx1262_interface/SX1262Interface.h @@ -0,0 +1,105 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#pragma once + +#include "Interface.h" +#include "Bytes.h" +#include "Type.h" +#include "Cryptography/Random.h" + +#ifdef ARDUINO +#include +#include +#include +#endif + +/** + * SX1262 LoRa Interface for T-Deck Plus + * + * Air-compatible with RNode devices using same modulation and packet framing. + * Uses RadioLib for SX1262 support with DIO1+BUSY pin model. + */ + +// T-Deck Plus SX1262 pins (from Hardware::TDeck::Radio) +namespace SX1262Pins { + constexpr uint8_t CS = 9; + constexpr uint8_t BUSY = 13; + constexpr uint8_t RST = 17; + constexpr uint8_t DIO1 = 45; + constexpr uint8_t SPI_MISO = 38; + // Shared with display: MOSI=41, SCK=40 +} + +/** + * LoRa configuration parameters. + * Defaults match RNode for interoperability. + */ +struct SX1262Config { + float frequency = 927.25f; // MHz + float bandwidth = 62.5f; // kHz (valid: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500) + uint8_t spreading_factor = 7; // SF7-SF12 + uint8_t coding_rate = 5; // 5=4/5, 6=4/6, 7=4/7, 8=4/8 + int8_t tx_power = 17; // dBm (2-22) + uint8_t sync_word = 0x12; // Standard LoRa sync word + uint16_t preamble_length = 20; // symbols +}; + +class SX1262Interface : public RNS::InterfaceImpl { +public: + SX1262Interface(const char* name = "LoRa"); + virtual ~SX1262Interface(); + + /** + * Set configuration before calling start(). + * Changes take effect on next start(). + */ + void set_config(const SX1262Config& config); + const SX1262Config& get_config() const { return _config; } + + // InterfaceImpl interface + virtual bool start() override; + virtual void stop() override; + virtual void loop() override; + + // Status getters (override virtual from InterfaceImpl) + float get_rssi() const override { return _last_rssi; } + float get_snr() const override { return _last_snr; } + bool is_transmitting() const { return _transmitting; } + + virtual std::string toString() const override; + +protected: + virtual void send_outgoing(const RNS::Bytes& data) override; + +private: + void on_incoming(const RNS::Bytes& data); + void start_receive(); + +#ifdef ARDUINO + // RadioLib objects + SX1262* _radio = nullptr; + Module* _module = nullptr; + + // SPI for LoRa (shared HSPI bus with display, but with MISO enabled) + SPIClass* _lora_spi = nullptr; + + // SPI mutex for shared bus with display + static SemaphoreHandle_t _spi_mutex; + static bool _mutex_initialized; +#endif + + // Configuration + SX1262Config _config; + + // State + bool _transmitting = false; + float _last_rssi = 0.0f; + float _last_snr = 0.0f; + + // Receive buffer + RNS::Bytes _rx_buffer; + + // Hardware MTU (matches existing LoRaInterface) + static constexpr uint16_t HW_MTU = 508; +}; diff --git a/lib/tdeck_ui/Display.cpp b/lib/tdeck_ui/Display.cpp new file mode 100644 index 0000000..c2141eb --- /dev/null +++ b/lib/tdeck_ui/Display.cpp @@ -0,0 +1,411 @@ +/* + * Display.cpp - OLED display implementation for microReticulum + */ + +#include "Display.h" + +#ifdef HAS_DISPLAY + +#include "DisplayGraphics.h" +#include "Identity.h" +#include "Interface.h" +#include "Reticulum.h" +#include "Transport.h" +#include "Log.h" +#include "Utilities/OS.h" + +// Display library includes (board-specific) +#ifdef ARDUINO + #include + #ifdef DISPLAY_TYPE_SH1106 + #include + #elif defined(DISPLAY_TYPE_SSD1306) + #include + #endif + #include +#endif + +namespace RNS { + +// Display dimensions +static const int16_t DISPLAY_WIDTH = 128; +static const int16_t DISPLAY_HEIGHT = 64; + +// Layout constants +static const int16_t HEADER_HEIGHT = 17; +static const int16_t CONTENT_Y = 21; +static const int16_t LINE_HEIGHT = 10; +static const int16_t LEFT_MARGIN = 2; + +// Static member initialization +bool Display::_ready = false; +bool Display::_blanked = false; +uint8_t Display::_current_page = 0; +uint32_t Display::_last_page_flip = 0; +uint32_t Display::_last_update = 0; +uint32_t Display::_start_time = 0; +Bytes Display::_identity_hash; +Interface* Display::_interface = nullptr; +Reticulum* Display::_reticulum = nullptr; +float Display::_rssi = -120.0f; + +// Display object (board-specific) +#ifdef ARDUINO + #ifdef DISPLAY_TYPE_SH1106 + static Adafruit_SH1106G* display = nullptr; + #elif defined(DISPLAY_TYPE_SSD1306) + static Adafruit_SSD1306* display = nullptr; + #endif +#endif + +bool Display::init() { +#ifdef ARDUINO + TRACE("Display::init: Initializing display..."); + + // Initialize I2C + #if defined(DISPLAY_SDA) && defined(DISPLAY_SCL) + Wire.begin(DISPLAY_SDA, DISPLAY_SCL); + #else + Wire.begin(); + #endif + + // Create display object + #ifdef DISPLAY_TYPE_SH1106 + display = new Adafruit_SH1106G(DISPLAY_WIDTH, DISPLAY_HEIGHT, &Wire, -1); + #ifndef DISPLAY_ADDR + #define DISPLAY_ADDR 0x3C + #endif + if (!display->begin(DISPLAY_ADDR, true)) { + ERROR("Display::init: SH1106 display not found"); + delete display; + display = nullptr; + return false; + } + #elif defined(DISPLAY_TYPE_SSD1306) + display = new Adafruit_SSD1306(DISPLAY_WIDTH, DISPLAY_HEIGHT, &Wire, -1); + #ifndef DISPLAY_ADDR + #define DISPLAY_ADDR 0x3C + #endif + if (!display->begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDR)) { + ERROR("Display::init: SSD1306 display not found"); + delete display; + display = nullptr; + return false; + } + #else + ERROR("Display::init: No display type defined"); + return false; + #endif + + // Configure display + display->setRotation(0); // Portrait mode + display->clearDisplay(); + display->setTextSize(1); + display->setTextColor(1); // White + display->cp437(true); // Enable extended characters + display->display(); + + _ready = true; + _start_time = (uint32_t)Utilities::OS::ltime(); + _last_page_flip = _start_time; + _last_update = _start_time; + + INFO("Display::init: Display initialized successfully"); + return true; +#else + // Native build - no display support + return false; +#endif +} + +void Display::update() { + if (!_ready || _blanked) return; + +#ifdef ARDUINO + uint32_t now = (uint32_t)Utilities::OS::ltime(); + + // Check for page rotation + if (now - _last_page_flip >= PAGE_INTERVAL) { + _current_page = (_current_page + 1) % NUM_PAGES; + _last_page_flip = now; + } + + // Throttle display updates + if (now - _last_update < UPDATE_INTERVAL) return; + _last_update = now; + + // Clear and redraw + display->clearDisplay(); + + // Draw header (common to all pages) + draw_header(); + + // Draw page-specific content + switch (_current_page) { + case 0: + draw_page_main(); + break; + case 1: + draw_page_interface(); + break; + case 2: + draw_page_network(); + break; + } + + // Send to display + display->display(); +#endif +} + +void Display::set_identity(const Identity& identity) { + if (identity) { + _identity_hash = identity.hash(); + } +} + +void Display::set_interface(Interface* iface) { + _interface = iface; +} + +void Display::set_reticulum(Reticulum* rns) { + _reticulum = rns; +} + +void Display::blank(bool blank) { + _blanked = blank; +#ifdef ARDUINO + if (_ready && display) { + if (blank) { + display->clearDisplay(); + display->display(); + } + } +#endif +} + +void Display::set_page(uint8_t page) { + if (page < NUM_PAGES) { + _current_page = page; + _last_page_flip = (uint32_t)Utilities::OS::ltime(); + } +} + +void Display::next_page() { + _current_page = (_current_page + 1) % NUM_PAGES; + _last_page_flip = (uint32_t)Utilities::OS::ltime(); +} + +uint8_t Display::current_page() { + return _current_page; +} + +bool Display::ready() { + return _ready; +} + +void Display::set_rssi(float rssi) { + _rssi = rssi; +} + +// Private implementation + +void Display::draw_header() { +#ifdef ARDUINO + // Draw "μRNS" text logo + display->setTextSize(2); // 2x size for visibility + display->setCursor(3, 0); + // Use CP437 code 230 (0xE6) for μ character + display->write(0xE6); // μ + display->print("RNS"); + display->setTextSize(1); // Reset to normal size + + // Draw signal bars on the right + draw_signal_bars(DISPLAY_WIDTH - Graphics::SIGNAL_WIDTH - 2, 4); + + // Draw separator line + display->drawLine(0, HEADER_HEIGHT, DISPLAY_WIDTH - 1, HEADER_HEIGHT, 1); +#endif +} + +void Display::draw_signal_bars(int16_t x, int16_t y) { +#ifdef ARDUINO + // Temporarily disabled to debug stray pixel + // uint8_t level = 0; + // if (_interface && _interface->online()) { + // level = Graphics::rssi_to_level(_rssi); + // } + // const uint8_t* bitmap = Graphics::get_signal_bitmap(level); + // display->drawBitmap(x, y, bitmap, Graphics::SIGNAL_WIDTH, Graphics::SIGNAL_HEIGHT, 1); +#endif +} + +void Display::draw_page_main() { +#ifdef ARDUINO + int16_t y = CONTENT_Y; + + // Identity hash + display->setCursor(LEFT_MARGIN, y); + display->print("ID: "); + if (_identity_hash.size() > 0) { + // Show first 12 hex chars + std::string hex = _identity_hash.toHex(); + if (hex.length() > 12) hex = hex.substr(0, 12); + display->print(hex.c_str()); + } else { + display->print("(none)"); + } + y += LINE_HEIGHT + 4; + + // Interface status + display->setCursor(LEFT_MARGIN, y); + if (_interface) { + display->print(_interface->name().c_str()); + display->print(": "); + display->print(_interface->online() ? "ONLINE" : "OFFLINE"); + } else { + display->print("No interface"); + } + y += LINE_HEIGHT; + + // Link count + display->setCursor(LEFT_MARGIN, y); + display->print("Links: "); + if (_reticulum) { + display->print((int)_reticulum->get_link_count()); + } else { + display->print("0"); + } +#endif +} + +void Display::draw_page_interface() { +#ifdef ARDUINO + int16_t y = CONTENT_Y; + + if (!_interface) { + display->setCursor(LEFT_MARGIN, y); + display->print("No interface"); + return; + } + + // Interface name + display->setCursor(LEFT_MARGIN, y); + display->print("Interface: "); + display->print(_interface->name().c_str()); + y += LINE_HEIGHT; + + // Mode + display->setCursor(LEFT_MARGIN, y); + display->print("Mode: "); + switch (_interface->mode()) { + case Type::Interface::MODE_GATEWAY: + display->print("Gateway"); + break; + case Type::Interface::MODE_ACCESS_POINT: + display->print("Access Point"); + break; + case Type::Interface::MODE_POINT_TO_POINT: + display->print("Point-to-Point"); + break; + case Type::Interface::MODE_ROAMING: + display->print("Roaming"); + break; + case Type::Interface::MODE_BOUNDARY: + display->print("Boundary"); + break; + default: + display->print("Unknown"); + break; + } + y += LINE_HEIGHT; + + // Bitrate + display->setCursor(LEFT_MARGIN, y); + display->print("Bitrate: "); + display->print(format_bitrate(_interface->bitrate()).c_str()); + y += LINE_HEIGHT; + + // Status + display->setCursor(LEFT_MARGIN, y); + display->print("Status: "); + display->print(_interface->online() ? "Online" : "Offline"); +#endif +} + +void Display::draw_page_network() { +#ifdef ARDUINO + int16_t y = CONTENT_Y; + + // Links and paths + display->setCursor(LEFT_MARGIN, y); + display->print("Links: "); + size_t link_count = _reticulum ? _reticulum->get_link_count() : 0; + display->print((int)link_count); + + display->print(" Paths: "); + size_t path_count = _reticulum ? _reticulum->get_path_table().size() : 0; + display->print((int)path_count); + y += LINE_HEIGHT; + + // RTT placeholder (would need link reference to get actual RTT) + display->setCursor(LEFT_MARGIN, y); + display->print("RTT: --"); + y += LINE_HEIGHT; + + // TX/RX bytes (if interface available) + display->setCursor(LEFT_MARGIN, y); + display->print("TX/RX: --"); + y += LINE_HEIGHT; + + // Uptime + display->setCursor(LEFT_MARGIN, y); + display->print("Uptime: "); + uint32_t uptime_sec = ((uint32_t)Utilities::OS::ltime() - _start_time) / 1000; + display->print(format_time(uptime_sec).c_str()); +#endif +} + +std::string Display::format_bytes(size_t bytes) { + char buf[16]; + if (bytes >= 1024 * 1024) { + snprintf(buf, sizeof(buf), "%.1fM", bytes / (1024.0 * 1024.0)); + } else if (bytes >= 1024) { + snprintf(buf, sizeof(buf), "%.1fK", bytes / 1024.0); + } else { + snprintf(buf, sizeof(buf), "%zuB", bytes); + } + return std::string(buf); +} + +std::string Display::format_time(uint32_t seconds) { + char buf[16]; + if (seconds >= 3600) { + uint32_t hours = seconds / 3600; + uint32_t mins = (seconds % 3600) / 60; + snprintf(buf, sizeof(buf), "%luh %lum", (unsigned long)hours, (unsigned long)mins); + } else if (seconds >= 60) { + uint32_t mins = seconds / 60; + uint32_t secs = seconds % 60; + snprintf(buf, sizeof(buf), "%lum %lus", (unsigned long)mins, (unsigned long)secs); + } else { + snprintf(buf, sizeof(buf), "%lus", (unsigned long)seconds); + } + return std::string(buf); +} + +std::string Display::format_bitrate(uint32_t bps) { + char buf[16]; + if (bps >= 1000000) { + snprintf(buf, sizeof(buf), "%.1f Mbps", bps / 1000000.0); + } else if (bps >= 1000) { + snprintf(buf, sizeof(buf), "%.1f kbps", bps / 1000.0); + } else { + snprintf(buf, sizeof(buf), "%lu bps", (unsigned long)bps); + } + return std::string(buf); +} + +} // namespace RNS + +#endif // HAS_DISPLAY diff --git a/lib/tdeck_ui/Display.h b/lib/tdeck_ui/Display.h new file mode 100644 index 0000000..644caf2 --- /dev/null +++ b/lib/tdeck_ui/Display.h @@ -0,0 +1,139 @@ +/* + * Display.h - OLED display support for microReticulum + * + * Provides status display on supported hardware (T-Beam Supreme, etc.) + * with auto-rotating pages showing identity, interface, and network info. + */ + +#pragma once + +#include "Type.h" +#include "Bytes.h" + +#include +#include + +// Only compile display code if enabled +#ifdef HAS_DISPLAY + +namespace RNS { + +// Forward declarations +class Identity; +class Interface; +class Reticulum; + +class Display { +public: + // Number of pages to rotate through + static const uint8_t NUM_PAGES = 3; + + // Page interval in milliseconds + static const uint32_t PAGE_INTERVAL = 4000; + + // Display update interval (~7 FPS) + static const uint32_t UPDATE_INTERVAL = 143; + +public: + /** + * Initialize the display hardware. + * Must be called once during setup. + * @return true if display initialized successfully + */ + static bool init(); + + /** + * Update the display. Call this frequently in the main loop. + * Handles page rotation and display refresh internally. + */ + static void update(); + + /** + * Set the identity to display. + * @param identity The identity whose hash will be shown + */ + static void set_identity(const Identity& identity); + + /** + * Set the primary interface to display status for. + * @param iface Pointer to the interface (can be nullptr) + */ + static void set_interface(Interface* iface); + + /** + * Set the Reticulum instance for network statistics. + * @param rns Pointer to the Reticulum instance + */ + static void set_reticulum(Reticulum* rns); + + /** + * Enable or disable display blanking (power save). + * @param blank true to blank the display + */ + static void blank(bool blank); + + /** + * Set the current page manually. + * @param page Page number (0 to NUM_PAGES-1) + */ + static void set_page(uint8_t page); + + /** + * Advance to the next page. + */ + static void next_page(); + + /** + * Get the current page number. + * @return Current page (0 to NUM_PAGES-1) + */ + static uint8_t current_page(); + + /** + * Check if display is ready. + * @return true if display was initialized successfully + */ + static bool ready(); + + /** + * Set RSSI value for signal bars display. + * @param rssi Signal strength in dBm + */ + static void set_rssi(float rssi); + +private: + // Page rendering functions + static void draw_page_main(); // Page 0: Main status + static void draw_page_interface(); // Page 1: Interface details + static void draw_page_network(); // Page 2: Network info + + // Common elements + static void draw_header(); // Logo + signal bars (all pages) + static void draw_signal_bars(int16_t x, int16_t y); + + // Helper functions + static std::string format_bytes(size_t bytes); + static std::string format_time(uint32_t seconds); + static std::string format_bitrate(uint32_t bps); + +private: + // State + static bool _ready; + static bool _blanked; + static uint8_t _current_page; + static uint32_t _last_page_flip; + static uint32_t _last_update; + static uint32_t _start_time; + + // Data sources + static Bytes _identity_hash; + static Interface* _interface; + static Reticulum* _reticulum; + + // Signal strength + static float _rssi; +}; + +} // namespace RNS + +#endif // HAS_DISPLAY diff --git a/lib/tdeck_ui/DisplayGraphics.h b/lib/tdeck_ui/DisplayGraphics.h new file mode 100644 index 0000000..84b4ea7 --- /dev/null +++ b/lib/tdeck_ui/DisplayGraphics.h @@ -0,0 +1,226 @@ +/* + * DisplayGraphics.h - Bitmap graphics for microReticulum display + * + * Contains the μRNS logo and status icons stored in PROGMEM. + * Bitmaps are in XBM format (1 bit per pixel, LSB first). + */ + +#ifndef DISPLAY_GRAPHICS_H +#define DISPLAY_GRAPHICS_H + +#include + +#ifdef ARDUINO + #ifdef ESP32 + #include + #elif defined(__AVR__) + #include + #else + #define PROGMEM + #endif +#else + #define PROGMEM +#endif + +namespace RNS { +namespace Graphics { + +// μRNS Logo - 40x12 pixels +// Displays "μRNS" text +static const uint8_t LOGO_WIDTH = 40; +static const uint8_t LOGO_HEIGHT = 12; +static const uint8_t logo_urns[] PROGMEM = { + // Row 0 + 0x00, 0x00, 0x00, 0x00, 0x00, + // Row 1 - top of letters + 0x00, 0x00, 0xFC, 0x0F, 0x00, + // Row 2 + 0x66, 0x1E, 0x86, 0x19, 0x3E, + // Row 3 + 0x66, 0x33, 0x83, 0x31, 0x33, + // Row 4 + 0x66, 0x33, 0x83, 0x31, 0x03, + // Row 5 + 0x66, 0x3F, 0x83, 0x31, 0x1E, + // Row 6 + 0x66, 0x33, 0x83, 0x31, 0x30, + // Row 7 + 0x66, 0x33, 0x83, 0x31, 0x30, + // Row 8 + 0x3C, 0x33, 0x86, 0x19, 0x33, + // Row 9 + 0x18, 0x33, 0xFC, 0x0F, 0x1E, + // Row 10 + 0x18, 0x00, 0x00, 0x00, 0x00, + // Row 11 + 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +// Alternate larger logo - 48x16 pixels +// More visible "μRNS" with better spacing +static const uint8_t LOGO_LARGE_WIDTH = 48; +static const uint8_t LOGO_LARGE_HEIGHT = 16; +static const uint8_t logo_urns_large[] PROGMEM = { + // Row 0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Row 1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Row 2 - μ starts, R N S top + 0x00, 0x00, 0xF8, 0x1F, 0x00, 0x00, + // Row 3 + 0xC6, 0x38, 0x0C, 0x30, 0xF8, 0x00, + // Row 4 + 0xC6, 0x6C, 0x06, 0x60, 0xCC, 0x00, + // Row 5 + 0xC6, 0x6C, 0x06, 0x60, 0x0C, 0x00, + // Row 6 + 0xC6, 0x7C, 0x06, 0x60, 0x0C, 0x00, + // Row 7 + 0xC6, 0x6C, 0x06, 0x60, 0x78, 0x00, + // Row 8 + 0xC6, 0x6C, 0x06, 0x60, 0xC0, 0x00, + // Row 9 + 0xC6, 0x6C, 0x06, 0x60, 0xC0, 0x00, + // Row 10 + 0x7C, 0x6C, 0x0C, 0x30, 0xCC, 0x00, + // Row 11 + 0x38, 0x6C, 0xF8, 0x1F, 0x78, 0x00, + // Row 12 + 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, + // Row 13 + 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, + // Row 14 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Row 15 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +// Signal strength bars - 12x8 pixels each +// 5 states: 0 bars (offline), 1-4 bars (signal strength) +static const uint8_t SIGNAL_WIDTH = 12; +static const uint8_t SIGNAL_HEIGHT = 8; + +// Signal bars - 0 (offline/no signal) +static const uint8_t signal_0[] PROGMEM = { + 0x00, 0x00, // Row 0 + 0x00, 0x00, // Row 1 + 0x00, 0x00, // Row 2 + 0x00, 0x00, // Row 3 + 0x00, 0x00, // Row 4 + 0x00, 0x00, // Row 5 + 0x00, 0x00, // Row 6 + 0x00, 0x00, // Row 7 +}; + +// Signal bars - 1 bar (weak) +static const uint8_t signal_1[] PROGMEM = { + 0x00, 0x00, // Row 0 + 0x00, 0x00, // Row 1 + 0x00, 0x00, // Row 2 + 0x00, 0x00, // Row 3 + 0x00, 0x00, // Row 4 + 0x00, 0x00, // Row 5 + 0x03, 0x00, // Row 6 - 1 bar + 0x03, 0x00, // Row 7 +}; + +// Signal bars - 2 bars (fair) +static const uint8_t signal_2[] PROGMEM = { + 0x00, 0x00, // Row 0 + 0x00, 0x00, // Row 1 + 0x00, 0x00, // Row 2 + 0x00, 0x00, // Row 3 + 0x0C, 0x00, // Row 4 - 2nd bar + 0x0C, 0x00, // Row 5 + 0x0F, 0x00, // Row 6 - both bars + 0x0F, 0x00, // Row 7 +}; + +// Signal bars - 3 bars (good) +static const uint8_t signal_3[] PROGMEM = { + 0x00, 0x00, // Row 0 + 0x00, 0x00, // Row 1 + 0x30, 0x00, // Row 2 - 3rd bar + 0x30, 0x00, // Row 3 + 0x3C, 0x00, // Row 4 - bars 2+3 + 0x3C, 0x00, // Row 5 + 0x3F, 0x00, // Row 6 - all 3 bars + 0x3F, 0x00, // Row 7 +}; + +// Signal bars - 4 bars (excellent) +static const uint8_t signal_4[] PROGMEM = { + 0xC0, 0x00, // Row 0 - 4th bar + 0xC0, 0x00, // Row 1 + 0xF0, 0x00, // Row 2 - bars 3+4 + 0xF0, 0x00, // Row 3 + 0xFC, 0x00, // Row 4 - bars 2+3+4 + 0xFC, 0x00, // Row 5 + 0xFF, 0x00, // Row 6 - all 4 bars + 0xFF, 0x00, // Row 7 +}; + +// Online indicator - filled circle 8x8 +static const uint8_t INDICATOR_SIZE = 8; +static const uint8_t indicator_online[] PROGMEM = { + 0x3C, // ..####.. + 0x7E, // .######. + 0xFF, // ######## + 0xFF, // ######## + 0xFF, // ######## + 0xFF, // ######## + 0x7E, // .######. + 0x3C, // ..####.. +}; + +// Offline indicator - empty circle 8x8 +static const uint8_t indicator_offline[] PROGMEM = { + 0x3C, // ..####.. + 0x42, // .#....#. + 0x81, // #......# + 0x81, // #......# + 0x81, // #......# + 0x81, // #......# + 0x42, // .#....#. + 0x3C, // ..####.. +}; + +// Link icon - two connected nodes 12x8 +static const uint8_t LINK_ICON_WIDTH = 12; +static const uint8_t LINK_ICON_HEIGHT = 8; +static const uint8_t icon_link[] PROGMEM = { + 0x1E, 0x07, // Row 0 + 0x21, 0x08, // Row 1 + 0x21, 0x08, // Row 2 + 0xE1, 0x08, // Row 3 - connection line + 0xE1, 0x08, // Row 4 - connection line + 0x21, 0x08, // Row 5 + 0x21, 0x08, // Row 6 + 0x1E, 0x07, // Row 7 +}; + +// Helper to get signal bitmap based on level (0-4) +inline const uint8_t* get_signal_bitmap(uint8_t level) { + switch(level) { + case 1: return signal_1; + case 2: return signal_2; + case 3: return signal_3; + case 4: return signal_4; + default: return signal_0; + } +} + +// Convert RSSI (dBm) to signal level (0-4) +// Typical LoRa RSSI ranges: -120 dBm (weak) to -30 dBm (strong) +inline uint8_t rssi_to_level(float rssi) { + if (rssi >= -60) return 4; // Excellent + if (rssi >= -80) return 3; // Good + if (rssi >= -100) return 2; // Fair + if (rssi >= -120) return 1; // Weak + return 0; // No signal / offline +} + +} // namespace Graphics +} // namespace RNS + +#endif // DISPLAY_GRAPHICS_H diff --git a/lib/tdeck_ui/Hardware/TDeck/Config.h b/lib/tdeck_ui/Hardware/TDeck/Config.h new file mode 100644 index 0000000..2bb9f63 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Config.h @@ -0,0 +1,171 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_CONFIG_H +#define HARDWARE_TDECK_CONFIG_H + +#include + +namespace Hardware { +namespace TDeck { + +/** + * T-Deck Plus Hardware Configuration + * + * Hardware: LilyGO T-Deck Plus + * MCU: ESP32-S3 + * Display: 320x240 IPS (ST7789V) + * Keyboard: ESP32-C3 I2C controller + * Touch: GT911 capacitive (shares I2C bus with keyboard) + * Trackball: GPIO pulse-based navigation + */ + +namespace Pin { + // Display (ST7789V SPI) + constexpr uint8_t DISPLAY_CS = 12; + constexpr uint8_t DISPLAY_DC = 11; + constexpr uint8_t DISPLAY_BACKLIGHT = 42; + constexpr uint8_t DISPLAY_MOSI = 41; // SPI MOSI + constexpr uint8_t DISPLAY_SCK = 40; // SPI CLK + constexpr uint8_t DISPLAY_RST = -1; // No reset pin (shared reset) + + // I2C Bus (shared by keyboard and touch) + constexpr uint8_t I2C_SDA = 18; + constexpr uint8_t I2C_SCL = 8; + + // Keyboard (ESP32-C3 controller on I2C) + // No additional pins needed - uses I2C bus + + // Touch (GT911 on I2C) + constexpr uint8_t TOUCH_INT = -1; // NOT USED - polling mode only + constexpr uint8_t TOUCH_RST = -1; // NOT USED + + // Trackball (GPIO pulses) - per ESPP t-deck.hpp + constexpr uint8_t TRACKBALL_UP = 15; + constexpr uint8_t TRACKBALL_DOWN = 3; + constexpr uint8_t TRACKBALL_LEFT = 1; + constexpr uint8_t TRACKBALL_RIGHT = 2; + constexpr uint8_t TRACKBALL_BUTTON = 0; + + // Power management + constexpr uint8_t POWER_EN = 10; // Power enable pin + constexpr uint8_t BATTERY_ADC = 4; // Battery voltage ADC + + // GPS (L76K or UBlox M10Q - built-in on T-Deck Plus) + // Pin naming from ESP32's perspective (matches LilyGo convention) + constexpr uint8_t GPS_TX = 43; // ESP32 TX -> GPS RX (ESP32 transmits) + constexpr uint8_t GPS_RX = 44; // ESP32 RX <- GPS TX (ESP32 receives) +} + +namespace I2C { + // I2C addresses + constexpr uint8_t KEYBOARD_ADDR = 0x55; + constexpr uint8_t TOUCH_ADDR_1 = 0x5D; // Primary GT911 address + constexpr uint8_t TOUCH_ADDR_2 = 0x14; // Alternative GT911 address + + // I2C timing + constexpr uint32_t FREQUENCY = 400000; // 400kHz + constexpr uint32_t TIMEOUT_MS = 100; +} + +namespace Disp { + // Display dimensions + constexpr uint16_t WIDTH = 320; + constexpr uint16_t HEIGHT = 240; + constexpr uint8_t ROTATION = 1; // Landscape mode + + // SPI configuration + constexpr uint32_t SPI_FREQUENCY = 40000000; // 40MHz + constexpr uint8_t SPI_HOST = 1; // HSPI + + // Backlight PWM + constexpr uint8_t BACKLIGHT_CHANNEL = 0; + constexpr uint32_t BACKLIGHT_FREQ = 5000; // 5kHz PWM + constexpr uint8_t BACKLIGHT_RESOLUTION = 8; // 8-bit (0-255) + constexpr uint8_t BACKLIGHT_DEFAULT = 180; // Default brightness +} + +namespace Kbd { + // Keyboard register addresses (ESP32-C3 controller) + constexpr uint8_t REG_KEY_STATE = 0x01; // Key state register + constexpr uint8_t REG_KEY_COUNT = 0x02; // Number of keys pressed + constexpr uint8_t REG_KEY_DATA = 0x03; // Key data buffer start + + // Maximum keys in buffer + constexpr uint8_t MAX_KEYS_BUFFERED = 8; + + // Polling interval + constexpr uint32_t POLL_INTERVAL_MS = 10; +} + +namespace Tch { + // GT911 register addresses + constexpr uint16_t REG_CONFIG = 0x8047; // Config start + constexpr uint16_t REG_PRODUCT_ID = 0x8140; // Product ID + constexpr uint16_t REG_STATUS = 0x814E; // Touch status + constexpr uint16_t REG_POINT_1 = 0x814F; // First touch point + + // Touch configuration + constexpr uint8_t MAX_TOUCH_POINTS = 5; + + // Polling interval (MUST use polling mode - interrupts cause crashes) + constexpr uint32_t POLL_INTERVAL_MS = 10; + + // Touch calibration (raw coordinates in GT911's native portrait orientation) + // GT911 reports X for short edge (240) and Y for long edge (320) + constexpr uint16_t RAW_WIDTH = 240; // Portrait width (short edge) + constexpr uint16_t RAW_HEIGHT = 320; // Portrait height (long edge) +} + +namespace Trk { + // Debounce timing + constexpr uint32_t DEBOUNCE_MS = 10; + + // Pulse counting for movement speed + constexpr uint32_t PULSE_RESET_MS = 50; // Reset pulse count after 50ms idle + + // Movement sensitivity + constexpr uint8_t PIXELS_PER_PULSE = 5; + + // Focus navigation (KEYPAD mode) + constexpr uint8_t NAV_THRESHOLD = 1; // Pulses needed to trigger focus move + constexpr uint32_t KEY_REPEAT_MS = 100; // Min time between repeated key events +} + +namespace Power { + // Battery voltage divider (adjust based on hardware) + constexpr float BATTERY_VOLTAGE_DIVIDER = 2.0; + + // Battery levels (in volts) + constexpr float BATTERY_FULL = 4.2; + constexpr float BATTERY_EMPTY = 3.3; +} + +namespace Radio { + // SX1262 LoRa Radio pins + constexpr uint8_t LORA_CS = 9; // Chip select + constexpr uint8_t LORA_BUSY = 13; // SX1262 busy signal + constexpr uint8_t LORA_RST = 17; // Reset + constexpr uint8_t LORA_DIO1 = 45; // Interrupt (DIO1, not DIO0) + // Note: Uses shared SPI bus with display (SCK=40, MOSI=41, MISO=38) + constexpr uint8_t SPI_MISO = 38; // SPI MISO (LoRa only, display is write-only) +} + +namespace Audio { + // I2S speaker output pins + constexpr uint8_t I2S_BCK = 7; // Bit clock + constexpr uint8_t I2S_WS = 5; // Word select (LRCK) + constexpr uint8_t I2S_DOUT = 6; // Data out + // Note: Pin::POWER_EN (10) must be HIGH to enable speaker power +} + +namespace SDCard { + // SD Card pins (shares SPI bus with display and LoRa) + constexpr uint8_t CS = 39; // SD card chip select + // SPI bus shared: SCK=40, MOSI=41, MISO=38 +} + +} // namespace TDeck +} // namespace Hardware + +#endif // HARDWARE_TDECK_CONFIG_H diff --git a/lib/tdeck_ui/Hardware/TDeck/Display.cpp b/lib/tdeck_ui/Hardware/TDeck/Display.cpp new file mode 100644 index 0000000..4816df7 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Display.cpp @@ -0,0 +1,269 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "Display.h" + +#ifdef ARDUINO + +#include "Log.h" +#include + +using namespace RNS; + +namespace Hardware { +namespace TDeck { + +SPIClass* Display::_spi = nullptr; +uint8_t Display::_brightness = Disp::BACKLIGHT_DEFAULT; +bool Display::_initialized = false; + +bool Display::init() { + if (_initialized) { + return true; + } + + INFO("Initializing T-Deck display"); + + // Initialize hardware first + if (!init_hardware_only()) { + return false; + } + + // Allocate LVGL buffers in PSRAM (2 buffers for double buffering) + static lv_disp_draw_buf_t draw_buf; + size_t buf_size = Disp::WIDTH * Disp::HEIGHT * sizeof(lv_color_t); + + lv_color_t* buf1 = (lv_color_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM); + lv_color_t* buf2 = (lv_color_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM); + + if (!buf1 || !buf2) { + ERROR("Failed to allocate LVGL buffers in PSRAM"); + if (buf1) heap_caps_free(buf1); + if (buf2) heap_caps_free(buf2); + return false; + } + + INFO((" LVGL buffers allocated in PSRAM (" + String(buf_size * 2) + " bytes)").c_str()); + + // Initialize LVGL draw buffer + lv_disp_draw_buf_init(&draw_buf, buf1, buf2, Disp::WIDTH * Disp::HEIGHT); + + // Register display driver with LVGL + static lv_disp_drv_t disp_drv; + lv_disp_drv_init(&disp_drv); + disp_drv.hor_res = Disp::WIDTH; + disp_drv.ver_res = Disp::HEIGHT; + disp_drv.flush_cb = lvgl_flush_cb; + disp_drv.draw_buf = &draw_buf; + + lv_disp_t* disp = lv_disp_drv_register(&disp_drv); + if (!disp) { + ERROR("Failed to register display driver with LVGL"); + return false; + } + + INFO("Display initialized successfully"); + return true; +} + +bool Display::init_hardware_only() { + if (_initialized) { + return true; + } + + INFO("Initializing display hardware"); + + // Configure backlight PWM + ledcSetup(Disp::BACKLIGHT_CHANNEL, Disp::BACKLIGHT_FREQ, Disp::BACKLIGHT_RESOLUTION); + ledcAttachPin(Pin::DISPLAY_BACKLIGHT, Disp::BACKLIGHT_CHANNEL); + set_brightness(_brightness); + + // Initialize SPI + _spi = new SPIClass(HSPI); + _spi->begin(Pin::DISPLAY_SCK, -1, Pin::DISPLAY_MOSI, Pin::DISPLAY_CS); + + // Configure CS and DC pins + pinMode(Pin::DISPLAY_CS, OUTPUT); + pinMode(Pin::DISPLAY_DC, OUTPUT); + digitalWrite(Pin::DISPLAY_CS, HIGH); + digitalWrite(Pin::DISPLAY_DC, HIGH); + + // Initialize ST7789V registers + init_registers(); + + _initialized = true; + INFO(" Display hardware ready"); + return true; +} + +void Display::init_registers() { + INFO(" Configuring ST7789V registers"); + + // Software reset + write_command(Command::SWRESET); + // DELAY RATIONALE: LCD reset pulse width + // ST7789 datasheet specifies minimum 120ms reset low time for reliable initialization. + // Using 150ms for margin. Shorter values cause display initialization failures. + delay(150); + + // Sleep out + write_command(Command::SLPOUT); + // DELAY RATIONALE: SPI command settling - allow display controller to process command before next + delay(10); + + // Color mode: 16-bit (RGB565) + write_command(Command::COLMOD); + write_data(0x55); // 16-bit color + + // Memory data access control (rotation + RGB order) + write_command(Command::MADCTL); + uint8_t madctl = MADCTL::MX | MADCTL::MY | MADCTL::RGB; + if (Disp::ROTATION == 1) { + madctl = MADCTL::MX | MADCTL::MV | MADCTL::RGB; // Landscape + } + write_data(madctl); + + // Inversion on (required for ST7789V panels) + write_command(Command::INVON); + // DELAY RATIONALE: SPI command settling - allow display controller to process command before next + delay(10); + + // Normal display mode + write_command(Command::NORON); + // DELAY RATIONALE: SPI command settling - allow display controller to process command before next + delay(10); + + // Display on + write_command(Command::DISPON); + // DELAY RATIONALE: SPI command settling - allow display controller to process command before next + delay(10); + + // Clear screen to black + fill_screen(0x0000); + + INFO(" ST7789V initialized"); +} + +void Display::set_brightness(uint8_t brightness) { + _brightness = brightness; + ledcWrite(Disp::BACKLIGHT_CHANNEL, brightness); +} + +uint8_t Display::get_brightness() { + return _brightness; +} + +void Display::set_power(bool on) { + if (on) { + write_command(Command::DISPON); + set_brightness(_brightness); + } else { + set_brightness(0); + write_command(Command::DISPOFF); + } +} + +void Display::fill_screen(uint16_t color) { + set_addr_window(0, 0, Disp::WIDTH - 1, Disp::HEIGHT - 1); + + begin_write(); + write_command(Command::RAMWR); + + // Send color data for entire screen + uint8_t color_bytes[2]; + color_bytes[0] = (color >> 8) & 0xFF; + color_bytes[1] = color & 0xFF; + + for (uint32_t i = 0; i < (uint32_t)Disp::WIDTH * Disp::HEIGHT; i++) { + write_data(color_bytes, 2); + } + end_write(); +} + +void Display::draw_rect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color) { + if (x < 0 || y < 0 || x + w > Disp::WIDTH || y + h > Disp::HEIGHT) { + return; + } + + set_addr_window(x, y, x + w - 1, y + h - 1); + + begin_write(); + write_command(Command::RAMWR); + + uint8_t color_bytes[2]; + color_bytes[0] = (color >> 8) & 0xFF; + color_bytes[1] = color & 0xFF; + + for (int32_t i = 0; i < w * h; i++) { + write_data(color_bytes, 2); + } + end_write(); +} + +void Display::lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p) { + int32_t w = area->x2 - area->x1 + 1; + int32_t h = area->y2 - area->y1 + 1; + + set_addr_window(area->x1, area->y1, area->x2, area->y2); + + begin_write(); + write_command(Command::RAMWR); + + // Send pixel data + // lv_color_t is RGB565, which matches ST7789V format + size_t len = w * h * sizeof(lv_color_t); + write_data((const uint8_t*)color_p, len); + + end_write(); + + // Tell LVGL we're done flushing + lv_disp_flush_ready(drv); +} + +void Display::write_command(uint8_t cmd) { + digitalWrite(Pin::DISPLAY_DC, LOW); // Command mode + digitalWrite(Pin::DISPLAY_CS, LOW); + _spi->transfer(cmd); + digitalWrite(Pin::DISPLAY_CS, HIGH); +} + +void Display::write_data(uint8_t data) { + digitalWrite(Pin::DISPLAY_DC, HIGH); // Data mode + digitalWrite(Pin::DISPLAY_CS, LOW); + _spi->transfer(data); + digitalWrite(Pin::DISPLAY_CS, HIGH); +} + +void Display::write_data(const uint8_t* data, size_t len) { + digitalWrite(Pin::DISPLAY_DC, HIGH); // Data mode + digitalWrite(Pin::DISPLAY_CS, LOW); + _spi->transferBytes(data, nullptr, len); + digitalWrite(Pin::DISPLAY_CS, HIGH); +} + +void Display::set_addr_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { + write_command(Command::CASET); // Column address set + write_data(x0 >> 8); + write_data(x0 & 0xFF); + write_data(x1 >> 8); + write_data(x1 & 0xFF); + + write_command(Command::RASET); // Row address set + write_data(y0 >> 8); + write_data(y0 & 0xFF); + write_data(y1 >> 8); + write_data(y1 & 0xFF); +} + +void Display::begin_write() { + _spi->beginTransaction(SPISettings(Disp::SPI_FREQUENCY, MSBFIRST, SPI_MODE0)); +} + +void Display::end_write() { + _spi->endTransaction(); +} + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO diff --git a/lib/tdeck_ui/Hardware/TDeck/Display.h b/lib/tdeck_ui/Hardware/TDeck/Display.h new file mode 100644 index 0000000..8e0df6e --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Display.h @@ -0,0 +1,153 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_DISPLAY_H +#define HARDWARE_TDECK_DISPLAY_H + +#include "Config.h" + +#ifdef ARDUINO +#include +#include +#include + +namespace Hardware { +namespace TDeck { + +/** + * ST7789V Display Driver for T-Deck Plus + * + * Provides: + * - SPI initialization for ST7789V controller + * - Backlight control via PWM + * - LVGL display flush callback + * - Display rotation support + * + * Memory: Uses PSRAM for LVGL buffers (2 x 320x240x2 bytes = 307KB) + */ +class Display { +public: + /** + * Initialize display and LVGL + * @return true if initialization successful + */ + static bool init(); + + /** + * Initialize display without LVGL (for hardware testing) + * @return true if initialization successful + */ + static bool init_hardware_only(); + + /** + * Set backlight brightness + * @param brightness 0-255 (0=off, 255=max) + */ + static void set_brightness(uint8_t brightness); + + /** + * Get current backlight brightness + * @return brightness 0-255 + */ + static uint8_t get_brightness(); + + /** + * Turn display on/off + * @param on true to turn on, false to turn off + */ + static void set_power(bool on); + + /** + * Fill entire display with color (for testing) + * @param color RGB565 color + */ + static void fill_screen(uint16_t color); + + /** + * Draw rectangle (for testing) + */ + static void draw_rect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color); + + /** + * LVGL flush callback - called by LVGL to update display + * Do not call directly - used internally by LVGL + */ + static void lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p); + +private: + // SPI commands for ST7789V + enum Command : uint8_t { + NOP = 0x00, + SWRESET = 0x01, + RDDID = 0x04, + RDDST = 0x09, + + SLPIN = 0x10, + SLPOUT = 0x11, + PTLON = 0x12, + NORON = 0x13, + + INVOFF = 0x20, + INVON = 0x21, + DISPOFF = 0x28, + DISPON = 0x29, + CASET = 0x2A, + RASET = 0x2B, + RAMWR = 0x2C, + RAMRD = 0x2E, + + PTLAR = 0x30, + COLMOD = 0x3A, + MADCTL = 0x36, + + FRMCTR1 = 0xB1, + FRMCTR2 = 0xB2, + FRMCTR3 = 0xB3, + INVCTR = 0xB4, + DISSET5 = 0xB6, + + PWCTR1 = 0xC0, + PWCTR2 = 0xC1, + PWCTR3 = 0xC2, + PWCTR4 = 0xC3, + PWCTR5 = 0xC4, + VMCTR1 = 0xC5, + + PWCTR6 = 0xFC, + + GMCTRP1 = 0xE0, + GMCTRN1 = 0xE1 + }; + + // MADCTL bits + enum MADCTL : uint8_t { + MY = 0x80, // Row address order + MX = 0x40, // Column address order + MV = 0x20, // Row/column exchange + ML = 0x10, // Vertical refresh order + RGB = 0x00, // RGB order + BGR = 0x08, // BGR order + MH = 0x04 // Horizontal refresh order + }; + + static SPIClass* _spi; + static uint8_t _brightness; + static bool _initialized; + + // Low-level SPI functions + static void write_command(uint8_t cmd); + static void write_data(uint8_t data); + static void write_data(const uint8_t* data, size_t len); + static void set_addr_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1); + static void begin_write(); + static void end_write(); + + // Initialization sequence + static void init_registers(); +}; + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO +#endif // HARDWARE_TDECK_DISPLAY_H diff --git a/lib/tdeck_ui/Hardware/TDeck/Keyboard.cpp b/lib/tdeck_ui/Hardware/TDeck/Keyboard.cpp new file mode 100644 index 0000000..a409e1a --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Keyboard.cpp @@ -0,0 +1,293 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "Keyboard.h" + +#ifdef ARDUINO + +#include "Log.h" + +using namespace RNS; + +namespace Hardware { +namespace TDeck { + +TwoWire* Keyboard::_wire = nullptr; +bool Keyboard::_initialized = false; +lv_indev_t* Keyboard::_indev = nullptr; +char Keyboard::_key_buffer[Kbd::MAX_KEYS_BUFFERED]; +uint8_t Keyboard::_buffer_head = 0; +uint8_t Keyboard::_buffer_tail = 0; +uint8_t Keyboard::_buffer_count = 0; +uint32_t Keyboard::_last_poll_time = 0; +uint32_t Keyboard::_last_key_time = 0; + +bool Keyboard::init(TwoWire& wire) { + if (_initialized) { + return true; + } + + INFO("Initializing T-Deck keyboard"); + + // Initialize hardware first + if (!init_hardware_only(wire)) { + return false; + } + + // Register LVGL input device + static lv_indev_drv_t indev_drv; + lv_indev_drv_init(&indev_drv); + indev_drv.type = LV_INDEV_TYPE_KEYPAD; + indev_drv.read_cb = lvgl_read_cb; + + _indev = lv_indev_drv_register(&indev_drv); + if (!_indev) { + ERROR("Failed to register keyboard with LVGL"); + return false; + } + + INFO("Keyboard initialized successfully"); + return true; +} + +lv_indev_t* Keyboard::get_indev() { + return _indev; +} + +bool Keyboard::init_hardware_only(TwoWire& wire) { + if (_initialized) { + return true; + } + + INFO("Initializing keyboard hardware"); + + _wire = &wire; + + // Verify keyboard is present by checking I2C address + _wire->beginTransmission(I2C::KEYBOARD_ADDR); + uint8_t result = _wire->endTransmission(); + if (result != 0) { + WARNING(" Keyboard not found on I2C bus"); + _wire = nullptr; + return false; + } + + // Clear any pending keys by reading and discarding + _wire->requestFrom(I2C::KEYBOARD_ADDR, (uint8_t)1); + while (_wire->available()) { + _wire->read(); + } + + // Clear buffer + clear_buffer(); + + _initialized = true; + INFO(" Keyboard hardware ready"); + return true; +} + +uint8_t Keyboard::poll() { + if (!_initialized || !_wire) { + return 0; + } + + uint32_t now = millis(); + if (now - _last_poll_time < Kbd::POLL_INTERVAL_MS) { + return 0; // Don't poll too frequently + } + _last_poll_time = now; + + // T-Deck keyboard uses simple protocol: just read 1 byte directly + // Returns ASCII key code if pressed, 0 if no key + uint8_t bytes_read = _wire->requestFrom(I2C::KEYBOARD_ADDR, (uint8_t)1); + if (bytes_read != 1) { + return 0; + } + + uint8_t key = _wire->read(); + + // No key pressed or invalid + if (key == KEY_NONE || key == 0xFF) { + return 0; + } + + // Add key to buffer + buffer_push((char)key); + return 1; +} + +char Keyboard::read_key() { + if (_buffer_count == 0) { + return 0; + } + return buffer_pop(); +} + +bool Keyboard::available() { + return _buffer_count > 0; +} + +uint8_t Keyboard::get_key_count() { + return _buffer_count; +} + +void Keyboard::clear_buffer() { + _buffer_head = 0; + _buffer_tail = 0; + _buffer_count = 0; +} + +uint8_t Keyboard::get_firmware_version() { + uint8_t version = 0; + if (!read_register(Register::REG_VERSION, &version)) { + return 0; + } + return version; +} + +void Keyboard::set_backlight(uint8_t brightness) { + if (!_wire || !_initialized) { + return; + } + + // Command 0x01 = LILYGO_KB_BRIGHTNESS_CMD + _wire->beginTransmission(I2C::KEYBOARD_ADDR); + _wire->write(0x01); + _wire->write(brightness); + _wire->endTransmission(); +} + +void Keyboard::backlight_on() { + set_backlight(255); +} + +void Keyboard::backlight_off() { + set_backlight(0); +} + +void Keyboard::lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) { + // Safety check - LVGL should never pass null but be defensive + if (!data) { + return; + } + + // Skip if not properly initialized + if (!_initialized || !_wire) { + data->state = LV_INDEV_STATE_RELEASED; + data->key = 0; + data->continue_reading = false; + return; + } + + // Poll for new keys + poll(); + + // Read next key from buffer + char key = read_key(); + + if (key != 0) { + data->state = LV_INDEV_STATE_PRESSED; + data->key = key; + } else { + data->state = LV_INDEV_STATE_RELEASED; + data->key = 0; + } + + // Continue reading is set automatically by LVGL based on state + data->continue_reading = (available() > 0); +} + +bool Keyboard::write_register(uint8_t reg, uint8_t value) { + if (!_wire) { + return false; + } + + _wire->beginTransmission(I2C::KEYBOARD_ADDR); + _wire->write(reg); + _wire->write(value); + uint8_t result = _wire->endTransmission(); + + return (result == 0); +} + +bool Keyboard::read_register(uint8_t reg, uint8_t* value) { + if (!_wire) { + return false; + } + + // Write register address + _wire->beginTransmission(I2C::KEYBOARD_ADDR); + _wire->write(reg); + uint8_t result = _wire->endTransmission(false); // Send repeated start + + if (result != 0) { + return false; + } + + // Read register value + if (_wire->requestFrom(I2C::KEYBOARD_ADDR, (uint8_t)1) != 1) { + return false; + } + + *value = _wire->read(); + return true; +} + +bool Keyboard::read_registers(uint8_t reg, uint8_t* buffer, size_t len) { + if (!_wire) { + return false; + } + + // Write register address + _wire->beginTransmission(I2C::KEYBOARD_ADDR); + _wire->write(reg); + uint8_t result = _wire->endTransmission(false); // Send repeated start + + if (result != 0) { + return false; + } + + // Read register values + if (_wire->requestFrom(I2C::KEYBOARD_ADDR, (uint8_t)len) != len) { + return false; + } + + for (size_t i = 0; i < len; i++) { + buffer[i] = _wire->read(); + } + + return true; +} + +void Keyboard::buffer_push(char key) { + if (_buffer_count >= Kbd::MAX_KEYS_BUFFERED) { + // Buffer full, drop oldest key + buffer_pop(); + } + + _key_buffer[_buffer_tail] = key; + _buffer_tail = (_buffer_tail + 1) % Kbd::MAX_KEYS_BUFFERED; + _buffer_count++; + _last_key_time = millis(); +} + +uint32_t Keyboard::get_last_key_time() { + return _last_key_time; +} + +char Keyboard::buffer_pop() { + if (_buffer_count == 0) { + return 0; + } + + char key = _key_buffer[_buffer_head]; + _buffer_head = (_buffer_head + 1) % Kbd::MAX_KEYS_BUFFERED; + _buffer_count--; + + return key; +} + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO diff --git a/lib/tdeck_ui/Hardware/TDeck/Keyboard.h b/lib/tdeck_ui/Hardware/TDeck/Keyboard.h new file mode 100644 index 0000000..bb40b26 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Keyboard.h @@ -0,0 +1,166 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_KEYBOARD_H +#define HARDWARE_TDECK_KEYBOARD_H + +#include "Config.h" + +#ifdef ARDUINO +#include +#include +#include + +namespace Hardware { +namespace TDeck { + +/** + * T-Deck Keyboard Driver (ESP32-C3 I2C Controller) + * + * The T-Deck keyboard is controlled by an ESP32-C3 microcontroller + * that communicates via I2C at address 0x55. Key presses are buffered + * and can be read via I2C registers. + * + * Features: + * - ASCII key code reading + * - Key buffer (up to 8 keys) + * - LVGL keyboard input driver integration + * - Modifier keys (Shift, Alt, Ctrl, Fn) + */ +class Keyboard { +public: + /** + * Initialize keyboard I2C communication + * @param wire Wire interface to use (default Wire) + * @return true if initialization successful + */ + static bool init(TwoWire& wire = Wire); + + /** + * Initialize keyboard without LVGL (for hardware testing) + * @param wire Wire interface to use (default Wire) + * @return true if initialization successful + */ + static bool init_hardware_only(TwoWire& wire = Wire); + + /** + * Poll keyboard for new key presses + * Should be called periodically (e.g., every 10ms) + * @return number of new keys available + */ + static uint8_t poll(); + + /** + * Read next key from buffer + * @return ASCII key code, or 0 if buffer empty + */ + static char read_key(); + + /** + * Check if keys are available in buffer + * @return true if keys available + */ + static bool available(); + + /** + * Get number of keys in buffer + * @return number of keys buffered + */ + static uint8_t get_key_count(); + + /** + * Clear key buffer + */ + static void clear_buffer(); + + /** + * LVGL keyboard input callback + * Do not call directly - used internally by LVGL + */ + static void lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data); + + /** + * Get keyboard firmware version (for diagnostics) + * @return firmware version, or 0 if read failed + */ + static uint8_t get_firmware_version(); + + /** + * Get the LVGL input device for the keyboard + * @return LVGL input device, or nullptr if not initialized + */ + static lv_indev_t* get_indev(); + + /** + * Set keyboard backlight brightness + * @param brightness 0-255 (0 = off, 255 = max) + */ + static void set_backlight(uint8_t brightness); + + /** + * Turn keyboard backlight on (max brightness) + */ + static void backlight_on(); + + /** + * Turn keyboard backlight off + */ + static void backlight_off(); + + /** + * Get time of last key press + * @return millis() timestamp of last key press, or 0 if never + */ + static uint32_t get_last_key_time(); + +private: + // Special key codes + enum SpecialKey : uint8_t { + KEY_NONE = 0x00, + KEY_BACKSPACE = 0x08, + KEY_TAB = 0x09, + KEY_ENTER = 0x0D, + KEY_ESC = 0x1B, + KEY_DELETE = 0x7F, + KEY_UP = 0x80, + KEY_DOWN = 0x81, + KEY_LEFT = 0x82, + KEY_RIGHT = 0x83, + KEY_FN = 0x90, + KEY_SYM = 0x91, + KEY_MIC = 0x92 + }; + + // Register addresses + enum Register : uint8_t { + REG_VERSION = 0x00, + REG_KEY_STATE = 0x01, + REG_KEY_COUNT = 0x02, + REG_KEY_DATA = 0x03 + }; + + static TwoWire* _wire; + static bool _initialized; + static lv_indev_t* _indev; + static char _key_buffer[Kbd::MAX_KEYS_BUFFERED]; + static uint8_t _buffer_head; + static uint8_t _buffer_tail; + static uint8_t _buffer_count; + static uint32_t _last_poll_time; + static uint32_t _last_key_time; + + // I2C communication + static bool write_register(uint8_t reg, uint8_t value); + static bool read_register(uint8_t reg, uint8_t* value); + static bool read_registers(uint8_t reg, uint8_t* buffer, size_t len); + + // Buffer management + static void buffer_push(char key); + static char buffer_pop(); +}; + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO +#endif // HARDWARE_TDECK_KEYBOARD_H diff --git a/lib/tdeck_ui/Hardware/TDeck/SDLogger.cpp b/lib/tdeck_ui/Hardware/TDeck/SDLogger.cpp new file mode 100644 index 0000000..6fa4bd2 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/SDLogger.cpp @@ -0,0 +1,193 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "SDLogger.h" + +#ifdef ARDUINO +#include +#endif + +using namespace Hardware::TDeck; + +// Static member initialization +bool SDLogger::_active = false; +bool SDLogger::_initialized = false; +uint32_t SDLogger::_bytes_written = 0; +uint32_t SDLogger::_last_flush = 0; +uint32_t SDLogger::_line_count = 0; + +#ifdef ARDUINO +File SDLogger::_logFile; + +bool SDLogger::init() { + if (_initialized) { + return _active; + } + _initialized = true; + + // Initialize SD card on shared SPI bus + // Note: SPI should already be initialized by display + if (!SD.begin(SDCard::CS)) { + Serial.println("[SDLogger] SD card mount failed"); + return false; + } + + // Check card type + uint8_t cardType = SD.cardType(); + if (cardType == CARD_NONE) { + Serial.println("[SDLogger] No SD card detected"); + return false; + } + + // Open or create log file + // Use append mode to preserve history across reboots + _logFile = SD.open(CURRENT_LOG, FILE_APPEND); + if (!_logFile) { + Serial.println("[SDLogger] Failed to open log file"); + return false; + } + + // Write boot marker + _logFile.println("\n========================================"); + _logFile.println("=== BOOT - SD LOGGING STARTED ==="); + _logFile.printf("=== Free heap: %lu bytes ===\n", ESP.getFreeHeap()); + _logFile.printf("=== Min free heap: %lu bytes ===\n", ESP.getMinFreeHeap()); + _logFile.println("========================================\n"); + _logFile.flush(); + + _bytes_written = 0; + _last_flush = millis(); + _line_count = 0; + _active = true; + + // Set log callback to capture all logs + RNS::setLogCallback(logCallback); + + Serial.println("[SDLogger] SD card logging active"); + Serial.printf("[SDLogger] Card size: %lluMB\n", SD.cardSize() / (1024 * 1024)); + + return true; +} + +void SDLogger::logCallback(const char* msg, RNS::LogLevel level) { + // Always print to serial as well + Serial.print(RNS::getTimeString()); + Serial.print(" ["); + Serial.print(RNS::getLevelName(level)); + Serial.print("] "); + Serial.println(msg); + Serial.flush(); + + // Write to SD if active + if (_active && _logFile) { + writeToFile(msg, level); + } +} + +void SDLogger::writeToFile(const char* msg, RNS::LogLevel level) { + // Format: timestamp [LEVEL] message + int written = _logFile.printf("%s [%s] %s\n", + RNS::getTimeString(), + RNS::getLevelName(level), + msg); + if (written > 0) { + _bytes_written += written; + _line_count++; + } + + // Flush periodically or after critical messages + uint32_t now = millis(); + bool should_flush = false; + + // Always flush errors and warnings immediately + if (level <= RNS::LOG_WARNING) { + should_flush = true; + } + // Flush every N lines + else if (_line_count >= FLUSH_AFTER_LINES) { + should_flush = true; + } + // Flush every N milliseconds + else if (now - _last_flush >= FLUSH_INTERVAL_MS) { + should_flush = true; + } + + if (should_flush) { + _logFile.flush(); + _last_flush = now; + _line_count = 0; + } + + // Check if we need to rotate + if (_bytes_written >= MAX_LOG_SIZE) { + rotateIfNeeded(); + } +} + +void SDLogger::flush() { + if (_active && _logFile) { + _logFile.flush(); + _last_flush = millis(); + _line_count = 0; + } +} + +void SDLogger::marker(const char* msg) { + if (_active && _logFile) { + _logFile.println("----------------------------------------"); + _logFile.printf(">>> MARKER: %s <<<\n", msg); + _logFile.printf(">>> Heap: %lu / Min: %lu <<<\n", + ESP.getFreeHeap(), ESP.getMinFreeHeap()); + _logFile.println("----------------------------------------"); + _logFile.flush(); + } +} + +void SDLogger::rotateIfNeeded() { + if (!_active || !_logFile) return; + + // Close current log + _logFile.close(); + + // Rotate: delete old B, rename A to B, rename current to A + if (SD.exists(LOG_FILE_B)) { + SD.remove(LOG_FILE_B); + } + if (SD.exists(LOG_FILE_A)) { + SD.rename(LOG_FILE_A, LOG_FILE_B); + } + if (SD.exists(CURRENT_LOG)) { + SD.rename(CURRENT_LOG, LOG_FILE_A); + } + + // Open new current log + _logFile = SD.open(CURRENT_LOG, FILE_WRITE); + if (!_logFile) { + _active = false; + Serial.println("[SDLogger] Failed to create new log file after rotation"); + return; + } + + _logFile.println("=== LOG ROTATED ===\n"); + _logFile.flush(); + _bytes_written = 0; +} + +void SDLogger::close() { + if (_active && _logFile) { + _logFile.println("\n=== LOG CLOSED CLEANLY ==="); + _logFile.flush(); + _logFile.close(); + _active = false; + } + // Restore default logging + RNS::setLogCallback(nullptr); +} + +#else +// Native build stubs +bool SDLogger::init() { return false; } +void SDLogger::flush() {} +void SDLogger::marker(const char*) {} +void SDLogger::close() {} +#endif diff --git a/lib/tdeck_ui/Hardware/TDeck/SDLogger.h b/lib/tdeck_ui/Hardware/TDeck/SDLogger.h new file mode 100644 index 0000000..decd531 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/SDLogger.h @@ -0,0 +1,89 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_SDLOGGER_H +#define HARDWARE_TDECK_SDLOGGER_H + +#include "Config.h" +#include + +#ifdef ARDUINO +#include +#include +#endif + +namespace Hardware { +namespace TDeck { + +/** + * SD Card Logger for crash debugging + * + * Writes logs to SD card with frequent flushing to capture + * context before crashes. Uses a ring buffer approach with + * two log files to prevent unbounded growth. + * + * Usage: + * SDLogger::init(); // Call after SPI is initialized + * // Logs are automatically written via RNS::setLogCallback + */ +class SDLogger { +public: + /** + * Initialize SD card and set up logging callback. + * Must be called after SPI bus is initialized (after display init). + * + * @return true if SD card mounted and logging active + */ + static bool init(); + + /** + * Check if SD logging is active + */ + static bool isActive() { return _active; } + + /** + * Force flush any buffered log data to SD card. + * Call periodically or before expected operations. + */ + static void flush(); + + /** + * Write a marker to help identify crash points. + * Use before operations that might crash. + */ + static void marker(const char* msg); + + /** + * Close log file cleanly (call before SD card removal) + */ + static void close(); + +private: + static void logCallback(const char* msg, RNS::LogLevel level); + static void rotateIfNeeded(); + static void writeToFile(const char* msg, RNS::LogLevel level); + + static bool _active; + static bool _initialized; + +#ifdef ARDUINO + static File _logFile; +#endif + + static uint32_t _bytes_written; + static uint32_t _last_flush; + static uint32_t _line_count; + + // Configuration + static constexpr uint32_t MAX_LOG_SIZE = 1024 * 1024; // 1MB per log file + static constexpr uint32_t FLUSH_INTERVAL_MS = 1000; // Flush every second + static constexpr uint32_t FLUSH_AFTER_LINES = 10; // Or every 10 lines + static constexpr const char* LOG_FILE_A = "/crash_log_a.txt"; + static constexpr const char* LOG_FILE_B = "/crash_log_b.txt"; + static constexpr const char* CURRENT_LOG = "/crash_log_current.txt"; +}; + +} // namespace TDeck +} // namespace Hardware + +#endif // HARDWARE_TDECK_SDLOGGER_H diff --git a/lib/tdeck_ui/Hardware/TDeck/Touch.cpp b/lib/tdeck_ui/Hardware/TDeck/Touch.cpp new file mode 100644 index 0000000..f7bf029 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Touch.cpp @@ -0,0 +1,323 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "Touch.h" + +#ifdef ARDUINO + +#include "Log.h" + +using namespace RNS; + +namespace Hardware { +namespace TDeck { + +TwoWire* Touch::_wire = nullptr; +bool Touch::_initialized = false; +uint8_t Touch::_i2c_addr = I2C::TOUCH_ADDR_1; +Touch::TouchPoint Touch::_points[Tch::MAX_TOUCH_POINTS]; +uint8_t Touch::_touch_count = 0; +uint32_t Touch::_last_poll_time = 0; + +bool Touch::init(TwoWire& wire) { + if (_initialized) { + return true; + } + + INFO("Initializing T-Deck touch controller"); + + // Initialize hardware first + if (!init_hardware_only(wire)) { + return false; + } + + // Register LVGL input device + static lv_indev_drv_t indev_drv; + lv_indev_drv_init(&indev_drv); + indev_drv.type = LV_INDEV_TYPE_POINTER; + indev_drv.read_cb = lvgl_read_cb; + + lv_indev_t* indev = lv_indev_drv_register(&indev_drv); + if (!indev) { + ERROR("Failed to register touch controller with LVGL"); + return false; + } + + INFO("Touch controller initialized successfully"); + return true; +} + +bool Touch::init_hardware_only(TwoWire& wire) { + if (_initialized) { + return true; + } + + INFO("Initializing touch hardware"); + + _wire = &wire; + + // Detect I2C address (0x5D or 0x14) + if (!detect_i2c_address()) { + ERROR(" Failed to detect GT911 I2C address"); + return false; + } + + INFO((" GT911 detected at address 0x" + String(_i2c_addr, HEX)).c_str()); + + // Verify product ID + if (!verify_product_id()) { + WARNING(" Could not verify GT911 product ID (may not be critical)"); + } + + // Clear initial touch state + for (uint8_t i = 0; i < Tch::MAX_TOUCH_POINTS; i++) { + _points[i].valid = false; + } + _touch_count = 0; + + _initialized = true; + INFO(" Touch hardware ready"); + return true; +} + +uint8_t Touch::poll() { + if (!_initialized) { + return 0; + } + + uint32_t now = millis(); + if (now - _last_poll_time < Tch::POLL_INTERVAL_MS) { + return _touch_count; // Don't poll too frequently + } + _last_poll_time = now; + + // Read status register + uint8_t status = 0; + if (!read_register(Tch::REG_STATUS, &status)) { + return 0; + } + + // Check if touch data is ready + bool buffer_status = (status & 0x80) != 0; + uint8_t touch_count = status & 0x0F; + + if (!buffer_status || touch_count == 0) { + // No touch or data not ready + _touch_count = 0; + for (uint8_t i = 0; i < Tch::MAX_TOUCH_POINTS; i++) { + _points[i].valid = false; + } + + // Clear status register + write_register(Tch::REG_STATUS, 0x00); + return 0; + } + + // Limit to max supported points + if (touch_count > Tch::MAX_TOUCH_POINTS) { + touch_count = Tch::MAX_TOUCH_POINTS; + } + + // Read touch point data + _touch_count = 0; + for (uint8_t i = 0; i < touch_count; i++) { + uint8_t point_data[8]; + uint16_t point_reg = Tch::REG_POINT_1 + (i * 8); + + if (read_registers(point_reg, point_data, 8)) { + uint8_t track_id = point_data[0]; + uint16_t x = point_data[1] | (point_data[2] << 8); + uint16_t y = point_data[3] | (point_data[4] << 8); + uint16_t size = point_data[5] | (point_data[6] << 8); + + // Validate coordinates + if (x < Tch::RAW_WIDTH && y < Tch::RAW_HEIGHT) { + _points[i].x = x; + _points[i].y = y; + _points[i].size = size; + _points[i].track_id = track_id; + _points[i].valid = true; + _touch_count++; + } else { + _points[i].valid = false; + } + } else { + _points[i].valid = false; + } + } + + // Clear remaining points + for (uint8_t i = touch_count; i < Tch::MAX_TOUCH_POINTS; i++) { + _points[i].valid = false; + } + + // Clear status register + write_register(Tch::REG_STATUS, 0x00); + + return _touch_count; +} + +bool Touch::get_point(uint8_t index, TouchPoint& point) { + if (index >= Tch::MAX_TOUCH_POINTS) { + return false; + } + + if (!_points[index].valid) { + return false; + } + + point = _points[index]; + return true; +} + +bool Touch::is_touched() { + return _touch_count > 0; +} + +uint8_t Touch::get_touch_count() { + return _touch_count; +} + +String Touch::get_product_id() { + uint8_t product_id[4]; + if (!read_registers(Tch::REG_PRODUCT_ID, product_id, 4)) { + return ""; + } + + char id_str[5]; + id_str[0] = (char)product_id[0]; + id_str[1] = (char)product_id[1]; + id_str[2] = (char)product_id[2]; + id_str[3] = (char)product_id[3]; + id_str[4] = '\0'; + + return String(id_str); +} + +void Touch::lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) { + // Poll for new touch data + poll(); + + // Get first touch point + TouchPoint point; + if (get_point(0, point)) { + data->state = LV_INDEV_STATE_PRESSED; + + // Transform touch coordinates for landscape display rotation + // Display uses MADCTL with MX|MV for landscape (rotation=1) + // GT911 reports in native portrait orientation: + // - raw X: 0-239 (portrait width, maps to screen Y after rotation) + // - raw Y: 0-319 (portrait height, maps to screen X after rotation) + // Transform: swap X/Y and invert the new Y axis + data->point.x = point.y; + data->point.y = Disp::HEIGHT - 1 - point.x; // Use display height (240), not raw height + } else { + data->state = LV_INDEV_STATE_RELEASED; + } + + // Continue reading is set automatically by LVGL based on state +} + +bool Touch::write_register(uint16_t reg, uint8_t value) { + if (!_wire) { + return false; + } + + _wire->beginTransmission(_i2c_addr); + _wire->write((uint8_t)(reg >> 8)); // Register high byte + _wire->write((uint8_t)(reg & 0xFF)); // Register low byte + _wire->write(value); + uint8_t result = _wire->endTransmission(); + + return (result == 0); +} + +bool Touch::read_register(uint16_t reg, uint8_t* value) { + if (!_wire) { + return false; + } + + // Write register address + _wire->beginTransmission(_i2c_addr); + _wire->write((uint8_t)(reg >> 8)); // Register high byte + _wire->write((uint8_t)(reg & 0xFF)); // Register low byte + uint8_t result = _wire->endTransmission(false); // Send repeated start + + if (result != 0) { + return false; + } + + // Read register value + if (_wire->requestFrom(_i2c_addr, (uint8_t)1) != 1) { + return false; + } + + *value = _wire->read(); + return true; +} + +bool Touch::read_registers(uint16_t reg, uint8_t* buffer, size_t len) { + if (!_wire) { + return false; + } + + // Write register address + _wire->beginTransmission(_i2c_addr); + _wire->write((uint8_t)(reg >> 8)); // Register high byte + _wire->write((uint8_t)(reg & 0xFF)); // Register low byte + uint8_t result = _wire->endTransmission(false); // Send repeated start + + if (result != 0) { + return false; + } + + // Read register values + if (_wire->requestFrom(_i2c_addr, (uint8_t)len) != len) { + return false; + } + + for (size_t i = 0; i < len; i++) { + buffer[i] = _wire->read(); + } + + return true; +} + +bool Touch::detect_i2c_address() { + // Try primary address first (0x5D) + _i2c_addr = I2C::TOUCH_ADDR_1; + _wire->beginTransmission(_i2c_addr); + uint8_t result = _wire->endTransmission(); + + if (result == 0) { + return true; + } + + // Try alternative address (0x14) + _i2c_addr = I2C::TOUCH_ADDR_2; + _wire->beginTransmission(_i2c_addr); + result = _wire->endTransmission(); + + return (result == 0); +} + +bool Touch::verify_product_id() { + String product_id = get_product_id(); + if (product_id.length() == 0) { + return false; + } + + // GT911 should return "911" as product ID + if (product_id.indexOf("911") >= 0) { + INFO((" Touch product ID: " + product_id).c_str()); + return true; + } + + WARNING((" Unexpected touch product ID: " + product_id).c_str()); + return false; +} + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO diff --git a/lib/tdeck_ui/Hardware/TDeck/Touch.h b/lib/tdeck_ui/Hardware/TDeck/Touch.h new file mode 100644 index 0000000..6aed7c0 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Touch.h @@ -0,0 +1,120 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_TOUCH_H +#define HARDWARE_TDECK_TOUCH_H + +#include "Config.h" + +#ifdef ARDUINO +#include +#include +#include + +namespace Hardware { +namespace TDeck { + +/** + * GT911 Capacitive Touch Driver for T-Deck Plus + * + * CRITICAL: Uses POLLING MODE ONLY - interrupts cause crashes! + * + * The GT911 shares the I2C bus with the keyboard. It supports up to + * 5 simultaneous touch points but typically we only use 1 for UI navigation. + * + * Features: + * - Multi-touch support (up to 5 points) + * - Polling mode with 10ms interval + * - LVGL touchpad integration + * - Automatic I2C address detection (0x5D or 0x14) + */ +class Touch { +public: + /** + * Touch point data + */ + struct TouchPoint { + uint16_t x; + uint16_t y; + uint16_t size; // Touch area size + uint8_t track_id; // Touch tracking ID + bool valid; + }; + + /** + * Initialize touch controller + * @param wire Wire interface to use (default Wire) + * @return true if initialization successful + */ + static bool init(TwoWire& wire = Wire); + + /** + * Initialize touch without LVGL (for hardware testing) + * @param wire Wire interface to use (default Wire) + * @return true if initialization successful + */ + static bool init_hardware_only(TwoWire& wire = Wire); + + /** + * Poll touch controller for new touch data + * Should be called periodically (e.g., every 10ms) + * @return number of touch points detected + */ + static uint8_t poll(); + + /** + * Get touch point data + * @param index Touch point index (0-4) + * @param point Output touch point data + * @return true if point is valid + */ + static bool get_point(uint8_t index, TouchPoint& point); + + /** + * Check if screen is currently touched + * @return true if at least one touch point detected + */ + static bool is_touched(); + + /** + * Get number of touch points + * @return number of active touch points + */ + static uint8_t get_touch_count(); + + /** + * LVGL touchpad input callback + * Do not call directly - used internally by LVGL + */ + static void lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data); + + /** + * Get product ID (for diagnostics) + * @return product ID string, or empty if read failed + */ + static String get_product_id(); + +private: + // Touch state + static TwoWire* _wire; + static bool _initialized; + static uint8_t _i2c_addr; + static TouchPoint _points[Tch::MAX_TOUCH_POINTS]; + static uint8_t _touch_count; + static uint32_t _last_poll_time; + + // I2C communication + static bool write_register(uint16_t reg, uint8_t value); + static bool read_register(uint16_t reg, uint8_t* value); + static bool read_registers(uint16_t reg, uint8_t* buffer, size_t len); + + // Initialization helpers + static bool detect_i2c_address(); + static bool verify_product_id(); +}; + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO +#endif // HARDWARE_TDECK_TOUCH_H diff --git a/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp b/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp new file mode 100644 index 0000000..0f696d2 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp @@ -0,0 +1,348 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "Trackball.h" + +#ifdef ARDUINO + +#include "Log.h" +#include + +using namespace RNS; + +namespace Hardware { +namespace TDeck { + +// Static member initialization +lv_indev_t* Trackball::_indev = nullptr; +volatile int16_t Trackball::_pulse_up = 0; +volatile int16_t Trackball::_pulse_down = 0; +volatile int16_t Trackball::_pulse_left = 0; +volatile int16_t Trackball::_pulse_right = 0; +volatile uint32_t Trackball::_last_pulse_time = 0; + +bool Trackball::_button_pressed = false; +uint32_t Trackball::_last_button_time = 0; +Trackball::State Trackball::_state; +bool Trackball::_initialized = false; + +bool Trackball::init() { + if (_initialized) { + return true; + } + + INFO("Initializing T-Deck trackball"); + + // Initialize hardware first + if (!init_hardware_only()) { + return false; + } + + // Register LVGL input device as KEYPAD for focus navigation + static lv_indev_drv_t indev_drv; + lv_indev_drv_init(&indev_drv); + indev_drv.type = LV_INDEV_TYPE_KEYPAD; // KEYPAD for 2D focus navigation + indev_drv.read_cb = lvgl_read_cb; + + _indev = lv_indev_drv_register(&indev_drv); + if (!_indev) { + ERROR("Failed to register trackball with LVGL"); + return false; + } + + INFO("Trackball initialized successfully"); + return true; +} + +bool Trackball::init_hardware_only() { + if (_initialized) { + return true; + } + + INFO("Initializing trackball hardware"); + + // Use ESP-IDF gpio driver for reliable interrupt handling on strapping pins + gpio_config_t io_conf = {}; + io_conf.intr_type = GPIO_INTR_NEGEDGE; // Falling edge trigger + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pull_up_en = GPIO_PULLUP_ENABLE; + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + + // Configure all trackball directional pins + io_conf.pin_bit_mask = (1ULL << Pin::TRACKBALL_UP) | + (1ULL << Pin::TRACKBALL_DOWN) | + (1ULL << Pin::TRACKBALL_LEFT) | + (1ULL << Pin::TRACKBALL_RIGHT); + gpio_config(&io_conf); + + // Configure button separately (just input with pullup, no interrupt) + gpio_config_t btn_conf = {}; + btn_conf.intr_type = GPIO_INTR_DISABLE; + btn_conf.mode = GPIO_MODE_INPUT; + btn_conf.pull_up_en = GPIO_PULLUP_ENABLE; + btn_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + btn_conf.pin_bit_mask = (1ULL << Pin::TRACKBALL_BUTTON); + gpio_config(&btn_conf); + + // Install GPIO ISR service + gpio_install_isr_service(0); + + // Attach ISR handlers + gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_UP, isr_up, nullptr); + gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_DOWN, isr_down, nullptr); + gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_LEFT, isr_left, nullptr); + gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_RIGHT, isr_right, nullptr); + + // Initialize state + _state.delta_x = 0; + _state.delta_y = 0; + _state.button_pressed = false; + _state.timestamp = millis(); + + _initialized = true; + INFO(" Trackball hardware ready"); + return true; +} + +bool Trackball::poll() { + if (!_initialized) { + return false; + } + + bool state_changed = false; + uint32_t now = millis(); + + // Read pulse counters (critical section to avoid race with ISRs) + noInterrupts(); + int16_t up = _pulse_up; + int16_t down = _pulse_down; + int16_t left = _pulse_left; + int16_t right = _pulse_right; + uint32_t last_pulse = _last_pulse_time; + interrupts(); + + + // Calculate net movement + int16_t delta_y = down - up; // Positive = down, negative = up + int16_t delta_x = right - left; // Positive = right, negative = left + + // Apply sensitivity multiplier + delta_x *= Trk::PIXELS_PER_PULSE; + delta_y *= Trk::PIXELS_PER_PULSE; + + // Update state if movement detected + if (delta_x != 0 || delta_y != 0) { + _state.delta_x = delta_x; + _state.delta_y = delta_y; + _state.timestamp = now; + state_changed = true; + + // Reset pulse counters after reading + noInterrupts(); + _pulse_up = 0; + _pulse_down = 0; + _pulse_left = 0; + _pulse_right = 0; + interrupts(); + } else { + // Reset deltas if no recent pulses (timeout) + if (now - last_pulse > Trk::PULSE_RESET_MS) { + if (_state.delta_x != 0 || _state.delta_y != 0) { + _state.delta_x = 0; + _state.delta_y = 0; + state_changed = true; + } + } + } + + // Read button state with debouncing + bool button = read_button_debounced(); + if (button != _state.button_pressed) { + _state.button_pressed = button; + state_changed = true; + } + + return state_changed; +} + +void Trackball::get_state(State& state) { + state = _state; +} + +void Trackball::reset_deltas() { + _state.delta_x = 0; + _state.delta_y = 0; +} + +bool Trackball::is_button_pressed() { + return _state.button_pressed; +} + +lv_indev_t* Trackball::get_indev() { + return _indev; +} + +void Trackball::lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) { + // Static accumulators for threshold-based navigation + static int16_t accum_x = 0; + static int16_t accum_y = 0; + static uint32_t last_key_time = 0; + // Key press/release state machine + static uint32_t pending_key = 0; // Key waiting to be pressed + static uint32_t pressed_key = 0; // Key currently pressed (needs release) + static bool button_was_pressed = false; // Track physical button state for release + // Poll for new trackball data + poll(); + + // Get current state + State state; + get_state(state); + + // Accumulate movement (convert back from pixels to pulses) + accum_x += state.delta_x / Trk::PIXELS_PER_PULSE; + accum_y += state.delta_y / Trk::PIXELS_PER_PULSE; + + // Button handling - trigger on release only to avoid double-activation + // When screen changes on press, release would hit new screen's focused element + if (state.button_pressed) { + button_was_pressed = true; + // Don't send anything yet, wait for release + } else if (button_was_pressed) { + // Button just released - queue ENTER key for press/release cycle + button_was_pressed = false; + pending_key = LV_KEY_ENTER; + // Fall through to let pending_key logic handle it + } + + // Check if we need to release a previously pressed navigation key + if (pressed_key != 0) { + data->key = pressed_key; + data->state = LV_INDEV_STATE_RELEASED; + pressed_key = 0; + reset_deltas(); + return; + } + + // Check if we have a pending key to press + if (pending_key != 0) { + data->key = pending_key; + data->state = LV_INDEV_STATE_PRESSED; + pressed_key = pending_key; // Mark for release on next callback + pending_key = 0; + reset_deltas(); + return; + } + + uint32_t now = millis(); + + // Check thresholds - use NEXT/PREV for group focus navigation + // LVGL groups only support linear navigation with NEXT/PREV + if (abs(accum_y) >= Trk::NAV_THRESHOLD && abs(accum_y) >= abs(accum_x)) { + if (now - last_key_time >= Trk::KEY_REPEAT_MS) { + pending_key = (accum_y > 0) ? LV_KEY_NEXT : LV_KEY_PREV; + last_key_time = now; + } + accum_y = 0; + } else if (abs(accum_x) >= Trk::NAV_THRESHOLD) { + if (now - last_key_time >= Trk::KEY_REPEAT_MS) { + pending_key = (accum_x > 0) ? LV_KEY_NEXT : LV_KEY_PREV; + last_key_time = now; + } + accum_x = 0; + } + + // If we have a pending key, check if anything visible is focused + // If nothing is focused or focused object is hidden, find a visible object + if (pending_key != 0) { + lv_group_t* group = lv_group_get_default(); + if (group) { + lv_obj_t* focused = lv_group_get_focused(group); + bool need_refocus = !focused; + + // Check if focused object or any parent is hidden + if (focused && !need_refocus) { + lv_obj_t* obj = focused; + while (obj) { + if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) { + need_refocus = true; + break; + } + obj = lv_obj_get_parent(obj); + } + } + + if (need_refocus) { + // Find first visible object in group + uint32_t obj_cnt = lv_group_get_obj_count(group); + for (uint32_t i = 0; i < obj_cnt; i++) { + lv_group_focus_next(group); + lv_obj_t* candidate = lv_group_get_focused(group); + if (candidate) { + bool visible = true; + lv_obj_t* obj = candidate; + while (obj) { + if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) { + visible = false; + break; + } + obj = lv_obj_get_parent(obj); + } + if (visible) break; // Found a visible object + } + } + pending_key = 0; // Don't send the key, just focus + accum_x = 0; + accum_y = 0; + } + } + } + + // Default: no key activity + data->key = 0; + data->state = LV_INDEV_STATE_RELEASED; + reset_deltas(); +} + +bool Trackball::read_button_debounced() { + bool current = (digitalRead(Pin::TRACKBALL_BUTTON) == LOW); // Active low + uint32_t now = millis(); + + // Debounce: only accept change if stable for debounce period + if (current != _button_pressed) { + if (now - _last_button_time > Trk::DEBOUNCE_MS) { + _last_button_time = now; + return current; + } + } else { + _last_button_time = now; + } + + return _button_pressed; +} + +// ISR handlers - MUST be in IRAM for ESP32 +// ESP-IDF gpio_isr_handler signature requires void* arg +void IRAM_ATTR Trackball::isr_up(void* arg) { + _pulse_up++; + _last_pulse_time = millis(); +} + +void IRAM_ATTR Trackball::isr_down(void* arg) { + _pulse_down++; + _last_pulse_time = millis(); +} + +void IRAM_ATTR Trackball::isr_left(void* arg) { + _pulse_left++; + _last_pulse_time = millis(); +} + +void IRAM_ATTR Trackball::isr_right(void* arg) { + _pulse_right++; + _last_pulse_time = millis(); +} + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO diff --git a/lib/tdeck_ui/Hardware/TDeck/Trackball.h b/lib/tdeck_ui/Hardware/TDeck/Trackball.h new file mode 100644 index 0000000..69e90b0 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/Trackball.h @@ -0,0 +1,132 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_TRACKBALL_H +#define HARDWARE_TDECK_TRACKBALL_H + +#include "Config.h" + +#ifdef ARDUINO +#include +#include + +namespace Hardware { +namespace TDeck { + +/** + * T-Deck Trackball Driver (GPIO Pulse-based) + * + * The trackball generates GPIO pulses on directional movement. + * We count pulses and convert them to cursor movement. + * + * Features: + * - 4-direction movement (up, down, left, right) + * - Center button press + * - Pulse counting for movement speed + * - LVGL encoder integration + * - Debouncing + */ +class Trackball { +public: + /** + * Trackball state + */ + struct State { + int16_t delta_x; // Horizontal movement (-n to +n) + int16_t delta_y; // Vertical movement (-n to +n) + bool button_pressed; // Center button state + uint32_t timestamp; // Last update timestamp + }; + + /** + * Initialize trackball GPIO pins and interrupts + * @return true if initialization successful + */ + static bool init(); + + /** + * Initialize trackball without LVGL (for hardware testing) + * @return true if initialization successful + */ + static bool init_hardware_only(); + + /** + * Poll trackball for movement and button state + * Should be called periodically (e.g., every 10ms) + * @return true if state changed + */ + static bool poll(); + + /** + * Get current trackball state + * @param state Output state structure + */ + static void get_state(State& state); + + /** + * Reset accumulated movement deltas + */ + static void reset_deltas(); + + /** + * Check if button is currently pressed + * @return true if button pressed + */ + static bool is_button_pressed(); + + /** + * LVGL input callback + * Do not call directly - used internally by LVGL + */ + static void lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data); + + /** + * Get the LVGL input device for the trackball + * @return LVGL input device, or nullptr if not initialized + */ + static lv_indev_t* get_indev(); + +private: + // LVGL input device + static lv_indev_t* _indev; + + // VOLATILE RATIONALE: ISR pulse counters + // + // These are modified by hardware interrupt handlers (IRAM_ATTR ISRs) + // and read by the main task for trackball input processing. + // + // Volatile required because: + // - ISR context modifies values asynchronously + // - Without volatile, compiler may cache values in registers + // - 16-bit/32-bit aligned values are atomic on ESP32 + // + // Reference: ESP-IDF GPIO interrupt documentation + static volatile int16_t _pulse_up; + static volatile int16_t _pulse_down; + static volatile int16_t _pulse_left; + static volatile int16_t _pulse_right; + static volatile uint32_t _last_pulse_time; + + // Button state + static bool _button_pressed; + static uint32_t _last_button_time; + + // State tracking + static State _state; + static bool _initialized; + + // ISR handlers (ESP-IDF gpio_isr_handler signature) + static void IRAM_ATTR isr_up(void* arg); + static void IRAM_ATTR isr_down(void* arg); + static void IRAM_ATTR isr_left(void* arg); + static void IRAM_ATTR isr_right(void* arg); + + // Button debouncing + static bool read_button_debounced(); +}; + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO +#endif // HARDWARE_TDECK_TRACKBALL_H diff --git a/lib/tdeck_ui/UI/Clipboard.cpp b/lib/tdeck_ui/UI/Clipboard.cpp new file mode 100644 index 0000000..cfca3b9 --- /dev/null +++ b/lib/tdeck_ui/UI/Clipboard.cpp @@ -0,0 +1,8 @@ +#include "Clipboard.h" + +namespace UI { + +String Clipboard::_content = ""; +bool Clipboard::_has_content = false; + +} // namespace UI diff --git a/lib/tdeck_ui/UI/Clipboard.h b/lib/tdeck_ui/UI/Clipboard.h new file mode 100644 index 0000000..e50da1e --- /dev/null +++ b/lib/tdeck_ui/UI/Clipboard.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +namespace UI { + +/** + * @brief Simple in-app clipboard for copy/paste functionality + * + * Since ESP32 doesn't have a system clipboard, this provides + * a simple static clipboard for the application. + */ +class Clipboard { +public: + /** + * @brief Copy text to clipboard + * @param text Text to copy + */ + static void copy(const String& text) { + _content = text; + _has_content = true; + } + + /** + * @brief Get clipboard content + * @return Reference to clipboard text (empty if nothing copied) + */ + static const String& paste() { + return _content; + } + + /** + * @brief Check if clipboard has content + * @return true if clipboard is not empty + */ + static bool has_content() { + return _has_content && _content.length() > 0; + } + + /** + * @brief Clear clipboard + */ + static void clear() { + _content = ""; + _has_content = false; + } + +private: + static String _content; + static bool _has_content; +}; + +} // namespace UI diff --git a/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp b/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp new file mode 100644 index 0000000..2ee429b --- /dev/null +++ b/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp @@ -0,0 +1,330 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "LVGLInit.h" + +#ifdef ARDUINO + +#include "esp_task_wdt.h" + +#include "Log.h" +#include "../../Hardware/TDeck/Display.h" +#include "../../Hardware/TDeck/Keyboard.h" +#include "../../Hardware/TDeck/Touch.h" +#include "../../Hardware/TDeck/Trackball.h" + +using namespace RNS; +using namespace Hardware::TDeck; + +namespace UI { +namespace LVGL { + +bool LVGLInit::_initialized = false; +lv_disp_t* LVGLInit::_display = nullptr; +lv_indev_t* LVGLInit::_keyboard = nullptr; +lv_indev_t* LVGLInit::_touch = nullptr; +lv_indev_t* LVGLInit::_trackball = nullptr; +lv_group_t* LVGLInit::_default_group = nullptr; +TaskHandle_t LVGLInit::_task_handle = nullptr; +SemaphoreHandle_t LVGLInit::_mutex = nullptr; + +bool LVGLInit::init() { + if (_initialized) { + return true; + } + + INFO("Initializing LVGL"); + + // Create recursive mutex for thread-safe LVGL access + // Recursive because LVGL callbacks may call other LVGL functions + _mutex = xSemaphoreCreateRecursiveMutex(); + if (!_mutex) { + ERROR("Failed to create LVGL mutex"); + return false; + } + + // Initialize LVGL library + lv_init(); + + // LVGL 8.x logging is configured via lv_conf.h LV_USE_LOG + // No runtime callback registration needed + + // Initialize display (this also sets up LVGL display driver) + if (!Display::init()) { + ERROR("Failed to initialize display for LVGL"); + return false; + } + _display = lv_disp_get_default(); + + INFO(" Display initialized"); + + // Create default input group for keyboard navigation + _default_group = lv_group_create(); + if (!_default_group) { + ERROR("Failed to create input group"); + return false; + } + lv_group_set_default(_default_group); + + // Initialize keyboard input + if (Keyboard::init()) { + _keyboard = Keyboard::get_indev(); + // Associate keyboard with input group + if (_keyboard) { + lv_indev_set_group(_keyboard, _default_group); + INFO(" Keyboard registered with input group"); + } + } else { + WARNING(" Keyboard initialization failed"); + } + + // Initialize touch input + if (Touch::init()) { + _touch = lv_indev_get_next(_keyboard); + INFO(" Touch registered"); + } else { + WARNING(" Touch initialization failed"); + } + + // Initialize trackball input + if (Trackball::init()) { + _trackball = Trackball::get_indev(); + // Associate trackball with input group for focus navigation + if (_trackball) { + lv_indev_set_group(_trackball, _default_group); + INFO(" Trackball registered with input group"); + } + } else { + WARNING(" Trackball initialization failed"); + } + + // Set default dark theme + set_theme(true); + + _initialized = true; + INFO("LVGL initialized successfully"); + + return true; +} + +bool LVGLInit::init_display_only() { + if (_initialized) { + return true; + } + + INFO("Initializing LVGL (display only)"); + + // Initialize LVGL library + lv_init(); + + // LVGL 8.x logging is configured via lv_conf.h LV_USE_LOG + // No runtime callback registration needed + + // Initialize display + if (!Display::init()) { + ERROR("Failed to initialize display for LVGL"); + return false; + } + _display = lv_disp_get_default(); + + INFO(" Display initialized"); + + // Set default dark theme + set_theme(true); + + _initialized = true; + INFO("LVGL initialized (display only)"); + + return true; +} + +void LVGLInit::task_handler() { + if (!_initialized) { + return; + } + + // If running as a task, this is a no-op + if (_task_handle != nullptr) { + return; + } + + lv_task_handler(); +} + +void LVGLInit::lvgl_task(void* param) { + Serial.printf("LVGL task started on core %d\n", xPortGetCoreID()); + + // Subscribe this task to Task Watchdog Timer + esp_task_wdt_add(nullptr); // nullptr = current task + + while (true) { + // Acquire mutex before calling LVGL +#ifndef NDEBUG + // Debug builds: 5-second timeout for stuck task detection + BaseType_t result = xSemaphoreTakeRecursive(_mutex, pdMS_TO_TICKS(5000)); + if (result != pdTRUE) { + WARNING("LVGL task mutex timeout (5s) - possible deadlock or stuck task"); + // Per Phase 8 context: log warning and continue waiting (don't break functionality) + xSemaphoreTakeRecursive(_mutex, portMAX_DELAY); + } +#else + xSemaphoreTakeRecursive(_mutex, portMAX_DELAY); +#endif + lv_task_handler(); + xSemaphoreGiveRecursive(_mutex); + + // Feed watchdog and yield to other tasks + esp_task_wdt_reset(); + vTaskDelay(pdMS_TO_TICKS(5)); + } +} + +bool LVGLInit::start_task(int priority, int core) { + if (!_initialized) { + ERROR("Cannot start LVGL task - not initialized"); + return false; + } + + if (_task_handle != nullptr) { + WARNING("LVGL task already running"); + return true; + } + + // Create the task + BaseType_t result; + if (core >= 0) { + result = xTaskCreatePinnedToCore( + lvgl_task, + "lvgl", + 8192, // Stack size (8KB should be enough for LVGL) + nullptr, + priority, + &_task_handle, + core + ); + } else { + result = xTaskCreate( + lvgl_task, + "lvgl", + 8192, + nullptr, + priority, + &_task_handle + ); + } + + if (result != pdPASS) { + ERROR("Failed to create LVGL task"); + return false; + } + + Serial.printf("LVGL task created with priority %d%s%d\n", + priority, + core >= 0 ? " on core " : "", + core >= 0 ? core : 0); + return true; +} + +bool LVGLInit::is_task_running() { + return _task_handle != nullptr; +} + +SemaphoreHandle_t LVGLInit::get_mutex() { + return _mutex; +} + +uint32_t LVGLInit::get_tick() { + return millis(); +} + +bool LVGLInit::is_initialized() { + return _initialized; +} + +void LVGLInit::set_theme(bool dark) { + if (!_initialized) { + return; + } + + lv_theme_t* theme; + + if (dark) { + // Dark theme with blue accents + theme = lv_theme_default_init( + _display, + lv_palette_main(LV_PALETTE_BLUE), // Primary color + lv_palette_main(LV_PALETTE_RED), // Secondary color + true, // Dark mode + &lv_font_montserrat_14 // Default font + ); + } else { + // Light theme + theme = lv_theme_default_init( + _display, + lv_palette_main(LV_PALETTE_BLUE), + lv_palette_main(LV_PALETTE_RED), + false, // Light mode + &lv_font_montserrat_14 + ); + } + + lv_disp_set_theme(_display, theme); +} + +lv_disp_t* LVGLInit::get_display() { + return _display; +} + +lv_indev_t* LVGLInit::get_keyboard() { + return _keyboard; +} + +lv_indev_t* LVGLInit::get_touch() { + return _touch; +} + +lv_indev_t* LVGLInit::get_trackball() { + return _trackball; +} + +lv_group_t* LVGLInit::get_default_group() { + return _default_group; +} + +void LVGLInit::focus_widget(lv_obj_t* obj) { + if (!_default_group || !obj) { + return; + } + + // Remove from group first if already there (to avoid duplicates) + lv_group_remove_obj(obj); + + // Add to group and focus + lv_group_add_obj(_default_group, obj); + lv_group_focus_obj(obj); +} + +void LVGLInit::log_print(const char* buf) { + // Forward LVGL logs to our logging system + // LVGL logs include newlines, so strip them + String msg(buf); + msg.trim(); + + if (msg.length() > 0) { + // LVGL log levels: Trace, Info, Warn, Error + if (msg.indexOf("[Error]") >= 0) { + ERROR(msg.c_str()); + } else if (msg.indexOf("[Warn]") >= 0) { + WARNING(msg.c_str()); + } else if (msg.indexOf("[Info]") >= 0) { + INFO(msg.c_str()); + } else { + TRACE(msg.c_str()); + } + } +} + +} // namespace LVGL +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LVGL/LVGLInit.h b/lib/tdeck_ui/UI/LVGL/LVGLInit.h new file mode 100644 index 0000000..a529496 --- /dev/null +++ b/lib/tdeck_ui/UI/LVGL/LVGLInit.h @@ -0,0 +1,153 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LVGL_LVGLINIT_H +#define UI_LVGL_LVGLINIT_H + +#ifdef ARDUINO +#include +#include + +namespace UI { +namespace LVGL { + +/** + * LVGL Initialization and Configuration + * + * Handles: + * - LVGL library initialization + * - Display driver integration + * - Input device integration (keyboard, touch, trackball) + * - Theme configuration + * - Memory management + * + * Must be called after hardware drivers are initialized. + */ +class LVGLInit { +public: + /** + * Initialize LVGL with all input devices + * Requires Display, Keyboard, Touch, and Trackball to be initialized first + * @return true if initialization successful + */ + static bool init(); + + /** + * Initialize LVGL with minimal setup (display only) + * @return true if initialization successful + */ + static bool init_display_only(); + + /** + * Task handler - must be called periodically (e.g., in main loop) + * Handles LVGL rendering and input processing + * NOTE: If start_task() was called, this is a no-op as LVGL runs on its own task + */ + static void task_handler(); + + /** + * Start LVGL on its own FreeRTOS task + * This allows LVGL to run independently of the main loop, preventing + * UI freezes when other operations (like BLE) take time. + * @param priority Task priority (default 1, higher than idle) + * @param core Core to pin the task to (-1 for no affinity, 0 or 1 for specific core) + * @return true if task started successfully + */ + static bool start_task(int priority = 1, int core = 1); + + /** + * Check if LVGL is running on its own task + * @return true if running as a FreeRTOS task + */ + static bool is_task_running(); + + /** + * Get the LVGL mutex for thread-safe access + * All LVGL API calls from outside the LVGL task must acquire this mutex. + * @return Recursive mutex handle + */ + static SemaphoreHandle_t get_mutex(); + + /** + * Get the LVGL FreeRTOS task handle + * Useful for stack monitoring and task introspection. + * @return Task handle, or nullptr if task not started + */ + static TaskHandle_t get_task_handle() { return _task_handle; } + + /** + * Get time in milliseconds for LVGL + * Required LVGL callback + */ + static uint32_t get_tick(); + + /** + * Check if LVGL is initialized + * @return true if initialized + */ + static bool is_initialized(); + + /** + * Set default theme (dark or light) + * @param dark true for dark theme, false for light theme + */ + static void set_theme(bool dark = true); + + /** + * Get current LVGL display object + * @return LVGL display object, or nullptr if not initialized + */ + static lv_disp_t* get_display(); + + /** + * Get keyboard input device + * @return LVGL input device, or nullptr if not initialized + */ + static lv_indev_t* get_keyboard(); + + /** + * Get touch input device + * @return LVGL input device, or nullptr if not initialized + */ + static lv_indev_t* get_touch(); + + /** + * Get trackball input device + * @return LVGL input device, or nullptr if not initialized + */ + static lv_indev_t* get_trackball(); + + /** + * Get the default input group for keyboard/encoder navigation + * @return LVGL group object + */ + static lv_group_t* get_default_group(); + + /** + * Focus a widget (add to group and set as focused) + * @param obj Widget to focus + */ + static void focus_widget(lv_obj_t* obj); + +private: + static bool _initialized; + static lv_disp_t* _display; + static lv_indev_t* _keyboard; + static lv_indev_t* _touch; + static lv_indev_t* _trackball; + static lv_group_t* _default_group; + + // FreeRTOS task support + static TaskHandle_t _task_handle; + static SemaphoreHandle_t _mutex; + static void lvgl_task(void* param); + + // LVGL logging callback + static void log_print(const char* buf); +}; + +} // namespace LVGL +} // namespace UI + +#endif // ARDUINO +#endif // UI_LVGL_LVGLINIT_H diff --git a/lib/tdeck_ui/UI/LVGL/LVGLLock.h b/lib/tdeck_ui/UI/LVGL/LVGLLock.h new file mode 100644 index 0000000..1b7ef61 --- /dev/null +++ b/lib/tdeck_ui/UI/LVGL/LVGLLock.h @@ -0,0 +1,80 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LVGL_LVGLLOCK_H +#define UI_LVGL_LVGLLOCK_H + +#ifdef ARDUINO + +#include "LVGLInit.h" +#include +#include + +namespace UI { +namespace LVGL { + +/** + * RAII lock for thread-safe LVGL access + * + * Usage: + * void SomeScreen::update() { + * LVGLLock lock; // Acquires mutex + * lv_label_set_text(label, "Hello"); + * // ... more LVGL calls ... + * } // Mutex released when lock goes out of scope + * + * Or use the macro: + * void SomeScreen::update() { + * LVGL_LOCK(); + * lv_label_set_text(label, "Hello"); + * } + */ +class LVGLLock { +public: + LVGLLock() { + SemaphoreHandle_t mutex = LVGLInit::get_mutex(); + if (mutex) { +#ifndef NDEBUG + // Debug builds: Use 5-second timeout for deadlock detection + BaseType_t result = xSemaphoreTakeRecursive(mutex, pdMS_TO_TICKS(5000)); + if (result != pdTRUE) { + // Log holder info if available + TaskHandle_t holder = xSemaphoreGetMutexHolder(mutex); + (void)holder; // Suppress unused warning if logging disabled + // Crash with diagnostic info + assert(false && "LVGL mutex timeout (5s) - possible deadlock"); + } + _acquired = true; +#else + // Release builds: Wait indefinitely (production behavior) + xSemaphoreTakeRecursive(mutex, portMAX_DELAY); + _acquired = true; +#endif + } + } + + ~LVGLLock() { + if (_acquired) { + SemaphoreHandle_t mutex = LVGLInit::get_mutex(); + if (mutex) { + xSemaphoreGiveRecursive(mutex); + } + } + } + + // Non-copyable + LVGLLock(const LVGLLock&) = delete; + LVGLLock& operator=(const LVGLLock&) = delete; + +private: + bool _acquired = false; +}; + +} // namespace LVGL +} // namespace UI + +// Convenience macro - creates a scoped lock variable +#define LVGL_LOCK() UI::LVGL::LVGLLock _lvgl_lock_guard + +#endif // ARDUINO +#endif // UI_LVGL_LVGLLOCK_H diff --git a/lib/tdeck_ui/UI/LXMF/AnnounceListScreen.cpp b/lib/tdeck_ui/UI/LXMF/AnnounceListScreen.cpp new file mode 100644 index 0000000..00b2538 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/AnnounceListScreen.cpp @@ -0,0 +1,456 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "AnnounceListScreen.h" +#include "Theme.h" + +#ifdef ARDUINO + +#include "Log.h" +#include "../LVGL/LVGLLock.h" +#include "Transport.h" +#include "Identity.h" +#include "Destination.h" +#include "Utilities/OS.h" +#include "../LVGL/LVGLInit.h" +#include + +using namespace RNS; + +namespace UI { +namespace LXMF { + +AnnounceListScreen::AnnounceListScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _list(nullptr), + _btn_back(nullptr), _btn_refresh(nullptr), _btn_announce(nullptr), _empty_label(nullptr) { + LVGL_LOCK(); + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Create UI components + create_header(); + create_list(); + + // Hide by default + hide(); + + TRACE("AnnounceListScreen created"); +} + +AnnounceListScreen::~AnnounceListScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void AnnounceListScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + _btn_back = lv_btn_create(_header); + lv_obj_set_size(_btn_back, 50, 28); + lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(_btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0); + + // Title + lv_obj_t* title = lv_label_create(_header); + lv_label_set_text(title, "Announces"); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 60, 0); + lv_obj_set_style_text_color(title, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + + // Send Announce button (green) + _btn_announce = lv_btn_create(_header); + lv_obj_set_size(_btn_announce, 65, 28); + lv_obj_align(_btn_announce, LV_ALIGN_RIGHT_MID, -70, 0); + lv_obj_set_style_bg_color(_btn_announce, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_announce, Theme::primaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_announce, on_send_announce_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_announce = lv_label_create(_btn_announce); + lv_label_set_text(label_announce, LV_SYMBOL_BELL); // Bell icon for announce + lv_obj_center(label_announce); + lv_obj_set_style_text_color(label_announce, Theme::textPrimary(), 0); + + // Refresh button + _btn_refresh = lv_btn_create(_header); + lv_obj_set_size(_btn_refresh, 65, 28); + lv_obj_align(_btn_refresh, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_set_style_bg_color(_btn_refresh, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_refresh, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_refresh, on_refresh_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_refresh = lv_label_create(_btn_refresh); + lv_label_set_text(label_refresh, LV_SYMBOL_REFRESH); + lv_obj_center(label_refresh); + lv_obj_set_style_text_color(label_refresh, Theme::textPrimary(), 0); +} + +void AnnounceListScreen::create_list() { + _list = lv_obj_create(_screen); + lv_obj_set_size(_list, LV_PCT(100), 204); // 240 - 36 (header) + lv_obj_align(_list, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_pad_all(_list, 4, 0); + lv_obj_set_style_pad_gap(_list, 4, 0); + lv_obj_set_style_bg_color(_list, Theme::surface(), 0); + lv_obj_set_style_border_width(_list, 0, 0); + lv_obj_set_style_radius(_list, 0, 0); + lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); +} + +void AnnounceListScreen::refresh() { + LVGL_LOCK(); + INFO("Refreshing announce list"); + + // Clear existing items (also removes from focus group when deleted) + lv_obj_clean(_list); + _announces.clear(); + _announce_containers.clear(); + _dest_hash_pool.clear(); + _empty_label = nullptr; + + // Get destination table from Transport + const auto& dest_table = Transport::get_destination_table(); + + // Compute name_hash for lxmf.delivery to filter announces + Bytes lxmf_delivery_name_hash = Destination::name_hash("lxmf", "delivery"); + + for (auto it = dest_table.begin(); it != dest_table.end(); ++it) { + const Bytes& dest_hash = it->first; + const Transport::DestinationEntry& dest_entry = it->second; + + // Check if this destination has a known identity (was announced properly) + Identity identity = Identity::recall(dest_hash); + if (!identity) { + continue; // Skip destinations without known identity + } + + // Verify this is an lxmf.delivery destination by computing expected hash + Bytes expected_hash = Destination::hash(identity, "lxmf", "delivery"); + if (dest_hash != expected_hash) { + continue; // Not an lxmf.delivery destination + } + + AnnounceItem item; + item.destination_hash = dest_hash; + item.hash_display = truncate_hash(dest_hash); + item.hops = dest_entry._hops; + item.timestamp = dest_entry._timestamp; + item.timestamp_str = format_timestamp(dest_entry._timestamp); + item.has_path = Transport::has_path(dest_hash); + + // Try to get display name from app_data + Bytes app_data = Identity::recall_app_data(dest_hash); + if (app_data && app_data.size() > 0) { + item.display_name = parse_display_name(app_data); + } + + _announces.push_back(item); + } + + // Sort by timestamp (newest first) + std::sort(_announces.begin(), _announces.end(), + [](const AnnounceItem& a, const AnnounceItem& b) { + return a.timestamp > b.timestamp; + }); + + { + char log_buf[64]; + snprintf(log_buf, sizeof(log_buf), " Found %zu announced destinations", _announces.size()); + INFO(log_buf); + } + + if (_announces.empty()) { + show_empty_state(); + } else { + // Limit to 20 most recent to prevent memory exhaustion + const size_t MAX_DISPLAY = 20; + size_t display_count = std::min(_announces.size(), MAX_DISPLAY); + + // Reserve capacity to avoid reallocations during population + _dest_hash_pool.reserve(display_count); + _announce_containers.reserve(display_count); + + size_t count = 0; + for (const auto& item : _announces) { + if (count >= MAX_DISPLAY) break; + create_announce_item(item); + count++; + } + } +} + +void AnnounceListScreen::show_empty_state() { + _empty_label = lv_label_create(_list); + lv_label_set_text(_empty_label, "No announces yet\n\nWaiting for LXMF\ndestinations to announce..."); + lv_obj_set_style_text_color(_empty_label, Theme::textMuted(), 0); + lv_obj_set_style_text_align(_empty_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(_empty_label, LV_ALIGN_CENTER, 0, 0); +} + +void AnnounceListScreen::create_announce_item(const AnnounceItem& item) { + // Create container for announce item - compact 2-row layout matching ConversationListScreen + lv_obj_t* container = lv_obj_create(_list); + lv_obj_set_size(container, LV_PCT(100), 44); + lv_obj_set_style_bg_color(container, Theme::surfaceContainer(), 0); + lv_obj_set_style_bg_color(container, Theme::surfaceElevated(), LV_STATE_PRESSED); + lv_obj_set_style_border_width(container, 1, 0); + lv_obj_set_style_border_color(container, Theme::border(), 0); + lv_obj_set_style_radius(container, 6, 0); + lv_obj_set_style_pad_all(container, 0, 0); + lv_obj_add_flag(container, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(container, LV_OBJ_FLAG_SCROLLABLE); + + // Focus style for trackball navigation + lv_obj_set_style_border_color(container, Theme::info(), LV_STATE_FOCUSED); + lv_obj_set_style_border_width(container, 2, LV_STATE_FOCUSED); + lv_obj_set_style_bg_color(container, Theme::surfaceElevated(), LV_STATE_FOCUSED); + + // Store destination hash in user data using pool (avoids per-item heap allocations) + _dest_hash_pool.push_back(item.destination_hash); + lv_obj_set_user_data(container, &_dest_hash_pool.back()); + lv_obj_add_event_cb(container, on_announce_clicked, LV_EVENT_CLICKED, this); + + // Track container for focus group management + _announce_containers.push_back(container); + + // Row 1: Display name (if available) or destination hash + lv_obj_t* label_name = lv_label_create(container); + if (item.display_name.length() > 0) { + lv_label_set_text(label_name, item.display_name.c_str()); + } else { + lv_label_set_text(label_name, item.hash_display.c_str()); + } + lv_obj_align(label_name, LV_ALIGN_TOP_LEFT, 6, 4); + lv_obj_set_style_text_color(label_name, Theme::info(), 0); + lv_obj_set_style_text_font(label_name, &lv_font_montserrat_14, 0); + + // Row 2: Hops info (left) + Timestamp (right) + lv_obj_t* label_hops = lv_label_create(container); + lv_label_set_text(label_hops, format_hops(item.hops).c_str()); + lv_obj_align(label_hops, LV_ALIGN_BOTTOM_LEFT, 6, -4); + lv_obj_set_style_text_color(label_hops, Theme::textTertiary(), 0); + + lv_obj_t* label_time = lv_label_create(container); + lv_label_set_text(label_time, item.timestamp_str.c_str()); + lv_obj_align(label_time, LV_ALIGN_BOTTOM_RIGHT, -6, -4); + lv_obj_set_style_text_color(label_time, Theme::textMuted(), 0); + + // Status indicator (green dot if has path) - on row 1, right side + if (item.has_path) { + lv_obj_t* status_dot = lv_obj_create(container); + lv_obj_set_size(status_dot, 8, 8); + lv_obj_align(status_dot, LV_ALIGN_TOP_RIGHT, -6, 8); + lv_obj_set_style_bg_color(status_dot, Theme::success(), 0); + lv_obj_set_style_radius(status_dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(status_dot, 0, 0); + lv_obj_set_style_pad_all(status_dot, 0, 0); + } +} + +void AnnounceListScreen::set_announce_selected_callback(AnnounceSelectedCallback callback) { + _announce_selected_callback = callback; +} + +void AnnounceListScreen::set_back_callback(BackCallback callback) { + _back_callback = callback; +} + +void AnnounceListScreen::set_send_announce_callback(SendAnnounceCallback callback) { + _send_announce_callback = callback; +} + +void AnnounceListScreen::show() { + LVGL_LOCK(); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); + + // Add widgets to focus group for trackball navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + // Add header buttons first + if (_btn_back) lv_group_add_obj(group, _btn_back); + if (_btn_announce) lv_group_add_obj(group, _btn_announce); + if (_btn_refresh) lv_group_add_obj(group, _btn_refresh); + + // Add announce containers + for (lv_obj_t* container : _announce_containers) { + lv_group_add_obj(group, container); + } + + // Focus on first announce if available, otherwise back button + if (!_announce_containers.empty()) { + lv_group_focus_obj(_announce_containers[0]); + } else if (_btn_back) { + lv_group_focus_obj(_btn_back); + } + } +} + +void AnnounceListScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_remove_obj(_btn_back); + if (_btn_announce) lv_group_remove_obj(_btn_announce); + if (_btn_refresh) lv_group_remove_obj(_btn_refresh); + + // Remove announce containers + for (lv_obj_t* container : _announce_containers) { + lv_group_remove_obj(container); + } + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* AnnounceListScreen::get_object() { + return _screen; +} + +void AnnounceListScreen::on_announce_clicked(lv_event_t* event) { + AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event); + lv_obj_t* target = lv_event_get_target(event); + + Bytes* dest_hash = (Bytes*)lv_obj_get_user_data(target); + + if (dest_hash && screen->_announce_selected_callback) { + screen->_announce_selected_callback(*dest_hash); + } +} + +void AnnounceListScreen::on_back_clicked(lv_event_t* event) { + AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event); + + if (screen->_back_callback) { + screen->_back_callback(); + } +} + +void AnnounceListScreen::on_refresh_clicked(lv_event_t* event) { + AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event); + screen->refresh(); +} + +void AnnounceListScreen::on_send_announce_clicked(lv_event_t* event) { + AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event); + if (screen->_send_announce_callback) { + screen->_send_announce_callback(); + } +} + +std::string AnnounceListScreen::format_timestamp(double timestamp) { + double now = Utilities::OS::time(); + double diff = now - timestamp; + char buf[16]; + + if (diff < 60) { + return "Just now"; + } else if (diff < 3600) { + int mins = (int)(diff / 60); + snprintf(buf, sizeof(buf), "%dm ago", mins); + return buf; + } else if (diff < 86400) { + int hours = (int)(diff / 3600); + snprintf(buf, sizeof(buf), "%dh ago", hours); + return buf; + } else { + int days = (int)(diff / 86400); + snprintf(buf, sizeof(buf), "%dd ago", days); + return buf; + } +} + +std::string AnnounceListScreen::format_hops(uint8_t hops) { + if (hops == 0) { + return "Direct"; + } else if (hops == 1) { + return "1 hop"; + } else { + char buf[16]; + snprintf(buf, sizeof(buf), "%u hops", hops); + return buf; + } +} + +std::string AnnounceListScreen::truncate_hash(const Bytes& hash) { + return hash.toHex(); +} + +std::string AnnounceListScreen::parse_display_name(const Bytes& app_data) { + if (app_data.size() == 0) { + return std::string(); + } + + uint8_t first_byte = app_data.data()[0]; + + // Check for msgpack array format (LXMF 0.5.0+) + // fixarray: 0x90-0x9f (array with 0-15 elements) + // array16: 0xdc + if ((first_byte >= 0x90 && first_byte <= 0x9f) || first_byte == 0xdc) { + // Msgpack encoded: [display_name, stamp_cost, ...] + MsgPack::Unpacker unpacker; + unpacker.feed(app_data.data(), app_data.size()); + + // Get array size + MsgPack::arr_size_t arr_size; + if (!unpacker.deserialize(arr_size)) { + return std::string(); + } + + if (arr_size.size() < 1) { + return std::string(); + } + + // First element is display_name (can be nil or bytes) + if (unpacker.isNil()) { + unpacker.unpackNil(); + return std::string(); + } + + MsgPack::bin_t name_bin; + if (unpacker.deserialize(name_bin)) { + // Convert bytes to string + return std::string((const char*)name_bin.data(), name_bin.size()); + } + + return std::string(); + } else { + // Original format: raw UTF-8 string + return app_data.toString(); + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/AnnounceListScreen.h b/lib/tdeck_ui/UI/LXMF/AnnounceListScreen.h new file mode 100644 index 0000000..41e7ee9 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/AnnounceListScreen.h @@ -0,0 +1,154 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_ANNOUNCELISTSCREEN_H +#define UI_LXMF_ANNOUNCELISTSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include +#include +#include "Bytes.h" + +namespace UI { +namespace LXMF { + +/** + * Announce List Screen + * + * Shows a scrollable list of announced LXMF destinations: + * - Destination hash (truncated) + * - Hop count / reachability + * - Timestamp of last announce + * - Tap to start conversation + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ ← Announces [Refresh]│ 32px header + * ├─────────────────────────────────────┤ + * │ ┌─ a1b2c3d4... │ + * │ │ 2 hops • 5 min ago │ + * │ └─ │ + * │ ┌─ e5f6a7b8... │ 168px scrollable + * │ │ Direct • Just now │ + * │ └─ │ + * ├─────────────────────────────────────┤ + * │ [💬] [📋] [📡] [⚙️] │ 32px bottom nav + * └─────────────────────────────────────┘ + */ +class AnnounceListScreen { +public: + /** + * Announce item data + */ + struct AnnounceItem { + RNS::Bytes destination_hash; + std::string hash_display; // Truncated hash for display + std::string display_name; // Display name from announce (if available) + uint8_t hops; // Hop count (0 = direct) + double timestamp; // When announced + std::string timestamp_str; // Human-readable time + bool has_path; // Whether path exists + }; + + /** + * Callback types + */ + using AnnounceSelectedCallback = std::function; + using BackCallback = std::function; + using RefreshCallback = std::function; + using SendAnnounceCallback = std::function; + + /** + * Create announce list screen + * @param parent Parent LVGL object (usually lv_scr_act()) + */ + AnnounceListScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~AnnounceListScreen(); + + /** + * Refresh announce list from Transport layer + */ + void refresh(); + + /** + * Set callback for announce selection (to start conversation) + * @param callback Function to call when announce is selected + */ + void set_announce_selected_callback(AnnounceSelectedCallback callback); + + /** + * Set callback for back button + * @param callback Function to call when back is pressed + */ + void set_back_callback(BackCallback callback); + + /** + * Set callback for send announce button + * @param callback Function to call when announce button is pressed + */ + void set_send_announce_callback(SendAnnounceCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + * @return Root object + */ + lv_obj_t* get_object(); + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _list; + lv_obj_t* _btn_back; + lv_obj_t* _btn_refresh; + lv_obj_t* _btn_announce; + lv_obj_t* _empty_label; + + std::vector _announces; + std::vector _announce_containers; // For focus group management + std::vector _dest_hash_pool; // Object pool to avoid per-item allocations + + AnnounceSelectedCallback _announce_selected_callback; + BackCallback _back_callback; + SendAnnounceCallback _send_announce_callback; + + // UI construction + void create_header(); + void create_list(); + void create_announce_item(const AnnounceItem& item); + void show_empty_state(); + + // Event handlers + static void on_announce_clicked(lv_event_t* event); + static void on_back_clicked(lv_event_t* event); + static void on_refresh_clicked(lv_event_t* event); + static void on_send_announce_clicked(lv_event_t* event); + + // Utility + static std::string format_timestamp(double timestamp); + static std::string format_hops(uint8_t hops); + static std::string truncate_hash(const RNS::Bytes& hash); + static std::string parse_display_name(const RNS::Bytes& app_data); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_ANNOUNCELISTSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp b/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp new file mode 100644 index 0000000..d3aa6fd --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp @@ -0,0 +1,686 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "ChatScreen.h" +#include "Theme.h" + +#ifdef ARDUINO + +#include "Log.h" +#include "Identity.h" +#include "../LVGL/LVGLInit.h" +#include "../LVGL/LVGLLock.h" +#include "../Clipboard.h" +#include + +using namespace RNS; + +namespace UI { +namespace LXMF { + +ChatScreen::ChatScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _message_list(nullptr), _input_area(nullptr), + _text_area(nullptr), _btn_send(nullptr), _btn_back(nullptr), + _message_store(nullptr), _display_start_idx(0), _loading_more(false) { + LVGL_LOCK(); + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Create UI components + create_header(); + create_message_list(); + create_input_area(); + + // Hide by default + hide(); + + TRACE("ChatScreen created"); +} + +ChatScreen::~ChatScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void ChatScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + _btn_back = lv_btn_create(_header); + lv_obj_set_size(_btn_back, 50, 28); + lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(_btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0); + + // Peer name/hash (will be set when conversation is loaded) + lv_obj_t* label_peer = lv_label_create(_header); + lv_label_set_text(label_peer, "Chat"); + lv_obj_align(label_peer, LV_ALIGN_LEFT_MID, 60, 0); + lv_obj_set_style_text_color(label_peer, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(label_peer, &lv_font_montserrat_16, 0); + +} + +void ChatScreen::create_message_list() { + _message_list = lv_obj_create(_screen); + lv_obj_set_size(_message_list, LV_PCT(100), 152); // 240 - 36 (header) - 52 (input) + lv_obj_align(_message_list, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_pad_all(_message_list, 4, 0); + lv_obj_set_style_pad_gap(_message_list, 4, 0); + lv_obj_set_style_bg_color(_message_list, lv_color_hex(0x0d0d0d), 0); // Slightly darker + lv_obj_set_style_border_width(_message_list, 0, 0); + lv_obj_set_style_radius(_message_list, 0, 0); + lv_obj_set_flex_flow(_message_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_message_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + // Add scroll event for infinite scroll (load more when at top) + lv_obj_add_event_cb(_message_list, on_scroll, LV_EVENT_SCROLL_END, this); +} + +void ChatScreen::create_input_area() { + _input_area = lv_obj_create(_screen); + lv_obj_set_size(_input_area, LV_PCT(100), 52); + lv_obj_align(_input_area, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(_input_area, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_input_area, 0, 0); + lv_obj_set_style_radius(_input_area, 0, 0); + lv_obj_set_style_pad_all(_input_area, 0, 0); + lv_obj_clear_flag(_input_area, LV_OBJ_FLAG_SCROLLABLE); + + // Text area for message input + _text_area = lv_textarea_create(_input_area); + lv_obj_set_size(_text_area, 241, 40); + lv_obj_align(_text_area, LV_ALIGN_LEFT_MID, 4, 0); + lv_textarea_set_placeholder_text(_text_area, "Type message..."); + lv_textarea_set_one_line(_text_area, false); + lv_textarea_set_max_length(_text_area, 500); + lv_obj_set_style_bg_color(_text_area, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_text_area, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_text_area, Theme::border(), 0); + + // Add long-press for paste + lv_obj_add_event_cb(_text_area, on_textarea_long_pressed, LV_EVENT_LONG_PRESSED, this); + + // Send button + _btn_send = lv_btn_create(_input_area); + lv_obj_set_size(_btn_send, 67, 40); + lv_obj_align(_btn_send, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_bg_color(_btn_send, Theme::successDark(), 0); + lv_obj_set_style_bg_color(_btn_send, Theme::successPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_send, on_send_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_send = lv_label_create(_btn_send); + lv_label_set_text(label_send, "Send"); + lv_obj_center(label_send); + lv_obj_set_style_text_color(label_send, Theme::textPrimary(), 0); +} + +void ChatScreen::load_conversation(const Bytes& peer_hash, ::LXMF::MessageStore& store) { + LVGL_LOCK(); + _peer_hash = peer_hash; + _message_store = &store; + + { + char log_buf[64]; + snprintf(log_buf, sizeof(log_buf), "Loading conversation with peer %.8s...", + peer_hash.toHex().c_str()); + INFO(log_buf); + } + + // Try to get display name from app_data + String peer_name; + Bytes app_data = Identity::recall_app_data(peer_hash); + if (app_data && app_data.size() > 0) { + peer_name = parse_display_name(app_data); + } + + // Fall back to truncated hash if no display name + if (peer_name.length() == 0) { + char hash_buf[20]; + snprintf(hash_buf, sizeof(hash_buf), "%.12s...", peer_hash.toHex().c_str()); + peer_name = hash_buf; + } + + // Update header with peer info + lv_obj_t* label_peer = lv_obj_get_child(_header, 1); // Second child is peer label + lv_label_set_text(label_peer, peer_name.c_str()); + + refresh(); +} + +void ChatScreen::refresh() { + LVGL_LOCK(); + if (!_message_store) { + return; + } + + INFO("Refreshing chat messages"); + + // Clear existing messages and row tracking + lv_obj_clean(_message_list); + _messages.clear(); + _message_rows.clear(); + + // Reserve capacity for message hashes to reduce fragmentation + _all_message_hashes.reserve(200); + + // Load all message hashes from store (sorted by timestamp) + _all_message_hashes = _message_store->get_messages_for_conversation(_peer_hash); + + // Start displaying from the most recent messages + if (_all_message_hashes.size() > MESSAGES_PER_PAGE) { + _display_start_idx = _all_message_hashes.size() - MESSAGES_PER_PAGE; + } else { + _display_start_idx = 0; + } + + { + char log_buf[80]; + snprintf(log_buf, sizeof(log_buf), " Found %zu messages, displaying last %zu", + _all_message_hashes.size(), _all_message_hashes.size() - _display_start_idx); + INFO(log_buf); + } + + for (size_t i = _display_start_idx; i < _all_message_hashes.size(); i++) { + const auto& msg_hash = _all_message_hashes[i]; + + // Use fast metadata loader (no msgpack unpacking) + ::LXMF::MessageStore::MessageMetadata meta = _message_store->load_message_metadata(msg_hash); + + if (!meta.valid) { + continue; + } + + MessageItem item; + item.message_hash = msg_hash; + item.content = String(meta.content.c_str()); + format_timestamp(meta.timestamp, item.timestamp_str, sizeof(item.timestamp_str)); + item.outgoing = !meta.incoming; + item.delivered = (meta.state == static_cast(::LXMF::Type::Message::DELIVERED)); + item.failed = (meta.state == static_cast(::LXMF::Type::Message::FAILED)); + + _messages.push_back(item); + create_message_bubble(item); + } + + // Scroll to bottom + lv_obj_scroll_to_y(_message_list, LV_COORD_MAX, LV_ANIM_OFF); +} + +void ChatScreen::load_more_messages() { + LVGL_LOCK(); + if (_loading_more || _display_start_idx == 0 || !_message_store) { + return; // Already at the beginning or already loading + } + + _loading_more = true; + INFO("Loading more messages..."); + + // Calculate how many more to load + size_t load_count = MESSAGES_PER_PAGE; + if (_display_start_idx < load_count) { + load_count = _display_start_idx; + } + size_t new_start_idx = _display_start_idx - load_count; + + { + char log_buf[64]; + snprintf(log_buf, sizeof(log_buf), " Loading messages %zu to %zu", + new_start_idx, _display_start_idx - 1); + INFO(log_buf); + } + + // Load and prepend messages directly (no temporary vector allocation) + // Process in reverse order so push_front maintains correct sequence + size_t items_added = 0; + for (size_t i = _display_start_idx; i > new_start_idx; ) { + --i; // Decrement first since we're iterating backwards + const auto& msg_hash = _all_message_hashes[i]; + + ::LXMF::MessageStore::MessageMetadata meta = _message_store->load_message_metadata(msg_hash); + if (!meta.valid) { + continue; + } + + MessageItem item; + item.message_hash = msg_hash; + item.content = String(meta.content.c_str()); + format_timestamp(meta.timestamp, item.timestamp_str, sizeof(item.timestamp_str)); + item.outgoing = !meta.incoming; + item.delivered = (meta.state == static_cast(::LXMF::Type::Message::DELIVERED)); + item.failed = (meta.state == static_cast(::LXMF::Type::Message::FAILED)); + + // Create bubble at index 0 (top of list) + create_message_bubble(item); + lv_obj_t* bubble_row = lv_obj_get_child(_message_list, lv_obj_get_child_cnt(_message_list) - 1); + lv_obj_move_to_index(bubble_row, 0); + + // Prepend to deque (O(1) operation) + _messages.push_front(item); + items_added++; + } + _display_start_idx = new_start_idx; + + _loading_more = false; + { + char log_buf[48]; + snprintf(log_buf, sizeof(log_buf), " Now displaying %zu messages", _messages.size()); + INFO(log_buf); + } +} + +void ChatScreen::on_scroll(lv_event_t* event) { + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + // Check if scrolled to top + lv_coord_t scroll_y = lv_obj_get_scroll_y(screen->_message_list); + + if (scroll_y <= 5) { // Near top (with small threshold) + screen->load_more_messages(); + } +} + +void ChatScreen::create_message_bubble(const MessageItem& item) { + // Create a full-width row container for alignment + lv_obj_t* row = lv_obj_create(_message_list); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Track row for targeted status updates + _message_rows[item.message_hash] = row; + + // Create the actual message bubble inside the row + lv_obj_t* bubble = lv_obj_create(row); + lv_obj_set_width(bubble, LV_PCT(80)); + lv_obj_set_height(bubble, LV_SIZE_CONTENT); + + // Style based on incoming/outgoing + if (item.outgoing) { + // Outgoing: purple, align right + lv_obj_set_style_bg_color(bubble, Theme::primary(), 0); + lv_obj_align(bubble, LV_ALIGN_RIGHT_MID, 0, 0); + } else { + // Incoming: gray, align left + lv_obj_set_style_bg_color(bubble, lv_color_hex(0x424242), 0); + lv_obj_align(bubble, LV_ALIGN_LEFT_MID, 0, 0); + } + + lv_obj_set_style_radius(bubble, 10, 0); + lv_obj_set_style_pad_all(bubble, 8, 0); + lv_obj_clear_flag(bubble, LV_OBJ_FLAG_SCROLLABLE); + + // Enable clickable for long-press detection + lv_obj_add_flag(bubble, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(bubble, on_message_long_pressed, LV_EVENT_LONG_PRESSED, this); + + // Build status text using char buffer to avoid String fragmentation + char status_text[32]; + build_status_text(status_text, sizeof(status_text), item.timestamp_str, + item.outgoing, item.delivered, item.failed); + + // Calculate text widths to decide layout + // Bubble is 80% of 320 = 256px, minus 16px padding = 240px usable + const lv_coord_t bubble_inner_width = 240; + const lv_font_t* font = &lv_font_montserrat_14; + const lv_coord_t gap = 12; // Space between message and timestamp + + lv_coord_t msg_width = lv_txt_get_width( + item.content.c_str(), item.content.length(), font, 0, LV_TEXT_FLAG_NONE); + lv_coord_t status_width = lv_txt_get_width( + status_text, strlen(status_text), font, 0, LV_TEXT_FLAG_NONE); + + // Use single-line layout if message + timestamp fit on one row + bool single_line = (msg_width + status_width + gap) <= bubble_inner_width; + + if (single_line) { + // Row layout: message and timestamp side by side + lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bubble, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // Message content + lv_obj_t* label_content = lv_label_create(bubble); + lv_label_set_text(label_content, item.content.c_str()); + lv_obj_set_style_text_color(label_content, lv_color_white(), 0); + + // Timestamp on same row + lv_obj_t* label_status = lv_label_create(bubble); + lv_label_set_text(label_status, status_text); + lv_obj_set_style_text_color(label_status, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(label_status, font, 0); + } else { + // Column layout: message above, timestamp below (for longer messages) + lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bubble, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + // Message content with wrapping + lv_obj_t* label_content = lv_label_create(bubble); + lv_label_set_text(label_content, item.content.c_str()); + lv_label_set_long_mode(label_content, LV_LABEL_LONG_WRAP); + lv_obj_set_width(label_content, LV_PCT(100)); + lv_obj_set_style_text_color(label_content, lv_color_white(), 0); + + // Timestamp on its own row + lv_obj_t* label_status = lv_label_create(bubble); + lv_label_set_text(label_status, status_text); + lv_obj_set_width(label_status, LV_PCT(100)); + lv_obj_set_style_text_align(label_status, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_set_style_text_color(label_status, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(label_status, font, 0); + } +} + +void ChatScreen::add_message(const ::LXMF::LXMessage& message, bool outgoing) { + LVGL_LOCK(); + MessageItem item; + item.message_hash = message.hash(); + + String content((const char*)message.content().data(), message.content().size()); + item.content = content; + format_timestamp(message.timestamp(), item.timestamp_str, sizeof(item.timestamp_str)); + item.outgoing = outgoing; + item.delivered = false; + item.failed = false; + + // Remove oldest messages if we exceed the limit + while (_messages.size() >= MAX_DISPLAYED_MESSAGES) { + // Remove from tracking map + _message_rows.erase(_messages.front().message_hash); + _messages.erase(_messages.begin()); + // Remove first child (oldest) from message list + lv_obj_t* first_row = lv_obj_get_child(_message_list, 0); + if (first_row) { + lv_obj_del(first_row); + } + } + + _messages.push_back(item); + create_message_bubble(item); + + // Scroll to bottom + lv_obj_scroll_to_y(_message_list, LV_COORD_MAX, LV_ANIM_ON); +} + +void ChatScreen::update_message_status(const Bytes& message_hash, bool delivered) { + LVGL_LOCK(); + // Find message and update status in our data + for (auto& msg : _messages) { + if (msg.message_hash == message_hash) { + msg.delivered = delivered; + msg.failed = !delivered; + + // Update just the status label instead of full refresh + auto row_it = _message_rows.find(message_hash); + if (row_it != _message_rows.end()) { + lv_obj_t* row = row_it->second; + // Structure: row -> bubble -> [content_label, status_label] + lv_obj_t* bubble = lv_obj_get_child(row, 0); + if (bubble) { + // Status label is always the last child + uint32_t child_count = lv_obj_get_child_cnt(bubble); + if (child_count > 0) { + lv_obj_t* status_label = lv_obj_get_child(bubble, child_count - 1); + if (status_label) { + char status_text[32]; + build_status_text(status_text, sizeof(status_text), msg.timestamp_str, + msg.outgoing, msg.delivered, msg.failed); + lv_label_set_text(status_label, status_text); + } + } + } + } + break; + } + } +} + +void ChatScreen::set_back_callback(BackCallback callback) { + _back_callback = callback; +} + +void ChatScreen::set_send_message_callback(SendMessageCallback callback) { + _send_message_callback = callback; +} + +void ChatScreen::show() { + LVGL_LOCK(); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); // Bring to front for touch events + + // Add buttons to default group for trackball navigation + // Note: text area not included since edit mode consumes arrow keys + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_add_obj(group, _btn_back); + if (_btn_send) lv_group_add_obj(group, _btn_send); + + // Focus on back button + if (_btn_back) { + lv_group_focus_obj(_btn_back); + } + } +} + +void ChatScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_remove_obj(_btn_back); + if (_btn_send) lv_group_remove_obj(_btn_send); + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* ChatScreen::get_object() { + return _screen; +} + +void ChatScreen::on_back_clicked(lv_event_t* event) { + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + if (screen->_back_callback) { + screen->_back_callback(); + } +} + +void ChatScreen::on_send_clicked(lv_event_t* event) { + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + // Get message text + const char* text = lv_textarea_get_text(screen->_text_area); + String message(text); + + if (message.length() > 0 && screen->_send_message_callback) { + screen->_send_message_callback(message); + + // Clear text area and keep focus for next message + lv_textarea_set_text(screen->_text_area, ""); + lv_group_focus_obj(screen->_text_area); + } +} + +void ChatScreen::format_timestamp(double timestamp, char* buf, size_t buf_size) { + // Convert to time_t for formatting + time_t time = (time_t)timestamp; + struct tm* timeinfo = localtime(&time); + + strftime(buf, buf_size, "%I:%M %p", timeinfo); +} + +const char* ChatScreen::get_delivery_indicator(bool outgoing, bool delivered, bool failed) { + if (!outgoing) { + return ""; // No indicator for incoming messages + } + + if (failed) { + return LV_SYMBOL_CLOSE; // X for failed + } else if (delivered) { + return LV_SYMBOL_OK LV_SYMBOL_OK; // Double check for delivered + } else { + return LV_SYMBOL_OK; // Single check for sent + } +} + +void ChatScreen::build_status_text(char* buf, size_t buf_size, const char* timestamp, + bool outgoing, bool delivered, bool failed) { + const char* indicator = get_delivery_indicator(outgoing, delivered, failed); + if (indicator[0] != '\0') { + snprintf(buf, buf_size, "%s %s", timestamp, indicator); + } else { + snprintf(buf, buf_size, "%s", timestamp); + } +} + +String ChatScreen::parse_display_name(const Bytes& app_data) { + if (app_data.size() == 0) { + return String(); + } + + // Check first byte to determine format + uint8_t first_byte = app_data.data()[0]; + + // Msgpack fixarray (0x90-0x9f) or array16 (0xdc) indicates LXMF 0.5.0+ format + if ((first_byte >= 0x90 && first_byte <= 0x9f) || first_byte == 0xdc) { + // Msgpack encoded: [display_name, stamp_cost] + MsgPack::Unpacker unpacker; + unpacker.feed(app_data.data(), app_data.size()); + + // Read array header + MsgPack::arr_size_t arr_size; + if (!unpacker.deserialize(arr_size)) { + return String(); + } + + if (arr_size.size() < 1) { + return String(); + } + + // First element is display_name (can be nil or bytes) + if (unpacker.isNil()) { + unpacker.unpackNil(); + return String(); + } + + // Try to read as binary (bytes) + MsgPack::bin_t name_bin; + if (unpacker.deserialize(name_bin)) { + return String((const char*)name_bin.data(), name_bin.size()); + } + + return String(); + } else { + // Legacy format: raw UTF-8 bytes + return String(app_data.toString().c_str()); + } +} + +void ChatScreen::on_message_long_pressed(lv_event_t* event) { + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + lv_obj_t* bubble = lv_event_get_target(event); + + // Find the content label (first child of bubble) + lv_obj_t* label = lv_obj_get_child(bubble, 0); + if (!label) { + return; + } + + // Get the message text + const char* text = lv_label_get_text(label); + if (!text || strlen(text) == 0) { + return; + } + + // Store text for copy action + screen->_pending_copy_text = String(text); + + // Show copy dialog + static const char* btns[] = {"Copy", "Cancel", ""}; + lv_obj_t* mbox = lv_msgbox_create(NULL, "Copy Message", + "Copy message to clipboard?", btns, false); + lv_obj_center(mbox); + lv_obj_add_event_cb(mbox, on_copy_dialog_action, LV_EVENT_VALUE_CHANGED, screen); +} + +void ChatScreen::on_copy_dialog_action(lv_event_t* event) { + lv_obj_t* mbox = lv_event_get_current_target(event); + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + uint16_t btn_id = lv_msgbox_get_active_btn(mbox); + + if (btn_id == 0) { // Copy button + Clipboard::copy(screen->_pending_copy_text); + } + + screen->_pending_copy_text = ""; + lv_msgbox_close(mbox); +} + +void ChatScreen::on_textarea_long_pressed(lv_event_t* event) { + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + // Only show paste if clipboard has content + if (!Clipboard::has_content()) { + return; + } + + // Show paste dialog + static const char* btns[] = {"Paste", "Cancel", ""}; + lv_obj_t* mbox = lv_msgbox_create(NULL, "Paste", + "Paste from clipboard?", btns, false); + lv_obj_center(mbox); + lv_obj_add_event_cb(mbox, on_paste_dialog_action, LV_EVENT_VALUE_CHANGED, screen); +} + +void ChatScreen::on_paste_dialog_action(lv_event_t* event) { + lv_obj_t* mbox = lv_event_get_current_target(event); + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + uint16_t btn_id = lv_msgbox_get_active_btn(mbox); + + if (btn_id == 0) { // Paste button + const String& content = Clipboard::paste(); + if (content.length() > 0) { + // Insert at cursor position + lv_textarea_add_text(screen->_text_area, content.c_str()); + } + } + + lv_msgbox_close(mbox); +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/ChatScreen.h b/lib/tdeck_ui/UI/LXMF/ChatScreen.h new file mode 100644 index 0000000..087916f --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/ChatScreen.h @@ -0,0 +1,189 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_CHATSCREEN_H +#define UI_LXMF_CHATSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include +#include +#include +#include "Bytes.h" +#include "LXMF/LXMessage.h" +#include "LXMF/MessageStore.h" + +namespace UI { +namespace LXMF { + +/** + * Chat Screen + * + * Shows messages in a conversation with: + * - Scrollable message list + * - Message bubbles (incoming/outgoing styled differently) + * - Delivery status indicators (✓ sent, ✓✓ delivered) + * - Message input area + * - Send button + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ ← Alice (a1b2c3d4...) │ 32px Header + * ├─────────────────────────────────────┤ + * │ [Hey there!] │ Outgoing (right) + * │ [10:23 AM ✓] │ + * │ [How are you doing?] │ Incoming (left) + * │ [10:25 AM] │ 156px scrollable + * │ [I'm good, thanks!] │ + * │ [10:26 AM ✓✓] │ + * ├─────────────────────────────────────┤ + * │ [Type message... ] [Send] │ 52px Input area + * └─────────────────────────────────────┘ + */ +class ChatScreen { +public: + /** + * Message item data + */ + struct MessageItem { + RNS::Bytes message_hash; + String content; + char timestamp_str[16]; // "12:34 PM" format - fixed buffer to avoid fragmentation + bool outgoing; // true if sent by us + bool delivered; // true if delivery confirmed + bool failed; // true if delivery failed + }; + + /** + * Callback types + */ + using BackCallback = std::function; + using SendMessageCallback = std::function; + + /** + * Create chat screen + * @param parent Parent LVGL object (usually lv_scr_act()) + */ + ChatScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~ChatScreen(); + + /** + * Load conversation with a specific peer + * @param peer_hash Peer destination hash + * @param store Message store to load from + */ + void load_conversation(const RNS::Bytes& peer_hash, ::LXMF::MessageStore& store); + + /** + * Add a new message to the chat + * @param message LXMF message to add + * @param outgoing true if message is outgoing + */ + void add_message(const ::LXMF::LXMessage& message, bool outgoing); + + /** + * Update delivery status of a message + * @param message_hash Hash of message to update + * @param delivered true if delivered, false if failed + */ + void update_message_status(const RNS::Bytes& message_hash, bool delivered); + + /** + * Refresh message list (reload from store) + */ + void refresh(); + + /** + * Set callback for back button + * @param callback Function to call when back button is pressed + */ + void set_back_callback(BackCallback callback); + + /** + * Set callback for sending messages + * @param callback Function to call when send button is pressed + */ + void set_send_message_callback(SendMessageCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + * @return Root object + */ + lv_obj_t* get_object(); + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _message_list; + lv_obj_t* _input_area; + lv_obj_t* _text_area; + lv_obj_t* _btn_send; + lv_obj_t* _btn_back; + + RNS::Bytes _peer_hash; + ::LXMF::MessageStore* _message_store; + std::deque _messages; + + // Map message hash to bubble row for targeted updates + std::map _message_rows; + + BackCallback _back_callback; + SendMessageCallback _send_message_callback; + + // UI construction + void create_header(); + void create_message_list(); + void create_input_area(); + void create_message_bubble(const MessageItem& item); + + // Event handlers + static void on_back_clicked(lv_event_t* event); + static void on_send_clicked(lv_event_t* event); + static void on_message_long_pressed(lv_event_t* event); + static void on_copy_dialog_action(lv_event_t* event); + static void on_textarea_long_pressed(lv_event_t* event); + static void on_paste_dialog_action(lv_event_t* event); + + // Copy/paste state + String _pending_copy_text; + + // Pagination state for infinite scroll + std::vector _all_message_hashes; // All message hashes in conversation + size_t _display_start_idx; // Index into _all_message_hashes where display starts + static constexpr size_t MESSAGES_PER_PAGE = 20; + static constexpr size_t MAX_DISPLAYED_MESSAGES = 50; // Cap to prevent memory exhaustion + bool _loading_more; // Prevent concurrent loads + + // Load more messages (for infinite scroll) + void load_more_messages(); + static void on_scroll(lv_event_t* event); + + // Utility + static void format_timestamp(double timestamp, char* buf, size_t buf_size); + static const char* get_delivery_indicator(bool outgoing, bool delivered, bool failed); + static String parse_display_name(const RNS::Bytes& app_data); + static void build_status_text(char* buf, size_t buf_size, const char* timestamp, + bool outgoing, bool delivered, bool failed); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_CHATSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/ComposeScreen.cpp b/lib/tdeck_ui/UI/LXMF/ComposeScreen.cpp new file mode 100644 index 0000000..b4c6849 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/ComposeScreen.cpp @@ -0,0 +1,320 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "ComposeScreen.h" +#include "Theme.h" + +#ifdef ARDUINO + +#include "Log.h" +#include "../LVGL/LVGLLock.h" +#include "../LVGL/LVGLInit.h" +#include "../TextAreaHelper.h" + +using namespace RNS; + +namespace UI { +namespace LXMF { + +ComposeScreen::ComposeScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _content_area(nullptr), _button_area(nullptr), + _text_area_dest(nullptr), _text_area_message(nullptr), + _btn_cancel(nullptr), _btn_send(nullptr), _btn_back(nullptr) { + LVGL_LOCK(); + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, lv_color_hex(0x121212), 0); // Dark background + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Create UI components + create_header(); + create_content_area(); + create_button_area(); + + // Hide by default + hide(); + + TRACE("ComposeScreen created"); +} + +ComposeScreen::~ComposeScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void ComposeScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, lv_color_hex(0x1a1a1a), 0); // Dark header + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + _btn_back = lv_btn_create(_header); + lv_obj_set_size(_btn_back, 50, 28); + lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_set_style_bg_color(_btn_back, lv_color_hex(0x333333), 0); + lv_obj_set_style_bg_color(_btn_back, lv_color_hex(0x444444), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(_btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, lv_color_hex(0xe0e0e0), 0); + + // Title + lv_obj_t* title = lv_label_create(_header); + lv_label_set_text(title, "New Message"); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 60, 0); + lv_obj_set_style_text_color(title, lv_color_hex(0xffffff), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); +} + +void ComposeScreen::create_content_area() { + _content_area = lv_obj_create(_screen); + lv_obj_set_size(_content_area, LV_PCT(100), 152); + lv_obj_align(_content_area, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_pad_all(_content_area, 6, 0); + lv_obj_set_style_bg_color(_content_area, lv_color_hex(0x121212), 0); // Match screen bg + lv_obj_set_style_border_width(_content_area, 0, 0); + lv_obj_set_style_radius(_content_area, 0, 0); + lv_obj_clear_flag(_content_area, LV_OBJ_FLAG_SCROLLABLE); + + // "To:" label + lv_obj_t* label_to = lv_label_create(_content_area); + lv_label_set_text(label_to, "To:"); + lv_obj_align(label_to, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_text_color(label_to, lv_color_hex(0xb0b0b0), 0); + + // Destination hash input + _text_area_dest = lv_textarea_create(_content_area); + lv_obj_set_size(_text_area_dest, LV_PCT(100), 36); + lv_obj_align(_text_area_dest, LV_ALIGN_TOP_LEFT, 0, 18); + lv_textarea_set_placeholder_text(_text_area_dest, "Destination hash (32 hex)"); + lv_textarea_set_one_line(_text_area_dest, true); + lv_textarea_set_max_length(_text_area_dest, 32); + lv_textarea_set_accepted_chars(_text_area_dest, "0123456789abcdefABCDEF"); + // Dark text area styling + lv_obj_set_style_bg_color(_text_area_dest, lv_color_hex(0x2a2a2a), 0); + lv_obj_set_style_text_color(_text_area_dest, lv_color_hex(0xffffff), 0); + lv_obj_set_style_border_color(_text_area_dest, lv_color_hex(0x404040), 0); + // Enable paste on long-press + TextAreaHelper::enable_paste(_text_area_dest); + + // "Message:" label + lv_obj_t* label_message = lv_label_create(_content_area); + lv_label_set_text(label_message, "Message:"); + lv_obj_align(label_message, LV_ALIGN_TOP_LEFT, 0, 60); + lv_obj_set_style_text_color(label_message, lv_color_hex(0xb0b0b0), 0); + + // Message input + _text_area_message = lv_textarea_create(_content_area); + lv_obj_set_size(_text_area_message, LV_PCT(100), 70); + lv_obj_align(_text_area_message, LV_ALIGN_TOP_LEFT, 0, 78); + lv_textarea_set_placeholder_text(_text_area_message, "Type your message..."); + lv_textarea_set_one_line(_text_area_message, false); + lv_textarea_set_max_length(_text_area_message, 500); + // Dark text area styling + lv_obj_set_style_bg_color(_text_area_message, lv_color_hex(0x2a2a2a), 0); + lv_obj_set_style_text_color(_text_area_message, lv_color_hex(0xffffff), 0); + lv_obj_set_style_border_color(_text_area_message, lv_color_hex(0x404040), 0); + // Enable paste on long-press + TextAreaHelper::enable_paste(_text_area_message); +} + +void ComposeScreen::create_button_area() { + _button_area = lv_obj_create(_screen); + lv_obj_set_size(_button_area, LV_PCT(100), 52); + lv_obj_align(_button_area, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(_button_area, lv_color_hex(0x1a1a1a), 0); // Dark + lv_obj_set_style_border_width(_button_area, 0, 0); + lv_obj_set_style_radius(_button_area, 0, 0); + lv_obj_set_style_pad_all(_button_area, 0, 0); + lv_obj_set_flex_flow(_button_area, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(_button_area, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // Cancel button + _btn_cancel = lv_btn_create(_button_area); + lv_obj_set_size(_btn_cancel, 110, 36); + lv_obj_set_style_bg_color(_btn_cancel, lv_color_hex(0x3a3a3a), 0); + lv_obj_set_style_bg_color(_btn_cancel, lv_color_hex(0x4a4a4a), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_cancel, on_cancel_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_cancel = lv_label_create(_btn_cancel); + lv_label_set_text(label_cancel, "Cancel"); + lv_obj_center(label_cancel); + lv_obj_set_style_text_color(label_cancel, lv_color_hex(0xe0e0e0), 0); + + // Spacer + lv_obj_t* spacer = lv_obj_create(_button_area); + lv_obj_set_size(spacer, 30, 1); + lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(spacer, 0, 0); + + // Send button + _btn_send = lv_btn_create(_button_area); + lv_obj_set_size(_btn_send, 110, 36); + lv_obj_add_event_cb(_btn_send, on_send_clicked, LV_EVENT_CLICKED, this); + lv_obj_set_style_bg_color(_btn_send, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_send, Theme::primaryPressed(), LV_STATE_PRESSED); + + lv_obj_t* label_send = lv_label_create(_btn_send); + lv_label_set_text(label_send, "Send"); + lv_obj_center(label_send); + lv_obj_set_style_text_color(label_send, lv_color_hex(0xffffff), 0); +} + +void ComposeScreen::clear() { + LVGL_LOCK(); + lv_textarea_set_text(_text_area_dest, ""); + lv_textarea_set_text(_text_area_message, ""); +} + +void ComposeScreen::set_destination(const Bytes& dest_hash) { + LVGL_LOCK(); + String hash_str = dest_hash.toHex().c_str(); + lv_textarea_set_text(_text_area_dest, hash_str.c_str()); +} + +void ComposeScreen::set_cancel_callback(CancelCallback callback) { + _cancel_callback = callback; +} + +void ComposeScreen::set_send_callback(SendCallback callback) { + _send_callback = callback; +} + +void ComposeScreen::show() { + LVGL_LOCK(); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); // Bring to front for touch events + + // Add widgets to focus group for trackball navigation + // Note: text areas in edit mode consume arrow keys, so focus on button first + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_add_obj(group, _btn_back); + if (_btn_cancel) lv_group_add_obj(group, _btn_cancel); + if (_btn_send) lv_group_add_obj(group, _btn_send); + + // Focus on back button (user can roll to other buttons) + if (_btn_back) { + lv_group_focus_obj(_btn_back); + } + } +} + +void ComposeScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_remove_obj(_btn_back); + if (_btn_cancel) lv_group_remove_obj(_btn_cancel); + if (_btn_send) lv_group_remove_obj(_btn_send); + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* ComposeScreen::get_object() { + return _screen; +} + +void ComposeScreen::on_back_clicked(lv_event_t* event) { + ComposeScreen* screen = (ComposeScreen*)lv_event_get_user_data(event); + + if (screen->_cancel_callback) { + screen->_cancel_callback(); + } +} + +void ComposeScreen::on_cancel_clicked(lv_event_t* event) { + ComposeScreen* screen = (ComposeScreen*)lv_event_get_user_data(event); + + if (screen->_cancel_callback) { + screen->_cancel_callback(); + } +} + +void ComposeScreen::on_send_clicked(lv_event_t* event) { + ComposeScreen* screen = (ComposeScreen*)lv_event_get_user_data(event); + + // Get destination hash + const char* dest_text = lv_textarea_get_text(screen->_text_area_dest); + String dest_hash_str(dest_text); + dest_hash_str.trim(); + dest_hash_str.toLowerCase(); + + // Validate destination hash + if (!screen->validate_destination_hash(dest_hash_str)) { + ERROR(("Invalid destination hash: " + dest_hash_str).c_str()); + // Show error dialog + lv_obj_t* mbox = lv_msgbox_create(NULL, "Invalid Address", + "Destination must be a 32-character hex address.", NULL, true); + lv_obj_center(mbox); + return; + } + + // Get message text + const char* message_text = lv_textarea_get_text(screen->_text_area_message); + String message(message_text); + message.trim(); + + if (message.length() == 0) { + ERROR("Message is empty"); + // Show error dialog + lv_obj_t* mbox = lv_msgbox_create(NULL, "Empty Message", + "Please enter a message to send.", NULL, true); + lv_obj_center(mbox); + return; + } + + // Convert hex string to bytes + Bytes dest_hash; + dest_hash.assignHex(dest_hash_str.c_str()); + + if (screen->_send_callback) { + screen->_send_callback(dest_hash, message); + } + + // Clear form + screen->clear(); +} + +bool ComposeScreen::validate_destination_hash(const String& hash_str) { + // Must be exactly 32 hex characters (16 bytes) + if (hash_str.length() != 32) { + return false; + } + + // Check all characters are valid hex + for (size_t i = 0; i < hash_str.length(); i++) { + char c = hash_str.charAt(i); + if (!isxdigit(c)) { + return false; + } + } + + return true; +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/ComposeScreen.h b/lib/tdeck_ui/UI/LXMF/ComposeScreen.h new file mode 100644 index 0000000..713d16e --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/ComposeScreen.h @@ -0,0 +1,129 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_COMPOSESCREEN_H +#define UI_LXMF_COMPOSESCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include "Bytes.h" + +namespace UI { +namespace LXMF { + +/** + * Compose New Message Screen + * + * Allows user to compose a message to a new peer by entering: + * - Destination hash (16 bytes = 32 hex characters) + * - Message content + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ ← New Message │ 32px Header + * ├─────────────────────────────────────┤ + * │ To: │ + * │ [Paste destination hash - 32 chars]│ + * │ │ + * │ Message: │ + * │ ┌─────────────────────────────────┐ │ 156px content + * │ │ [Type your message here...] │ │ + * │ │ │ │ + * │ └─────────────────────────────────┘ │ + * ├─────────────────────────────────────┤ + * │ [Cancel] [Send] │ 52px buttons + * └─────────────────────────────────────┘ + */ +class ComposeScreen { +public: + /** + * Callback types + */ + using CancelCallback = std::function; + using SendCallback = std::function; + + /** + * Create compose screen + * @param parent Parent LVGL object (usually lv_scr_act()) + */ + ComposeScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~ComposeScreen(); + + /** + * Clear all input fields + */ + void clear(); + + /** + * Set destination hash (pre-fill the to field) + * @param dest_hash Destination hash to set + */ + void set_destination(const RNS::Bytes& dest_hash); + + /** + * Set callback for cancel button + * @param callback Function to call when cancel is pressed + */ + void set_cancel_callback(CancelCallback callback); + + /** + * Set callback for send button + * @param callback Function to call when send is pressed + */ + void set_send_callback(SendCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + * @return Root object + */ + lv_obj_t* get_object(); + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _content_area; + lv_obj_t* _button_area; + lv_obj_t* _text_area_dest; + lv_obj_t* _text_area_message; + lv_obj_t* _btn_cancel; + lv_obj_t* _btn_send; + lv_obj_t* _btn_back; + + CancelCallback _cancel_callback; + SendCallback _send_callback; + + // UI construction + void create_header(); + void create_content_area(); + void create_button_area(); + + // Event handlers + static void on_back_clicked(lv_event_t* event); + static void on_cancel_clicked(lv_event_t* event); + static void on_send_clicked(lv_event_t* event); + + // Validation + bool validate_destination_hash(const String& hash_str); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_COMPOSESCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp new file mode 100644 index 0000000..206deb5 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp @@ -0,0 +1,723 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "ConversationListScreen.h" + +#ifdef ARDUINO + +#include "Theme.h" +#include "Log.h" +#include "Identity.h" +#include "Utilities/OS.h" +#include "../../Hardware/TDeck/Config.h" +#include "../LVGL/LVGLInit.h" +#include "../LVGL/LVGLLock.h" +#include +#include +#include + +using namespace RNS; +using namespace Hardware::TDeck; + +namespace UI { +namespace LXMF { + +ConversationListScreen::ConversationListScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _list(nullptr), _bottom_nav(nullptr), + _btn_new(nullptr), _btn_settings(nullptr), _label_wifi(nullptr), _label_lora(nullptr), + _label_gps(nullptr), _label_ble(nullptr), _battery_container(nullptr), + _label_battery_icon(nullptr), _label_battery_pct(nullptr), + _lora_interface(nullptr), _ble_interface(nullptr), _gps(nullptr), + _message_store(nullptr) { + LVGL_LOCK(); + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Create UI components + create_header(); + create_list(); + create_bottom_nav(); + + TRACE("ConversationListScreen created"); +} + +ConversationListScreen::~ConversationListScreen() { + LVGL_LOCK(); + // Pool handles cleanup automatically when vector destructs + if (_screen) { + lv_obj_del(_screen); + } +} + +void ConversationListScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Title + lv_obj_t* title = lv_label_create(_header); + lv_label_set_text(title, "LXMF"); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 8, 0); + lv_obj_set_style_text_color(title, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + + // Status indicators - compact layout: WiFi, LoRa, GPS, BLE, Battery(vertical) + _label_wifi = lv_label_create(_header); + lv_label_set_text(_label_wifi, LV_SYMBOL_WIFI " --"); + lv_obj_align(_label_wifi, LV_ALIGN_LEFT_MID, 54, 0); + lv_obj_set_style_text_color(_label_wifi, Theme::textMuted(), 0); + + _label_lora = lv_label_create(_header); + lv_label_set_text(_label_lora, LV_SYMBOL_CALL"--"); // Antenna-like symbol + lv_obj_align(_label_lora, LV_ALIGN_LEFT_MID, 101, 0); + lv_obj_set_style_text_color(_label_lora, Theme::textMuted(), 0); + + _label_gps = lv_label_create(_header); + lv_label_set_text(_label_gps, LV_SYMBOL_GPS " --"); + lv_obj_align(_label_gps, LV_ALIGN_LEFT_MID, 142, 0); + lv_obj_set_style_text_color(_label_gps, Theme::textMuted(), 0); + + // BLE status: Bluetooth icon with central|peripheral counts + _label_ble = lv_label_create(_header); + lv_label_set_text(_label_ble, LV_SYMBOL_BLUETOOTH " -|-"); + lv_obj_align(_label_ble, LV_ALIGN_LEFT_MID, 179, 0); + lv_obj_set_style_text_color(_label_ble, Theme::textMuted(), 0); + + // Battery: vertical layout (icon on top, percentage below) to save horizontal space + _battery_container = lv_obj_create(_header); + lv_obj_set_size(_battery_container, 30, 34); + lv_obj_align(_battery_container, LV_ALIGN_LEFT_MID, 219, 0); + lv_obj_set_style_bg_opa(_battery_container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(_battery_container, 0, 0); + lv_obj_set_style_pad_all(_battery_container, 0, 0); + lv_obj_clear_flag(_battery_container, LV_OBJ_FLAG_SCROLLABLE); + + _label_battery_icon = lv_label_create(_battery_container); + lv_label_set_text(_label_battery_icon, LV_SYMBOL_BATTERY_FULL); + lv_obj_align(_label_battery_icon, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_text_color(_label_battery_icon, Theme::textMuted(), 0); + + _label_battery_pct = lv_label_create(_battery_container); + lv_label_set_text(_label_battery_pct, "--%"); + lv_obj_align(_label_battery_pct, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_text_color(_label_battery_pct, Theme::textMuted(), 0); + lv_obj_set_style_text_font(_label_battery_pct, &lv_font_montserrat_12, 0); + + // Sync button (right corner) - syncs messages from propagation node + _btn_new = lv_btn_create(_header); + lv_obj_set_size(_btn_new, 55, 28); + lv_obj_align(_btn_new, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_bg_color(_btn_new, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_new, Theme::primaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_new, on_sync_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_sync = lv_label_create(_btn_new); + lv_label_set_text(label_sync, LV_SYMBOL_REFRESH); + lv_obj_center(label_sync); + lv_obj_set_style_text_color(label_sync, Theme::textPrimary(), 0); +} + +void ConversationListScreen::create_list() { + _list = lv_obj_create(_screen); + lv_obj_set_size(_list, LV_PCT(100), 168); // 240 - 36 (header) - 36 (bottom nav) + lv_obj_align(_list, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_pad_all(_list, 2, 0); + lv_obj_set_style_pad_gap(_list, 2, 0); + lv_obj_set_style_bg_color(_list, Theme::surface(), 0); + lv_obj_set_style_border_width(_list, 0, 0); + lv_obj_set_style_radius(_list, 0, 0); + lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); +} + +void ConversationListScreen::create_bottom_nav() { + _bottom_nav = lv_obj_create(_screen); + lv_obj_set_size(_bottom_nav, LV_PCT(100), 36); + lv_obj_align(_bottom_nav, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(_bottom_nav, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_bottom_nav, 0, 0); + lv_obj_set_style_radius(_bottom_nav, 0, 0); + lv_obj_set_style_pad_all(_bottom_nav, 0, 0); + lv_obj_set_flex_flow(_bottom_nav, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(_bottom_nav, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // Bottom navigation buttons: Messages, Announces, Status, Settings + const char* icons[] = {LV_SYMBOL_ENVELOPE, LV_SYMBOL_BELL, LV_SYMBOL_BARS, LV_SYMBOL_SETTINGS}; + + for (int i = 0; i < 4; i++) { + lv_obj_t* btn = lv_btn_create(_bottom_nav); + lv_obj_set_size(btn, 65, 28); + lv_obj_set_user_data(btn, (void*)(intptr_t)i); + lv_obj_set_style_bg_color(btn, Theme::surfaceInput(), 0); + lv_obj_set_style_bg_color(btn, lv_color_hex(0x3a3a3a), LV_STATE_PRESSED); + lv_obj_add_event_cb(btn, on_bottom_nav_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label = lv_label_create(btn); + lv_label_set_text(label, icons[i]); + lv_obj_center(label); + lv_obj_set_style_text_color(label, Theme::textTertiary(), 0); + } +} + +void ConversationListScreen::load_conversations(::LXMF::MessageStore& store) { + LVGL_LOCK(); + _message_store = &store; + refresh(); +} + +void ConversationListScreen::refresh() { + LVGL_LOCK(); + if (!_message_store) { + return; + } + + INFO("Refreshing conversation list"); + + // Clear existing items (also removes from focus group when deleted) + lv_obj_clean(_list); + _conversations.clear(); + _conversation_containers.clear(); + _peer_hash_pool.clear(); + + // Load conversations from store + std::vector peer_hashes = _message_store->get_conversations(); + + // Reserve capacity to avoid reallocations during population + _peer_hash_pool.reserve(peer_hashes.size()); + _conversations.reserve(peer_hashes.size()); + _conversation_containers.reserve(peer_hashes.size()); + + { + char log_buf[48]; + snprintf(log_buf, sizeof(log_buf), " Found %zu conversations", peer_hashes.size()); + INFO(log_buf); + } + + for (const auto& peer_hash : peer_hashes) { + std::vector messages = _message_store->get_messages_for_conversation(peer_hash); + + if (messages.empty()) { + continue; + } + + // Load last message for preview + Bytes last_msg_hash = messages.back(); + ::LXMF::LXMessage last_msg = _message_store->load_message(last_msg_hash); + + // Create conversation item + ConversationItem item; + item.peer_hash = peer_hash; + + // Try to get display name from app_data, fall back to hash + Bytes app_data = Identity::recall_app_data(peer_hash); + if (app_data && app_data.size() > 0) { + String display_name = parse_display_name(app_data); + if (display_name.length() > 0) { + item.peer_name = display_name; + } else { + item.peer_name = truncate_hash(peer_hash); + } + } else { + item.peer_name = truncate_hash(peer_hash); + } + + // Get message content for preview + String content((const char*)last_msg.content().data(), last_msg.content().size()); + item.last_message = content.substring(0, 30); // Truncate to 30 chars + if (content.length() > 30) { + item.last_message += "..."; + } + + item.timestamp = (uint32_t)last_msg.timestamp(); + item.timestamp_str = format_timestamp(item.timestamp); + item.unread_count = 0; // TODO: Track unread count + + _conversations.push_back(item); + create_conversation_item(item); + } +} + +void ConversationListScreen::create_conversation_item(const ConversationItem& item) { + // Create container for conversation item - compact 2-row layout + lv_obj_t* container = lv_obj_create(_list); + lv_obj_set_size(container, LV_PCT(100), 44); + lv_obj_set_style_bg_color(container, Theme::surfaceContainer(), 0); + lv_obj_set_style_bg_color(container, Theme::surfaceElevated(), LV_STATE_PRESSED); + lv_obj_set_style_border_width(container, 1, 0); + lv_obj_set_style_border_color(container, Theme::border(), 0); + lv_obj_set_style_radius(container, 6, 0); + lv_obj_set_style_pad_all(container, 0, 0); + lv_obj_add_flag(container, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(container, LV_OBJ_FLAG_SCROLLABLE); + + // Focus style for trackball navigation + lv_obj_set_style_border_color(container, Theme::info(), LV_STATE_FOCUSED); + lv_obj_set_style_border_width(container, 2, LV_STATE_FOCUSED); + lv_obj_set_style_bg_color(container, Theme::surfaceElevated(), LV_STATE_FOCUSED); + + // Store peer hash in user data using pool (avoids per-item heap allocations) + _peer_hash_pool.push_back(item.peer_hash); + lv_obj_set_user_data(container, &_peer_hash_pool.back()); + lv_obj_add_event_cb(container, on_conversation_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(container, on_conversation_long_pressed, LV_EVENT_LONG_PRESSED, this); + + // Track container for focus group management + _conversation_containers.push_back(container); + + // Row 1: Peer hash + lv_obj_t* label_peer = lv_label_create(container); + lv_label_set_text(label_peer, item.peer_name.c_str()); + lv_obj_align(label_peer, LV_ALIGN_TOP_LEFT, 6, 4); + lv_obj_set_style_text_color(label_peer, Theme::info(), 0); + lv_obj_set_style_text_font(label_peer, &lv_font_montserrat_14, 0); + + // Row 2: Message preview (left) + Timestamp (right) + lv_obj_t* label_preview = lv_label_create(container); + lv_label_set_text(label_preview, item.last_message.c_str()); + lv_obj_align(label_preview, LV_ALIGN_BOTTOM_LEFT, 6, -4); + lv_obj_set_style_text_color(label_preview, Theme::textTertiary(), 0); + lv_obj_set_width(label_preview, 220); // Limit width to leave room for timestamp + lv_label_set_long_mode(label_preview, LV_LABEL_LONG_DOT); + lv_obj_set_style_max_height(label_preview, 16, 0); // Force single line + + lv_obj_t* label_time = lv_label_create(container); + lv_label_set_text(label_time, item.timestamp_str.c_str()); + lv_obj_align(label_time, LV_ALIGN_BOTTOM_RIGHT, -6, -4); + lv_obj_set_style_text_color(label_time, Theme::textMuted(), 0); + + // Unread count badge + if (item.unread_count > 0) { + lv_obj_t* badge = lv_obj_create(container); + lv_obj_set_size(badge, 20, 20); + lv_obj_align(badge, LV_ALIGN_BOTTOM_RIGHT, -6, -4); + lv_obj_set_style_bg_color(badge, Theme::error(), 0); + lv_obj_set_style_radius(badge, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(badge, 0, 0); + lv_obj_set_style_pad_all(badge, 0, 0); + + lv_obj_t* label_count = lv_label_create(badge); + lv_label_set_text_fmt(label_count, "%d", item.unread_count); + lv_obj_center(label_count); + lv_obj_set_style_text_color(label_count, lv_color_white(), 0); + } +} + +void ConversationListScreen::update_unread_count(const Bytes& peer_hash, uint16_t unread_count) { + LVGL_LOCK(); + // Find conversation and update + for (auto& conv : _conversations) { + if (conv.peer_hash == peer_hash) { + conv.unread_count = unread_count; + refresh(); // Redraw list + break; + } + } +} + +void ConversationListScreen::set_conversation_selected_callback(ConversationSelectedCallback callback) { + _conversation_selected_callback = callback; +} + +void ConversationListScreen::set_compose_callback(ComposeCallback callback) { + _compose_callback = callback; +} + +void ConversationListScreen::set_sync_callback(SyncCallback callback) { + _sync_callback = callback; +} + +void ConversationListScreen::set_settings_callback(SettingsCallback callback) { + _settings_callback = callback; +} + +void ConversationListScreen::set_announces_callback(AnnouncesCallback callback) { + _announces_callback = callback; +} + +void ConversationListScreen::set_status_callback(StatusCallback callback) { + _status_callback = callback; +} + +void ConversationListScreen::show() { + LVGL_LOCK(); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); // Bring to front for touch events + + // Add widgets to focus group for trackball navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + // Add conversation containers first (so they come before New button in nav order) + for (lv_obj_t* container : _conversation_containers) { + lv_group_add_obj(group, container); + } + + // Add New button last + if (_btn_new) { + lv_group_add_obj(group, _btn_new); + } + + // Focus first conversation if available, otherwise New button + if (!_conversation_containers.empty()) { + lv_group_focus_obj(_conversation_containers[0]); + } else if (_btn_new) { + lv_group_focus_obj(_btn_new); + } + } +} + +void ConversationListScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + // Remove conversation containers + for (lv_obj_t* container : _conversation_containers) { + lv_group_remove_obj(container); + } + + if (_btn_new) { + lv_group_remove_obj(_btn_new); + } + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* ConversationListScreen::get_object() { + return _screen; +} + +void ConversationListScreen::update_status() { + LVGL_LOCK(); + // Update WiFi RSSI + if (WiFi.status() == WL_CONNECTED) { + int rssi = WiFi.RSSI(); + char wifi_text[32]; + snprintf(wifi_text, sizeof(wifi_text), "%s %d", LV_SYMBOL_WIFI, rssi); + lv_label_set_text(_label_wifi, wifi_text); + + // Color based on signal strength + if (rssi > -50) { + lv_obj_set_style_text_color(_label_wifi, Theme::success(), 0); // Green + } else if (rssi > -70) { + lv_obj_set_style_text_color(_label_wifi, Theme::warning(), 0); // Yellow + } else { + lv_obj_set_style_text_color(_label_wifi, Theme::error(), 0); // Red + } + } else { + lv_label_set_text(_label_wifi, LV_SYMBOL_WIFI " --"); + lv_obj_set_style_text_color(_label_wifi, Theme::textMuted(), 0); + } + + // Update LoRa RSSI + if (_lora_interface) { + float rssi_f = _lora_interface->get_rssi(); + int rssi = (int)rssi_f; + + // Only show RSSI if we've received at least one packet (RSSI != 0) + if (rssi_f != 0.0f) { + char lora_text[32]; + snprintf(lora_text, sizeof(lora_text), "%s%d", LV_SYMBOL_CALL, rssi); + lv_label_set_text(_label_lora, lora_text); + + // Color based on signal strength (LoRa typically has weaker signals) + if (rssi > -80) { + lv_obj_set_style_text_color(_label_lora, Theme::success(), 0); // Green + } else if (rssi > -100) { + lv_obj_set_style_text_color(_label_lora, Theme::warning(), 0); // Yellow + } else { + lv_obj_set_style_text_color(_label_lora, Theme::error(), 0); // Red + } + } else { + // RSSI of 0 means no recent packet + lv_label_set_text(_label_lora, LV_SYMBOL_CALL"--"); + lv_obj_set_style_text_color(_label_lora, Theme::textMuted(), 0); + } + } else { + lv_label_set_text(_label_lora, LV_SYMBOL_CALL"--"); + lv_obj_set_style_text_color(_label_lora, Theme::textMuted(), 0); + } + + // Update GPS satellite count + if (_gps && _gps->satellites.isValid()) { + int sats = _gps->satellites.value(); + char gps_text[32]; + snprintf(gps_text, sizeof(gps_text), "%s %d", LV_SYMBOL_GPS, sats); + lv_label_set_text(_label_gps, gps_text); + + // Color based on satellite count + if (sats >= 6) { + lv_obj_set_style_text_color(_label_gps, Theme::success(), 0); // Green + } else if (sats >= 3) { + lv_obj_set_style_text_color(_label_gps, Theme::warning(), 0); // Yellow + } else { + lv_obj_set_style_text_color(_label_gps, Theme::error(), 0); // Red + } + } else { + lv_label_set_text(_label_gps, LV_SYMBOL_GPS " --"); + lv_obj_set_style_text_color(_label_gps, Theme::textMuted(), 0); + } + + // Update BLE connection counts (central|peripheral) + if (_ble_interface) { + int central_count = 0; + int peripheral_count = 0; + + // Get connection counts from BLE interface + // The interface stores stats about connections + // Use get_stats() map if available, otherwise show "--" + auto stats = _ble_interface->get_stats(); + auto it_c = stats.find("central_connections"); + auto it_p = stats.find("peripheral_connections"); + if (it_c != stats.end()) central_count = (int)it_c->second; + if (it_p != stats.end()) peripheral_count = (int)it_p->second; + + char ble_text[32]; + snprintf(ble_text, sizeof(ble_text), "%s %d|%d", LV_SYMBOL_BLUETOOTH, central_count, peripheral_count); + lv_label_set_text(_label_ble, ble_text); + + // Color based on connection status + int total = central_count + peripheral_count; + if (total > 0) { + lv_obj_set_style_text_color(_label_ble, Theme::bluetooth(), 0); // Blue - connected + } else { + lv_obj_set_style_text_color(_label_ble, Theme::textMuted(), 0); // Gray - no connections + } + } else { + lv_label_set_text(_label_ble, LV_SYMBOL_BLUETOOTH " -|-"); + lv_obj_set_style_text_color(_label_ble, Theme::textMuted(), 0); + } + + // Update battery level (read from ADC) - vertical layout + // ESP32 ADC has linearity/offset issues - add 0.32V calibration per LilyGo community + int raw_adc = analogRead(Pin::BATTERY_ADC); + float voltage = (raw_adc / 4095.0) * 3.3 * Power::BATTERY_VOLTAGE_DIVIDER + 0.32; + int percent = (int)((voltage - Power::BATTERY_EMPTY) / (Power::BATTERY_FULL - Power::BATTERY_EMPTY) * 100); + percent = constrain(percent, 0, 100); + + // Detect charging: voltage > 4.4V indicates USB power connected + // (calibrated voltage reads ~5V+ when charging, ~4.2V max on battery) + bool charging = (voltage > 4.4); + + // Update icon and percentage display + if (charging) { + // When charging: show charge icon centered, hide percentage (voltage doesn't reflect battery state) + lv_label_set_text(_label_battery_icon, LV_SYMBOL_CHARGE); + lv_obj_align(_label_battery_icon, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_flag(_label_battery_pct, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_text_color(_label_battery_icon, Theme::charging(), 0); // Cyan + } else { + // When on battery: show icon at top with percentage below + lv_label_set_text(_label_battery_icon, LV_SYMBOL_BATTERY_FULL); + lv_obj_align(_label_battery_icon, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_clear_flag(_label_battery_pct, LV_OBJ_FLAG_HIDDEN); + char pct_text[16]; + snprintf(pct_text, sizeof(pct_text), "%d%%", percent); + lv_label_set_text(_label_battery_pct, pct_text); + + // Color based on battery level + lv_color_t battery_color; + if (percent > 50) { + battery_color = Theme::success(); // Green + } else if (percent > 20) { + battery_color = Theme::warning(); // Yellow + } else { + battery_color = Theme::error(); // Red + } + lv_obj_set_style_text_color(_label_battery_icon, battery_color, 0); + lv_obj_set_style_text_color(_label_battery_pct, battery_color, 0); + } +} + +void ConversationListScreen::on_conversation_clicked(lv_event_t* event) { + ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event); + lv_obj_t* target = lv_event_get_target(event); + + Bytes* peer_hash = (Bytes*)lv_obj_get_user_data(target); + + if (peer_hash && screen->_conversation_selected_callback) { + screen->_conversation_selected_callback(*peer_hash); + } +} + +void ConversationListScreen::on_sync_clicked(lv_event_t* event) { + ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event); + + if (screen->_sync_callback) { + screen->_sync_callback(); + } +} + +void ConversationListScreen::on_settings_clicked(lv_event_t* event) { + ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event); + + if (screen->_settings_callback) { + screen->_settings_callback(); + } +} + +void ConversationListScreen::on_bottom_nav_clicked(lv_event_t* event) { + ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event); + lv_obj_t* target = lv_event_get_target(event); + int btn_index = (int)(intptr_t)lv_obj_get_user_data(target); + + switch (btn_index) { + case 0: // Compose new message + if (screen->_compose_callback) { + screen->_compose_callback(); + } + break; + case 1: // Announces + if (screen->_announces_callback) { + screen->_announces_callback(); + } + break; + case 2: // Status + if (screen->_status_callback) { + screen->_status_callback(); + } + break; + case 3: // Settings + if (screen->_settings_callback) { + screen->_settings_callback(); + } + break; + default: + break; + } +} + +void ConversationListScreen::msgbox_close_cb(lv_event_t* event) { + lv_obj_t* mbox = lv_event_get_current_target(event); + lv_msgbox_close(mbox); +} + +void ConversationListScreen::on_conversation_long_pressed(lv_event_t* event) { + ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event); + lv_obj_t* target = lv_event_get_target(event); + + Bytes* peer_hash = (Bytes*)lv_obj_get_user_data(target); + if (!peer_hash) { + return; + } + + // Store the hash we want to delete + screen->_pending_delete_hash = *peer_hash; + + // Show confirmation dialog + static const char* btns[] = {"Delete", "Cancel", ""}; + lv_obj_t* mbox = lv_msgbox_create(NULL, "Delete Conversation", + "Delete this conversation and all messages?", btns, false); + lv_obj_center(mbox); + lv_obj_add_event_cb(mbox, on_delete_confirmed, LV_EVENT_VALUE_CHANGED, screen); +} + +void ConversationListScreen::on_delete_confirmed(lv_event_t* event) { + lv_obj_t* mbox = lv_event_get_current_target(event); + ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event); + uint16_t btn_id = lv_msgbox_get_active_btn(mbox); + + if (btn_id == 0 && screen->_message_store) { // "Delete" button + // Delete the conversation + screen->_message_store->delete_conversation(screen->_pending_delete_hash); + INFO("Deleted conversation"); + + // Refresh the list + screen->refresh(); + } + + lv_msgbox_close(mbox); +} + +String ConversationListScreen::format_timestamp(uint32_t timestamp) { + double now = Utilities::OS::time(); + double diff = now - (double)timestamp; + + if (diff < 0) { + return "Future"; // Clock not synced or future timestamp + } else if (diff < 60) { + return "Just now"; + } else if (diff < 3600) { + int mins = (int)(diff / 60); + return String(mins) + "m ago"; + } else if (diff < 86400) { + int hours = (int)(diff / 3600); + return String(hours) + "h ago"; + } else if (diff < 604800) { + int days = (int)(diff / 86400); + return String(days) + "d ago"; + } else { + int weeks = (int)(diff / 604800); + return String(weeks) + "w ago"; + } +} + +String ConversationListScreen::truncate_hash(const Bytes& hash) { + return String(hash.toHex().c_str()); +} + +String ConversationListScreen::parse_display_name(const Bytes& app_data) { + if (app_data.size() == 0) { + return String(); + } + + uint8_t first_byte = app_data.data()[0]; + + // Check for msgpack array format (LXMF 0.5.0+) + // fixarray: 0x90-0x9f (array with 0-15 elements) + // array16: 0xdc + if ((first_byte >= 0x90 && first_byte <= 0x9f) || first_byte == 0xdc) { + // Msgpack encoded: [display_name, stamp_cost, ...] + MsgPack::Unpacker unpacker; + unpacker.feed(app_data.data(), app_data.size()); + + // Get array size + MsgPack::arr_size_t arr_size; + if (!unpacker.deserialize(arr_size)) { + return String(); + } + + if (arr_size.size() < 1) { + return String(); + } + + // First element is display_name (can be nil or bytes) + if (unpacker.isNil()) { + unpacker.unpackNil(); + return String(); + } + + MsgPack::bin_t name_bin; + if (unpacker.deserialize(name_bin)) { + // Convert bytes to string + return String((const char*)name_bin.data(), name_bin.size()); + } + + return String(); + } else { + // Original format: raw UTF-8 string + return String(app_data.toString().c_str()); + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h new file mode 100644 index 0000000..0984fb6 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h @@ -0,0 +1,233 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_CONVERSATIONLISTSCREEN_H +#define UI_LXMF_CONVERSATIONLISTSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include +#include "Bytes.h" +#include "LXMF/MessageStore.h" +#include "Interface.h" + +class TinyGPSPlus; // Forward declaration + +namespace UI { +namespace LXMF { + +/** + * Conversation List Screen + * + * Shows a scrollable list of all LXMF conversations with: + * - Peer name/hash (truncated) + * - Last message preview + * - Timestamp + * - Unread count indicator + * - Navigation buttons (New message, Settings) + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ LXMF Messages [New] [☰] │ 32px header + * ├─────────────────────────────────────┤ + * │ ┌─ Alice (a1b2c3...) │ + * │ │ Hey, how are you? │ + * │ │ 2 hours ago [2] │ Unread count + * │ └─ │ + * │ ┌─ Bob (d4e5f6...) │ 176px scrollable + * │ │ See you tomorrow! │ + * │ │ Yesterday │ + * │ └─ │ + * ├─────────────────────────────────────┤ + * │ [💬] [👤] [📡] [⚙️] │ 32px bottom nav + * └─────────────────────────────────────┘ + */ +class ConversationListScreen { +public: + /** + * Conversation item data + */ + struct ConversationItem { + RNS::Bytes peer_hash; + String peer_name; // Or truncated hash if no name + String last_message; // Preview of last message + String timestamp_str; // Human-readable time + uint32_t timestamp; // Unix timestamp + uint16_t unread_count; + }; + + /** + * Callback types + */ + using ConversationSelectedCallback = std::function; + using ComposeCallback = std::function; + using SyncCallback = std::function; + using SettingsCallback = std::function; + using AnnouncesCallback = std::function; + using StatusCallback = std::function; + + /** + * Create conversation list screen + * @param parent Parent LVGL object (usually lv_scr_act()) + */ + ConversationListScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~ConversationListScreen(); + + /** + * Load conversations from message store + * @param store Message store to load from + */ + void load_conversations(::LXMF::MessageStore& store); + + /** + * Refresh conversation list (reload from store) + */ + void refresh(); + + /** + * Update unread count for a specific conversation + * @param peer_hash Peer hash + * @param unread_count New unread count + */ + void update_unread_count(const RNS::Bytes& peer_hash, uint16_t unread_count); + + /** + * Set callback for conversation selection + * @param callback Function to call when conversation is selected + */ + void set_conversation_selected_callback(ConversationSelectedCallback callback); + + /** + * Set callback for compose (envelope icon in bottom nav) + * @param callback Function to call when compose is requested + */ + void set_compose_callback(ComposeCallback callback); + + /** + * Set callback for sync button + * @param callback Function to call when sync button is pressed + */ + void set_sync_callback(SyncCallback callback); + + /** + * Set callback for settings button + * @param callback Function to call when settings button is pressed + */ + void set_settings_callback(SettingsCallback callback); + + /** + * Set callback for announces button + * @param callback Function to call when announces button is pressed + */ + void set_announces_callback(AnnouncesCallback callback); + + /** + * Set callback for status button + * @param callback Function to call when status button is pressed + */ + void set_status_callback(StatusCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + * @return Root object + */ + lv_obj_t* get_object(); + + /** + * Update status indicators (WiFi RSSI, LoRa RSSI, and battery) + * Call periodically from main loop + */ + void update_status(); + + /** + * Set LoRa interface for RSSI display + * @param iface LoRa interface implementation + */ + void set_lora_interface(RNS::Interface* iface) { _lora_interface = iface; } + + /** + * Set BLE interface for connection count display + * @param iface BLE interface implementation + */ + void set_ble_interface(RNS::Interface* iface) { _ble_interface = iface; } + + /** + * Set GPS for satellite count display + * @param gps TinyGPSPlus instance + */ + void set_gps(TinyGPSPlus* gps) { _gps = gps; } + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _list; + lv_obj_t* _bottom_nav; + lv_obj_t* _btn_new; + lv_obj_t* _btn_settings; + lv_obj_t* _label_wifi; + lv_obj_t* _label_lora; + lv_obj_t* _label_gps; + lv_obj_t* _label_ble; + lv_obj_t* _battery_container; + lv_obj_t* _label_battery_icon; + lv_obj_t* _label_battery_pct; + + RNS::Interface* _lora_interface; + RNS::Interface* _ble_interface; + TinyGPSPlus* _gps; + + ::LXMF::MessageStore* _message_store; + std::vector _conversations; + std::vector _conversation_containers; // For focus group management + std::vector _peer_hash_pool; // Object pool to avoid per-item allocations + RNS::Bytes _pending_delete_hash; // Hash of conversation pending deletion + + ConversationSelectedCallback _conversation_selected_callback; + ComposeCallback _compose_callback; + SyncCallback _sync_callback; + SettingsCallback _settings_callback; + AnnouncesCallback _announces_callback; + StatusCallback _status_callback; + + // UI construction + void create_header(); + void create_list(); + void create_bottom_nav(); + void create_conversation_item(const ConversationItem& item); + + // Event handlers + static void on_conversation_clicked(lv_event_t* event); + static void on_conversation_long_pressed(lv_event_t* event); + static void on_delete_confirmed(lv_event_t* event); + static void on_sync_clicked(lv_event_t* event); + static void on_settings_clicked(lv_event_t* event); + static void on_bottom_nav_clicked(lv_event_t* event); + static void msgbox_close_cb(lv_event_t* event); + + // Utility + static String format_timestamp(uint32_t timestamp); + static String truncate_hash(const RNS::Bytes& hash); + static String parse_display_name(const RNS::Bytes& app_data); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_CONVERSATIONLISTSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.cpp b/lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.cpp new file mode 100644 index 0000000..d957e7a --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.cpp @@ -0,0 +1,418 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "PropagationNodesScreen.h" +#include "Theme.h" + +#ifdef ARDUINO + +#include "Log.h" +#include "LXMF/PropagationNodeManager.h" +#include "Utilities/OS.h" +#include "../LVGL/LVGLInit.h" +#include "../LVGL/LVGLLock.h" + +using namespace RNS; + +namespace UI { +namespace LXMF { + +PropagationNodesScreen::PropagationNodesScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _list(nullptr), + _btn_back(nullptr), _btn_sync(nullptr), _auto_select_row(nullptr), + _auto_select_checkbox(nullptr), _empty_label(nullptr), + _auto_select_enabled(true) { + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Create UI components + create_header(); + create_auto_select_row(); + create_list(); + + // Hide by default + hide(); + + TRACE("PropagationNodesScreen created"); +} + +PropagationNodesScreen::~PropagationNodesScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void PropagationNodesScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + _btn_back = lv_btn_create(_header); + lv_obj_set_size(_btn_back, 50, 28); + lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(_btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0); + + // Title + lv_obj_t* title = lv_label_create(_header); + lv_label_set_text(title, "Prop Nodes"); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 60, 0); + lv_obj_set_style_text_color(title, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + + // Sync button + _btn_sync = lv_btn_create(_header); + lv_obj_set_size(_btn_sync, 65, 28); + lv_obj_align(_btn_sync, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_set_style_bg_color(_btn_sync, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_sync, Theme::primaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_sync, on_sync_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_sync = lv_label_create(_btn_sync); + lv_label_set_text(label_sync, "Sync"); + lv_obj_center(label_sync); + lv_obj_set_style_text_color(label_sync, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(label_sync, &lv_font_montserrat_12, 0); +} + +void PropagationNodesScreen::create_auto_select_row() { + _auto_select_row = lv_obj_create(_screen); + lv_obj_set_size(_auto_select_row, LV_PCT(100), 36); + lv_obj_align(_auto_select_row, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_bg_color(_auto_select_row, lv_color_hex(0x1e1e1e), 0); + lv_obj_set_style_border_width(_auto_select_row, 0, 0); + lv_obj_set_style_border_side(_auto_select_row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(_auto_select_row, Theme::btnSecondary(), 0); + lv_obj_set_style_radius(_auto_select_row, 0, 0); + lv_obj_set_style_pad_left(_auto_select_row, 8, 0); + lv_obj_add_flag(_auto_select_row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(_auto_select_row, on_auto_select_changed, LV_EVENT_CLICKED, this); + + // Checkbox + _auto_select_checkbox = lv_checkbox_create(_auto_select_row); + lv_checkbox_set_text(_auto_select_checkbox, "Auto-select best node"); + lv_obj_align(_auto_select_checkbox, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(_auto_select_checkbox, Theme::textSecondary(), 0); + lv_obj_set_style_text_font(_auto_select_checkbox, &lv_font_montserrat_14, 0); + + // Style the checkbox indicator + lv_obj_set_style_bg_color(_auto_select_checkbox, Theme::info(), LV_PART_INDICATOR | LV_STATE_CHECKED); + lv_obj_set_style_border_color(_auto_select_checkbox, Theme::textMuted(), LV_PART_INDICATOR); + lv_obj_set_style_border_width(_auto_select_checkbox, 2, LV_PART_INDICATOR); + + // Set initial state + if (_auto_select_enabled) { + lv_obj_add_state(_auto_select_checkbox, LV_STATE_CHECKED); + } +} + +void PropagationNodesScreen::create_list() { + _list = lv_obj_create(_screen); + lv_obj_set_size(_list, LV_PCT(100), 168); // 240 - 36 (header) - 36 (auto-select row) + lv_obj_align(_list, LV_ALIGN_TOP_MID, 0, 72); + lv_obj_set_style_pad_all(_list, 4, 0); + lv_obj_set_style_pad_gap(_list, 4, 0); + lv_obj_set_style_bg_color(_list, Theme::surface(), 0); + lv_obj_set_style_border_width(_list, 0, 0); + lv_obj_set_style_radius(_list, 0, 0); + lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); +} + +void PropagationNodesScreen::load_nodes(::LXMF::PropagationNodeManager& manager, + const RNS::Bytes& selected_hash, + bool auto_select_enabled) { + LVGL_LOCK(); + INFO("Loading propagation nodes"); + + _selected_hash = selected_hash; + _auto_select_enabled = auto_select_enabled; + + // Update checkbox state + if (_auto_select_enabled) { + lv_obj_add_state(_auto_select_checkbox, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_auto_select_checkbox, LV_STATE_CHECKED); + } + + // Clear existing items + lv_obj_clean(_list); + _nodes.clear(); + _empty_label = nullptr; + + // Get nodes from manager + auto nodes = manager.get_nodes(); + + for (const auto& node_info : nodes) { + NodeItem item; + item.node_hash = node_info.node_hash; + item.name = node_info.name.c_str(); + item.hash_display = truncate_hash(node_info.node_hash); + item.hops = node_info.hops; + item.enabled = node_info.enabled; + item.is_selected = (!_auto_select_enabled && node_info.node_hash == _selected_hash); + + _nodes.push_back(item); + } + + std::string count_msg = " Found " + std::to_string(_nodes.size()) + " propagation nodes"; + INFO(count_msg.c_str()); + + if (_nodes.empty()) { + show_empty_state(); + } else { + for (size_t i = 0; i < _nodes.size(); i++) { + create_node_item(_nodes[i], i); + } + } +} + +void PropagationNodesScreen::refresh() { + // Refresh requires manager reference - caller should use load_nodes() + DEBUG("PropagationNodesScreen::refresh() - use load_nodes() with manager reference"); +} + +void PropagationNodesScreen::show_empty_state() { + _empty_label = lv_label_create(_list); + lv_label_set_text(_empty_label, "No propagation nodes\n\nWaiting for nodes\nto announce..."); + lv_obj_set_style_text_color(_empty_label, Theme::textMuted(), 0); + lv_obj_set_style_text_align(_empty_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(_empty_label, LV_ALIGN_CENTER, 0, 0); +} + +void PropagationNodesScreen::create_node_item(const NodeItem& item, size_t index) { + // Container for node item - 2-row layout + lv_obj_t* container = lv_obj_create(_list); + lv_obj_set_size(container, LV_PCT(100), 44); + lv_obj_set_style_bg_color(container, lv_color_hex(0x1e1e1e), 0); + lv_obj_set_style_bg_color(container, Theme::surfaceInput(), LV_STATE_PRESSED); + lv_obj_set_style_border_width(container, 1, 0); + lv_obj_set_style_border_color(container, Theme::btnSecondary(), 0); + lv_obj_set_style_radius(container, 6, 0); + lv_obj_set_style_pad_all(container, 4, 0); + lv_obj_add_flag(container, LV_OBJ_FLAG_CLICKABLE); + + // Store index in user_data + lv_obj_set_user_data(container, (void*)(uintptr_t)index); + lv_obj_add_event_cb(container, on_node_clicked, LV_EVENT_CLICKED, this); + + // Selection indicator (radio button style) + lv_obj_t* radio = lv_obj_create(container); + lv_obj_set_size(radio, 16, 16); + lv_obj_align(radio, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_set_style_radius(radio, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(radio, 2, 0); + lv_obj_clear_flag(radio, LV_OBJ_FLAG_CLICKABLE); + + if (item.is_selected && !_auto_select_enabled) { + lv_obj_set_style_bg_color(radio, Theme::info(), 0); + lv_obj_set_style_border_color(radio, Theme::info(), 0); + } else { + lv_obj_set_style_bg_color(radio, lv_color_hex(0x1e1e1e), 0); + lv_obj_set_style_border_color(radio, Theme::textMuted(), 0); + } + + // Row 1: Name and hops + lv_obj_t* label_name = lv_label_create(container); + lv_label_set_text(label_name, item.name.c_str()); + lv_obj_align(label_name, LV_ALIGN_TOP_LEFT, 24, 2); + lv_obj_set_style_text_color(label_name, item.enabled ? Theme::info() : Theme::textMuted(), 0); + lv_obj_set_style_text_font(label_name, &lv_font_montserrat_14, 0); + + lv_obj_t* label_hops = lv_label_create(container); + lv_label_set_text(label_hops, format_hops(item.hops).c_str()); + lv_obj_align(label_hops, LV_ALIGN_TOP_RIGHT, -4, 2); + lv_obj_set_style_text_color(label_hops, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(label_hops, &lv_font_montserrat_12, 0); + + // Row 2: Hash and status + lv_obj_t* label_hash = lv_label_create(container); + lv_label_set_text(label_hash, item.hash_display.c_str()); + lv_obj_align(label_hash, LV_ALIGN_BOTTOM_LEFT, 24, -2); + lv_obj_set_style_text_color(label_hash, Theme::textMuted(), 0); + lv_obj_set_style_text_font(label_hash, &lv_font_montserrat_12, 0); + + if (!item.enabled) { + lv_obj_t* label_status = lv_label_create(container); + lv_label_set_text(label_status, "disabled"); + lv_obj_align(label_status, LV_ALIGN_BOTTOM_RIGHT, -4, -2); + lv_obj_set_style_text_color(label_status, Theme::error(), 0); + lv_obj_set_style_text_font(label_status, &lv_font_montserrat_12, 0); + } +} + +void PropagationNodesScreen::update_selection_ui() { + // Clear and recreate list to update selection state + lv_obj_clean(_list); + _empty_label = nullptr; + + if (_nodes.empty()) { + show_empty_state(); + } else { + for (size_t i = 0; i < _nodes.size(); i++) { + _nodes[i].is_selected = (!_auto_select_enabled && _nodes[i].node_hash == _selected_hash); + create_node_item(_nodes[i], i); + } + } +} + +// Event handlers +void PropagationNodesScreen::on_node_clicked(lv_event_t* event) { + PropagationNodesScreen* screen = static_cast(lv_event_get_user_data(event)); + lv_obj_t* target = lv_event_get_target(event); + size_t index = (size_t)(uintptr_t)lv_obj_get_user_data(target); + + if (index < screen->_nodes.size()) { + const NodeItem& item = screen->_nodes[index]; + std::string msg = "Selected propagation node: " + std::string(item.name.c_str()); + INFO(msg.c_str()); + + // Disable auto-select when user manually selects + screen->_auto_select_enabled = false; + lv_obj_clear_state(screen->_auto_select_checkbox, LV_STATE_CHECKED); + + screen->_selected_hash = item.node_hash; + screen->update_selection_ui(); + + if (screen->_auto_select_changed_callback) { + screen->_auto_select_changed_callback(false); + } + + if (screen->_node_selected_callback) { + screen->_node_selected_callback(item.node_hash); + } + } +} + +void PropagationNodesScreen::on_back_clicked(lv_event_t* event) { + PropagationNodesScreen* screen = static_cast(lv_event_get_user_data(event)); + if (screen->_back_callback) { + screen->_back_callback(); + } +} + +void PropagationNodesScreen::on_sync_clicked(lv_event_t* event) { + PropagationNodesScreen* screen = static_cast(lv_event_get_user_data(event)); + if (screen->_sync_callback) { + screen->_sync_callback(); + } +} + +void PropagationNodesScreen::on_auto_select_changed(lv_event_t* event) { + PropagationNodesScreen* screen = static_cast(lv_event_get_user_data(event)); + + screen->_auto_select_enabled = !screen->_auto_select_enabled; + + if (screen->_auto_select_enabled) { + lv_obj_add_state(screen->_auto_select_checkbox, LV_STATE_CHECKED); + screen->_selected_hash = {}; // Clear manual selection + } else { + lv_obj_clear_state(screen->_auto_select_checkbox, LV_STATE_CHECKED); + } + + screen->update_selection_ui(); + + if (screen->_auto_select_changed_callback) { + screen->_auto_select_changed_callback(screen->_auto_select_enabled); + } + + if (screen->_auto_select_enabled && screen->_node_selected_callback) { + // Signal that auto-select is now active (empty hash) + screen->_node_selected_callback({}); + } +} + +// Callback setters +void PropagationNodesScreen::set_node_selected_callback(NodeSelectedCallback callback) { + _node_selected_callback = callback; +} + +void PropagationNodesScreen::set_back_callback(BackCallback callback) { + _back_callback = callback; +} + +void PropagationNodesScreen::set_sync_callback(SyncCallback callback) { + _sync_callback = callback; +} + +void PropagationNodesScreen::set_auto_select_changed_callback(AutoSelectChangedCallback callback) { + _auto_select_changed_callback = callback; +} + +// Visibility +void PropagationNodesScreen::show() { + LVGL_LOCK(); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); + + // Add buttons to focus group for trackball navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_add_obj(group, _btn_back); + if (_btn_sync) lv_group_add_obj(group, _btn_sync); + + // Focus on back button + if (_btn_back) { + lv_group_focus_obj(_btn_back); + } + } +} + +void PropagationNodesScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_remove_obj(_btn_back); + if (_btn_sync) lv_group_remove_obj(_btn_sync); + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* PropagationNodesScreen::get_object() { + return _screen; +} + +// Utility functions +String PropagationNodesScreen::truncate_hash(const Bytes& hash) { + if (hash.size() < 8) return "???"; + String hex = hash.toHex().c_str(); + return hex.substring(0, 8) + "..."; +} + +String PropagationNodesScreen::format_hops(uint8_t hops) { + if (hops == 0) return "direct"; + if (hops == 0xFF) return "? hops"; + return String(hops) + " hop" + (hops == 1 ? "" : "s"); +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.h b/lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.h new file mode 100644 index 0000000..f80659d --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.h @@ -0,0 +1,174 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_PROPAGATIONNODESSCREEN_H +#define UI_LXMF_PROPAGATIONNODESSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include +#include "Bytes.h" + +namespace LXMF { + class PropagationNodeManager; +} + +namespace UI { +namespace LXMF { + +/** + * Propagation Nodes Screen + * + * Shows a list of discovered LXMF propagation nodes with selection: + * - Node name and hash + * - Hop count / reachability + * - Selection radio buttons + * - Auto-select option + * + * Layout: + * +-------------------------------------+ + * | <- Prop Nodes [Sync] | 32px header + * +-------------------------------------+ + * | ( ) Auto-select best node | + * +-------------------------------------+ + * | (*) NodeName1 2 hops | + * | abc123... | + * +-------------------------------------+ + * | ( ) NodeName2 3 hops | 168px scrollable + * | def456... disabled | + * +-------------------------------------+ + */ +class PropagationNodesScreen { +public: + /** + * Node item data for display + */ + struct NodeItem { + RNS::Bytes node_hash; + String name; + String hash_display; + uint8_t hops; + bool enabled; + bool is_selected; + }; + + /** + * Callback types + */ + using NodeSelectedCallback = std::function; + using BackCallback = std::function; + using SyncCallback = std::function; + using AutoSelectChangedCallback = std::function; + + /** + * Create propagation nodes screen + * @param parent Parent LVGL object (usually lv_scr_act()) + */ + PropagationNodesScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~PropagationNodesScreen(); + + /** + * Load nodes from PropagationNodeManager + * @param manager The propagation node manager + * @param selected_hash Currently selected node hash (empty for auto-select) + * @param auto_select_enabled Whether auto-select is enabled + */ + void load_nodes(::LXMF::PropagationNodeManager& manager, + const RNS::Bytes& selected_hash, + bool auto_select_enabled); + + /** + * Refresh the display + */ + void refresh(); + + /** + * Set callback for node selection + * @param callback Function to call when a node is selected + */ + void set_node_selected_callback(NodeSelectedCallback callback); + + /** + * Set callback for back button + * @param callback Function to call when back is pressed + */ + void set_back_callback(BackCallback callback); + + /** + * Set callback for sync button + * @param callback Function to call when sync is pressed + */ + void set_sync_callback(SyncCallback callback); + + /** + * Set callback for auto-select toggle + * @param callback Function to call when auto-select is changed + */ + void set_auto_select_changed_callback(AutoSelectChangedCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + * @return Root object + */ + lv_obj_t* get_object(); + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _list; + lv_obj_t* _btn_back; + lv_obj_t* _btn_sync; + lv_obj_t* _auto_select_row; + lv_obj_t* _auto_select_checkbox; + lv_obj_t* _empty_label; + + std::vector _nodes; + RNS::Bytes _selected_hash; + bool _auto_select_enabled; + + // Callbacks + NodeSelectedCallback _node_selected_callback; + BackCallback _back_callback; + SyncCallback _sync_callback; + AutoSelectChangedCallback _auto_select_changed_callback; + + // UI construction + void create_header(); + void create_auto_select_row(); + void create_list(); + void create_node_item(const NodeItem& item, size_t index); + void show_empty_state(); + void update_selection_ui(); + + // Event handlers + static void on_node_clicked(lv_event_t* event); + static void on_back_clicked(lv_event_t* event); + static void on_sync_clicked(lv_event_t* event); + static void on_auto_select_changed(lv_event_t* event); + + // Utility + static String truncate_hash(const RNS::Bytes& hash); + static String format_hops(uint8_t hops); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_PROPAGATIONNODESSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/QRScreen.cpp b/lib/tdeck_ui/UI/LXMF/QRScreen.cpp new file mode 100644 index 0000000..2ad3690 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/QRScreen.cpp @@ -0,0 +1,180 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "QRScreen.h" + +#ifdef ARDUINO + +#include "Log.h" +#include "../LVGL/LVGLInit.h" +#include "../LVGL/LVGLLock.h" + +using namespace RNS; + +namespace UI { +namespace LXMF { + +QRScreen::QRScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _content(nullptr), _btn_back(nullptr), + _qr_code(nullptr), _label_hint(nullptr) { + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, lv_color_hex(0x121212), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Create UI components + create_header(); + create_content(); + + // Hide by default + hide(); + + TRACE("QRScreen created"); +} + +QRScreen::~QRScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void QRScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, lv_color_hex(0x1a1a1a), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + _btn_back = lv_btn_create(_header); + lv_obj_set_size(_btn_back, 50, 28); + lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_set_style_bg_color(_btn_back, lv_color_hex(0x333333), 0); + lv_obj_set_style_bg_color(_btn_back, lv_color_hex(0x444444), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(_btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, lv_color_hex(0xe0e0e0), 0); + + // Title + lv_obj_t* title = lv_label_create(_header); + lv_label_set_text(title, "Share Identity"); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 60, 0); + lv_obj_set_style_text_color(title, lv_color_hex(0xffffff), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); +} + +void QRScreen::create_content() { + _content = lv_obj_create(_screen); + lv_obj_set_size(_content, LV_PCT(100), 204); // 240 - 36 header + lv_obj_align(_content, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_pad_all(_content, 8, 0); + lv_obj_set_style_bg_color(_content, lv_color_hex(0x121212), 0); + lv_obj_set_style_border_width(_content, 0, 0); + lv_obj_set_style_radius(_content, 0, 0); + lv_obj_clear_flag(_content, LV_OBJ_FLAG_SCROLLABLE); + + // QR code - centered, large for easy scanning + // 180px gives ~3.4px per module for 53-module QR (Version 9) + _qr_code = lv_qrcode_create(_content, 180, lv_color_hex(0x000000), lv_color_hex(0xffffff)); + lv_obj_align(_qr_code, LV_ALIGN_TOP_MID, 0, 0); + + // Hint text below QR + _label_hint = lv_label_create(_content); + lv_label_set_text(_label_hint, "Scan with Columba to add contact"); + lv_obj_align(_label_hint, LV_ALIGN_TOP_MID, 0, 183); // Below 180px QR + 3px gap + lv_obj_set_style_text_color(_label_hint, lv_color_hex(0x808080), 0); + lv_obj_set_style_text_font(_label_hint, &lv_font_montserrat_12, 0); +} + +void QRScreen::set_identity(const Identity& identity) { + LVGL_LOCK(); + _identity = identity; + update_qr_code(); +} + +void QRScreen::set_lxmf_address(const Bytes& hash) { + LVGL_LOCK(); + _lxmf_address = hash; + update_qr_code(); +} + +void QRScreen::update_qr_code() { + if (!_identity || !_qr_code || _lxmf_address.size() == 0) { + return; + } + + // Format: lxma://: + // Columba-compatible format for contact sharing + String dest_hash = String(_lxmf_address.toHex().c_str()); + String pub_key = String(_identity.get_public_key().toHex().c_str()); + + String qr_data = "lxma://"; + qr_data += dest_hash; + qr_data += ":"; + qr_data += pub_key; + + lv_qrcode_update(_qr_code, qr_data.c_str(), qr_data.length()); +} + +void QRScreen::set_back_callback(BackCallback callback) { + _back_callback = callback; +} + +void QRScreen::show() { + LVGL_LOCK(); + update_qr_code(); // Refresh QR when shown + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); + + // Add back button to focus group for trackball navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group && _btn_back) { + lv_group_add_obj(group, _btn_back); + lv_group_focus_obj(_btn_back); + } +} + +void QRScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group && _btn_back) { + lv_group_remove_obj(_btn_back); + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* QRScreen::get_object() { + return _screen; +} + +void QRScreen::on_back_clicked(lv_event_t* event) { + QRScreen* screen = (QRScreen*)lv_event_get_user_data(event); + + if (screen->_back_callback) { + screen->_back_callback(); + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/QRScreen.h b/lib/tdeck_ui/UI/LXMF/QRScreen.h new file mode 100644 index 0000000..c12d575 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/QRScreen.h @@ -0,0 +1,109 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_QRSCREEN_H +#define UI_LXMF_QRSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include "Bytes.h" +#include "Identity.h" + +namespace UI { +namespace LXMF { + +/** + * QR Code Screen + * + * Full-screen display of QR code for identity sharing. + * Shows a large, easily scannable QR code with the LXMF address. + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ ← Share Identity │ 36px header + * ├─────────────────────────────────────┤ + * │ │ + * │ ┌───────────────┐ │ + * │ │ │ │ + * │ │ QR CODE │ │ + * │ │ │ │ + * │ └───────────────┘ │ + * │ │ + * │ Scan to add contact │ + * └─────────────────────────────────────┘ + */ +class QRScreen { +public: + using BackCallback = std::function; + + /** + * Create QR screen + * @param parent Parent LVGL object + */ + QRScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~QRScreen(); + + /** + * Set identity for QR code generation + * @param identity The identity + */ + void set_identity(const RNS::Identity& identity); + + /** + * Set LXMF delivery destination hash + * @param hash The delivery destination hash + */ + void set_lxmf_address(const RNS::Bytes& hash); + + /** + * Set callback for back button + * @param callback Function to call when back is pressed + */ + void set_back_callback(BackCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + */ + lv_obj_t* get_object(); + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _content; + lv_obj_t* _btn_back; + lv_obj_t* _qr_code; + lv_obj_t* _label_hint; + + RNS::Identity _identity; + RNS::Bytes _lxmf_address; + + BackCallback _back_callback; + + void create_header(); + void create_content(); + void update_qr_code(); + + static void on_back_clicked(lv_event_t* event); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_QRSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp b/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp new file mode 100644 index 0000000..32a6e17 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp @@ -0,0 +1,1439 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "SettingsScreen.h" +#include "Theme.h" + +#ifdef ARDUINO + +#include +#include "../LVGL/LVGLLock.h" +#include +#include +#include "Log.h" +#include "Utilities/OS.h" +#include "../LVGL/LVGLInit.h" +#include "../TextAreaHelper.h" + +using namespace RNS; + +namespace UI { +namespace LXMF { + +// NVS keys +static const char* NVS_NAMESPACE = "settings"; +static const char* KEY_WIFI_SSID = "wifi_ssid"; +static const char* KEY_WIFI_PASS = "wifi_pass"; +static const char* KEY_TCP_HOST = "tcp_host"; +static const char* KEY_TCP_PORT = "tcp_port"; +static const char* KEY_DISPLAY_NAME = "disp_name"; +static const char* KEY_BRIGHTNESS = "brightness"; +static const char* KEY_KB_LIGHT = "kb_light"; +static const char* KEY_TIMEOUT = "timeout"; +static const char* KEY_ANNOUNCE_INT = "announce"; +static const char* KEY_SYNC_INT = "sync_int"; +static const char* KEY_GPS_SYNC = "gps_sync"; +// Notification settings +static const char* KEY_NOTIF_SND = "notif_snd"; +static const char* KEY_NOTIF_VOL = "notif_vol"; +// Interface settings +static const char* KEY_TCP_ENABLED = "tcp_en"; +static const char* KEY_LORA_ENABLED = "lora_en"; +static const char* KEY_LORA_FREQ = "lora_freq"; +static const char* KEY_LORA_BW = "lora_bw"; +static const char* KEY_LORA_SF = "lora_sf"; +static const char* KEY_LORA_CR = "lora_cr"; +static const char* KEY_LORA_POWER = "lora_pwr"; +static const char* KEY_AUTO_ENABLED = "auto_en"; +static const char* KEY_BLE_ENABLED = "ble_en"; +// Propagation settings +static const char* KEY_PROP_AUTO = "prop_auto"; +static const char* KEY_PROP_NODE = "prop_node"; +static const char* KEY_PROP_FALLBACK = "prop_fall"; +static const char* KEY_PROP_ONLY = "prop_only"; + +SettingsScreen::SettingsScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _content(nullptr), + _btn_back(nullptr), _btn_save(nullptr), + _ta_wifi_ssid(nullptr), _ta_wifi_password(nullptr), + _ta_tcp_host(nullptr), _ta_tcp_port(nullptr), _btn_reconnect(nullptr), + _ta_display_name(nullptr), + _slider_brightness(nullptr), _label_brightness_value(nullptr), _switch_kb_light(nullptr), _dropdown_timeout(nullptr), + _label_gps_sats(nullptr), _label_gps_coords(nullptr), _label_gps_alt(nullptr), _label_gps_hdop(nullptr), + _label_identity_hash(nullptr), _label_lxmf_address(nullptr), _label_firmware(nullptr), + _label_storage(nullptr), _label_ram(nullptr), + _switch_tcp_enabled(nullptr), _switch_lora_enabled(nullptr), + _ta_lora_frequency(nullptr), _dropdown_lora_bandwidth(nullptr), + _dropdown_lora_sf(nullptr), _dropdown_lora_cr(nullptr), + _slider_lora_power(nullptr), _label_lora_power_value(nullptr), + _lora_params_container(nullptr), _switch_auto_enabled(nullptr), _switch_ble_enabled(nullptr), + _ta_announce_interval(nullptr), _ta_sync_interval(nullptr), _switch_gps_sync(nullptr), + _btn_propagation_nodes(nullptr), _switch_prop_fallback(nullptr), _switch_prop_only(nullptr), + _gps(nullptr) { + LVGL_LOCK(); + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Load settings from NVS + load_settings(); + + // Create UI components + create_header(); + create_content(); + + // Update UI with loaded settings + update_ui_from_settings(); + + // Hide by default + hide(); + + TRACE("SettingsScreen created"); +} + +SettingsScreen::~SettingsScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void SettingsScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + _btn_back = lv_btn_create(_header); + lv_obj_set_size(_btn_back, 50, 28); + lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(_btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0); + + // Title + lv_obj_t* title = lv_label_create(_header); + lv_label_set_text(title, "Settings"); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 60, 0); + lv_obj_set_style_text_color(title, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + + // Save button + _btn_save = lv_btn_create(_header); + lv_obj_set_size(_btn_save, 55, 28); + lv_obj_align(_btn_save, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_bg_color(_btn_save, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_save, Theme::primaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_save, on_save_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_save = lv_label_create(_btn_save); + lv_label_set_text(label_save, "Save"); + lv_obj_center(label_save); + lv_obj_set_style_text_color(label_save, Theme::textPrimary(), 0); +} + +void SettingsScreen::create_content() { + _content = lv_obj_create(_screen); + lv_obj_set_size(_content, LV_PCT(100), 204); // 240 - 36 header + lv_obj_align(_content, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_pad_all(_content, 4, 0); + lv_obj_set_style_pad_gap(_content, 2, 0); + lv_obj_set_style_bg_color(_content, Theme::surface(), 0); + lv_obj_set_style_border_width(_content, 0, 0); + lv_obj_set_style_radius(_content, 0, 0); + + // Enable vertical scrolling with flex layout + lv_obj_set_flex_flow(_content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(_content, LV_DIR_VER); + lv_obj_set_scrollbar_mode(_content, LV_SCROLLBAR_MODE_AUTO); + + // Create sections + create_network_section(_content); + create_identity_section(_content); + create_display_section(_content); + create_notifications_section(_content); + create_interfaces_section(_content); + create_delivery_section(_content); + create_gps_section(_content); + create_system_section(_content); + create_advanced_section(_content); +} + +lv_obj_t* SettingsScreen::create_section_header(lv_obj_t* parent, const char* title) { + lv_obj_t* header = lv_label_create(parent); + lv_label_set_text(header, title); + lv_obj_set_width(header, LV_PCT(100)); + lv_obj_set_style_text_color(header, Theme::info(), 0); + lv_obj_set_style_text_font(header, &lv_font_montserrat_12, 0); + lv_obj_set_style_pad_top(header, 6, 0); + lv_obj_set_style_pad_bottom(header, 2, 0); + return header; +} + +lv_obj_t* SettingsScreen::create_label_row(lv_obj_t* parent, const char* text) { + lv_obj_t* label = lv_label_create(parent); + lv_label_set_text(label, text); + lv_obj_set_width(label, LV_PCT(100)); + lv_obj_set_style_text_color(label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); + return label; +} + +lv_obj_t* SettingsScreen::create_text_input(lv_obj_t* parent, const char* placeholder, + bool password, int max_len) { + lv_obj_t* ta = lv_textarea_create(parent); + lv_obj_set_width(ta, LV_PCT(100)); + lv_obj_set_height(ta, 28); + lv_textarea_set_placeholder_text(ta, placeholder); + lv_textarea_set_one_line(ta, true); + lv_textarea_set_max_length(ta, max_len); + if (password) { + lv_textarea_set_password_mode(ta, true); + } + lv_obj_set_style_bg_color(ta, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(ta, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(ta, Theme::border(), 0); + lv_obj_set_style_border_width(ta, 1, 0); + lv_obj_set_style_radius(ta, 4, 0); + lv_obj_set_style_pad_all(ta, 4, 0); + lv_obj_set_style_text_font(ta, &lv_font_montserrat_14, 0); + // Add to input group for keyboard navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + lv_group_add_obj(group, ta); + } + // Enable paste on long-press + TextAreaHelper::enable_paste(ta); + return ta; +} + +void SettingsScreen::create_network_section(lv_obj_t* parent) { + create_section_header(parent, "== Network =="); + + create_label_row(parent, "WiFi SSID:"); + _ta_wifi_ssid = create_text_input(parent, "Enter SSID", false, 32); + + create_label_row(parent, "WiFi Password:"); + _ta_wifi_password = create_text_input(parent, "Enter password", true, 64); + + create_label_row(parent, "TCP Server:"); + _ta_tcp_host = create_text_input(parent, "IP or hostname", false, 64); + + // Port and reconnect row + lv_obj_t* port_row = lv_obj_create(parent); + lv_obj_set_width(port_row, LV_PCT(100)); + lv_obj_set_height(port_row, 32); + lv_obj_set_style_bg_opa(port_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(port_row, 0, 0); + lv_obj_set_style_pad_all(port_row, 0, 0); + lv_obj_clear_flag(port_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* port_label = lv_label_create(port_row); + lv_label_set_text(port_label, "Port:"); + lv_obj_align(port_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(port_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(port_label, &lv_font_montserrat_14, 0); + + _ta_tcp_port = lv_textarea_create(port_row); + lv_obj_set_size(_ta_tcp_port, 60, 26); + lv_obj_align(_ta_tcp_port, LV_ALIGN_LEFT_MID, 35, 0); + lv_textarea_set_one_line(_ta_tcp_port, true); + lv_textarea_set_max_length(_ta_tcp_port, 5); + lv_textarea_set_accepted_chars(_ta_tcp_port, "0123456789"); + lv_obj_set_style_bg_color(_ta_tcp_port, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_ta_tcp_port, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_ta_tcp_port, Theme::border(), 0); + lv_obj_set_style_border_width(_ta_tcp_port, 1, 0); + lv_obj_set_style_radius(_ta_tcp_port, 4, 0); + lv_obj_set_style_pad_all(_ta_tcp_port, 4, 0); + lv_obj_set_style_text_font(_ta_tcp_port, &lv_font_montserrat_14, 0); + // Add to input group for keyboard navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + lv_group_add_obj(group, _ta_tcp_port); + } + // Enable paste on long-press + TextAreaHelper::enable_paste(_ta_tcp_port); + + _btn_reconnect = lv_btn_create(port_row); + lv_obj_set_size(_btn_reconnect, 80, 26); + lv_obj_align(_btn_reconnect, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_btn_reconnect, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_reconnect, Theme::primaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_reconnect, on_reconnect_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_reconnect = lv_label_create(_btn_reconnect); + lv_label_set_text(label_reconnect, "Reconnect"); + lv_obj_center(label_reconnect); + lv_obj_set_style_text_color(label_reconnect, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(label_reconnect, &lv_font_montserrat_14, 0); +} + +void SettingsScreen::create_identity_section(lv_obj_t* parent) { + create_section_header(parent, "== Identity =="); + + create_label_row(parent, "Display Name:"); + _ta_display_name = create_text_input(parent, "Your name", false, 32); +} + +void SettingsScreen::create_display_section(lv_obj_t* parent) { + create_section_header(parent, "== Display =="); + + // Brightness row + lv_obj_t* brightness_row = lv_obj_create(parent); + lv_obj_set_width(brightness_row, LV_PCT(100)); + lv_obj_set_height(brightness_row, 28); + lv_obj_set_style_bg_opa(brightness_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(brightness_row, 0, 0); + lv_obj_set_style_pad_all(brightness_row, 0, 0); + lv_obj_clear_flag(brightness_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* bright_label = lv_label_create(brightness_row); + lv_label_set_text(bright_label, "Brightness:"); + lv_obj_align(bright_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(bright_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(bright_label, &lv_font_montserrat_14, 0); + + _slider_brightness = lv_slider_create(brightness_row); + lv_obj_set_size(_slider_brightness, 120, 10); + lv_obj_align(_slider_brightness, LV_ALIGN_LEFT_MID, 95, 0); + lv_slider_set_range(_slider_brightness, 10, 255); + lv_obj_set_style_bg_color(_slider_brightness, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_slider_brightness, Theme::info(), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(_slider_brightness, Theme::textPrimary(), LV_PART_KNOB); + lv_obj_add_event_cb(_slider_brightness, on_brightness_changed, LV_EVENT_VALUE_CHANGED, this); + + _label_brightness_value = lv_label_create(brightness_row); + lv_label_set_text(_label_brightness_value, "180"); + lv_obj_align(_label_brightness_value, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_text_color(_label_brightness_value, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(_label_brightness_value, &lv_font_montserrat_14, 0); + + // Keyboard light row + lv_obj_t* kb_light_row = lv_obj_create(parent); + lv_obj_set_width(kb_light_row, LV_PCT(100)); + lv_obj_set_height(kb_light_row, 28); + lv_obj_set_style_bg_opa(kb_light_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(kb_light_row, 0, 0); + lv_obj_set_style_pad_all(kb_light_row, 0, 0); + lv_obj_clear_flag(kb_light_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* kb_light_label = lv_label_create(kb_light_row); + lv_label_set_text(kb_light_label, "Keyboard Light:"); + lv_obj_align(kb_light_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(kb_light_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(kb_light_label, &lv_font_montserrat_14, 0); + + _switch_kb_light = lv_switch_create(kb_light_row); + lv_obj_set_size(_switch_kb_light, 40, 20); + lv_obj_align(_switch_kb_light, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_kb_light, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_kb_light, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + + // Timeout row + lv_obj_t* timeout_row = lv_obj_create(parent); + lv_obj_set_width(timeout_row, LV_PCT(100)); + lv_obj_set_height(timeout_row, 28); + lv_obj_set_style_bg_opa(timeout_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(timeout_row, 0, 0); + lv_obj_set_style_pad_all(timeout_row, 0, 0); + lv_obj_clear_flag(timeout_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* timeout_label = lv_label_create(timeout_row); + lv_label_set_text(timeout_label, "Screen Timeout:"); + lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(timeout_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(timeout_label, &lv_font_montserrat_14, 0); + + _dropdown_timeout = lv_dropdown_create(timeout_row); + lv_dropdown_set_options(_dropdown_timeout, "30 sec\n1 min\n5 min\nNever"); + lv_obj_set_size(_dropdown_timeout, 90, 28); + lv_obj_align(_dropdown_timeout, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_dropdown_timeout, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_dropdown_timeout, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_dropdown_timeout, Theme::border(), 0); + lv_obj_set_style_text_font(_dropdown_timeout, &lv_font_montserrat_14, 0); +} + +void SettingsScreen::create_notifications_section(lv_obj_t* parent) { + create_section_header(parent, "== Notifications =="); + + // Sound enabled row + lv_obj_t* sound_row = lv_obj_create(parent); + lv_obj_set_width(sound_row, LV_PCT(100)); + lv_obj_set_height(sound_row, 28); + lv_obj_set_style_bg_opa(sound_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(sound_row, 0, 0); + lv_obj_set_style_pad_all(sound_row, 0, 0); + lv_obj_clear_flag(sound_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* sound_label = lv_label_create(sound_row); + lv_label_set_text(sound_label, "Message Sound:"); + lv_obj_align(sound_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(sound_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(sound_label, &lv_font_montserrat_14, 0); + + _switch_notification_sound = lv_switch_create(sound_row); + lv_obj_set_size(_switch_notification_sound, 40, 20); + lv_obj_align(_switch_notification_sound, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_notification_sound, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_notification_sound, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + + // Volume row + lv_obj_t* volume_row = lv_obj_create(parent); + lv_obj_set_width(volume_row, LV_PCT(100)); + lv_obj_set_height(volume_row, 28); + lv_obj_set_style_bg_opa(volume_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(volume_row, 0, 0); + lv_obj_set_style_pad_all(volume_row, 0, 0); + lv_obj_clear_flag(volume_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* volume_label = lv_label_create(volume_row); + lv_label_set_text(volume_label, "Volume:"); + lv_obj_align(volume_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(volume_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(volume_label, &lv_font_montserrat_14, 0); + + _slider_notification_volume = lv_slider_create(volume_row); + lv_obj_set_size(_slider_notification_volume, 120, 10); + lv_obj_align(_slider_notification_volume, LV_ALIGN_LEFT_MID, 65, 0); + lv_slider_set_range(_slider_notification_volume, 0, 100); + lv_obj_set_style_bg_color(_slider_notification_volume, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_slider_notification_volume, Theme::info(), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(_slider_notification_volume, Theme::textPrimary(), LV_PART_KNOB); + lv_obj_add_event_cb(_slider_notification_volume, on_notification_volume_changed, LV_EVENT_VALUE_CHANGED, this); + + _label_notification_volume_value = lv_label_create(volume_row); + lv_label_set_text(_label_notification_volume_value, "50"); + lv_obj_align(_label_notification_volume_value, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_text_color(_label_notification_volume_value, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(_label_notification_volume_value, &lv_font_montserrat_14, 0); +} + +void SettingsScreen::create_interfaces_section(lv_obj_t* parent) { + create_section_header(parent, "== Interfaces =="); + + // Auto Discovery row + lv_obj_t* auto_row = lv_obj_create(parent); + lv_obj_set_width(auto_row, LV_PCT(100)); + lv_obj_set_height(auto_row, 28); + lv_obj_set_style_bg_opa(auto_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(auto_row, 0, 0); + lv_obj_set_style_pad_all(auto_row, 0, 0); + lv_obj_clear_flag(auto_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* auto_label = lv_label_create(auto_row); + lv_label_set_text(auto_label, "Auto Discovery:"); + lv_obj_align(auto_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(auto_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(auto_label, &lv_font_montserrat_14, 0); + + _switch_auto_enabled = lv_switch_create(auto_row); + lv_obj_set_size(_switch_auto_enabled, 40, 20); + lv_obj_align(_switch_auto_enabled, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_auto_enabled, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_auto_enabled, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + + // BLE P2P row + lv_obj_t* ble_row = lv_obj_create(parent); + lv_obj_set_width(ble_row, LV_PCT(100)); + lv_obj_set_height(ble_row, 28); + lv_obj_set_style_bg_opa(ble_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(ble_row, 0, 0); + lv_obj_set_style_pad_all(ble_row, 0, 0); + lv_obj_clear_flag(ble_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* ble_label = lv_label_create(ble_row); + lv_label_set_text(ble_label, "BLE P2P:"); + lv_obj_align(ble_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(ble_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(ble_label, &lv_font_montserrat_14, 0); + + _switch_ble_enabled = lv_switch_create(ble_row); + lv_obj_set_size(_switch_ble_enabled, 40, 20); + lv_obj_align(_switch_ble_enabled, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_ble_enabled, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_ble_enabled, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + + // TCP Enable row + lv_obj_t* tcp_row = lv_obj_create(parent); + lv_obj_set_width(tcp_row, LV_PCT(100)); + lv_obj_set_height(tcp_row, 28); + lv_obj_set_style_bg_opa(tcp_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(tcp_row, 0, 0); + lv_obj_set_style_pad_all(tcp_row, 0, 0); + lv_obj_clear_flag(tcp_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* tcp_label = lv_label_create(tcp_row); + lv_label_set_text(tcp_label, "TCP Interface:"); + lv_obj_align(tcp_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(tcp_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(tcp_label, &lv_font_montserrat_14, 0); + + _switch_tcp_enabled = lv_switch_create(tcp_row); + lv_obj_set_size(_switch_tcp_enabled, 40, 20); + lv_obj_align(_switch_tcp_enabled, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_tcp_enabled, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_tcp_enabled, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + + // LoRa Enable row + lv_obj_t* lora_row = lv_obj_create(parent); + lv_obj_set_width(lora_row, LV_PCT(100)); + lv_obj_set_height(lora_row, 28); + lv_obj_set_style_bg_opa(lora_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(lora_row, 0, 0); + lv_obj_set_style_pad_all(lora_row, 0, 0); + lv_obj_clear_flag(lora_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* lora_label = lv_label_create(lora_row); + lv_label_set_text(lora_label, "LoRa Interface:"); + lv_obj_align(lora_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(lora_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(lora_label, &lv_font_montserrat_14, 0); + + _switch_lora_enabled = lv_switch_create(lora_row); + lv_obj_set_size(_switch_lora_enabled, 40, 20); + lv_obj_align(_switch_lora_enabled, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_lora_enabled, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_lora_enabled, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + lv_obj_add_event_cb(_switch_lora_enabled, on_lora_enabled_changed, LV_EVENT_VALUE_CHANGED, this); + + // LoRa parameters container (shown/hidden based on LoRa enabled) + _lora_params_container = lv_obj_create(parent); + lv_obj_set_width(_lora_params_container, LV_PCT(100)); + lv_obj_set_height(_lora_params_container, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(_lora_params_container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(_lora_params_container, 0, 0); + lv_obj_set_style_pad_all(_lora_params_container, 0, 0); + lv_obj_set_style_pad_gap(_lora_params_container, 2, 0); + lv_obj_set_flex_flow(_lora_params_container, LV_FLEX_FLOW_COLUMN); + lv_obj_clear_flag(_lora_params_container, LV_OBJ_FLAG_SCROLLABLE); + + // Frequency row + lv_obj_t* freq_row = lv_obj_create(_lora_params_container); + lv_obj_set_width(freq_row, LV_PCT(100)); + lv_obj_set_height(freq_row, 28); + lv_obj_set_style_bg_opa(freq_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(freq_row, 0, 0); + lv_obj_set_style_pad_all(freq_row, 0, 0); + lv_obj_clear_flag(freq_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* freq_label = lv_label_create(freq_row); + lv_label_set_text(freq_label, " Frequency:"); + lv_obj_align(freq_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(freq_label, lv_color_hex(0x909090), 0); + lv_obj_set_style_text_font(freq_label, &lv_font_montserrat_14, 0); + + _ta_lora_frequency = lv_textarea_create(freq_row); + lv_obj_set_size(_ta_lora_frequency, 80, 24); + lv_obj_align(_ta_lora_frequency, LV_ALIGN_RIGHT_MID, -30, 0); + lv_textarea_set_one_line(_ta_lora_frequency, true); + lv_textarea_set_max_length(_ta_lora_frequency, 8); + lv_textarea_set_accepted_chars(_ta_lora_frequency, "0123456789."); + lv_obj_set_style_bg_color(_ta_lora_frequency, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_ta_lora_frequency, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_ta_lora_frequency, Theme::border(), 0); + lv_obj_set_style_border_width(_ta_lora_frequency, 1, 0); + lv_obj_set_style_radius(_ta_lora_frequency, 4, 0); + lv_obj_set_style_pad_all(_ta_lora_frequency, 4, 0); + lv_obj_set_style_text_font(_ta_lora_frequency, &lv_font_montserrat_14, 0); + // Enable paste on long-press + TextAreaHelper::enable_paste(_ta_lora_frequency); + + lv_obj_t* mhz_label = lv_label_create(freq_row); + lv_label_set_text(mhz_label, "MHz"); + lv_obj_align(mhz_label, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_text_color(mhz_label, Theme::textMuted(), 0); + lv_obj_set_style_text_font(mhz_label, &lv_font_montserrat_14, 0); + + // Bandwidth dropdown row + lv_obj_t* bw_row = lv_obj_create(_lora_params_container); + lv_obj_set_width(bw_row, LV_PCT(100)); + lv_obj_set_height(bw_row, 28); + lv_obj_set_style_bg_opa(bw_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(bw_row, 0, 0); + lv_obj_set_style_pad_all(bw_row, 0, 0); + lv_obj_clear_flag(bw_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* bw_label = lv_label_create(bw_row); + lv_label_set_text(bw_label, " Bandwidth:"); + lv_obj_align(bw_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(bw_label, lv_color_hex(0x909090), 0); + lv_obj_set_style_text_font(bw_label, &lv_font_montserrat_14, 0); + + _dropdown_lora_bandwidth = lv_dropdown_create(bw_row); + lv_dropdown_set_options(_dropdown_lora_bandwidth, "62.5 kHz\n125 kHz\n250 kHz\n500 kHz"); + lv_obj_set_size(_dropdown_lora_bandwidth, 100, 28); + lv_obj_align(_dropdown_lora_bandwidth, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_dropdown_lora_bandwidth, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_dropdown_lora_bandwidth, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_dropdown_lora_bandwidth, Theme::border(), 0); + lv_obj_set_style_text_font(_dropdown_lora_bandwidth, &lv_font_montserrat_14, 0); + + // SF/CR row + lv_obj_t* sfcr_row = lv_obj_create(_lora_params_container); + lv_obj_set_width(sfcr_row, LV_PCT(100)); + lv_obj_set_height(sfcr_row, 28); + lv_obj_set_style_bg_opa(sfcr_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(sfcr_row, 0, 0); + lv_obj_set_style_pad_all(sfcr_row, 0, 0); + lv_obj_clear_flag(sfcr_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* sf_label = lv_label_create(sfcr_row); + lv_label_set_text(sf_label, " SF:"); + lv_obj_align(sf_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(sf_label, lv_color_hex(0x909090), 0); + lv_obj_set_style_text_font(sf_label, &lv_font_montserrat_14, 0); + + _dropdown_lora_sf = lv_dropdown_create(sfcr_row); + lv_dropdown_set_options(_dropdown_lora_sf, "7\n8\n9\n10\n11\n12"); + lv_obj_set_size(_dropdown_lora_sf, 50, 28); + lv_obj_align(_dropdown_lora_sf, LV_ALIGN_LEFT_MID, 30, 0); + lv_obj_set_style_bg_color(_dropdown_lora_sf, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_dropdown_lora_sf, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_dropdown_lora_sf, Theme::border(), 0); + lv_obj_set_style_text_font(_dropdown_lora_sf, &lv_font_montserrat_14, 0); + + lv_obj_t* cr_label = lv_label_create(sfcr_row); + lv_label_set_text(cr_label, "CR:"); + lv_obj_align(cr_label, LV_ALIGN_LEFT_MID, 90, 0); + lv_obj_set_style_text_color(cr_label, lv_color_hex(0x909090), 0); + lv_obj_set_style_text_font(cr_label, &lv_font_montserrat_14, 0); + + _dropdown_lora_cr = lv_dropdown_create(sfcr_row); + lv_dropdown_set_options(_dropdown_lora_cr, "4/5\n4/6\n4/7\n4/8"); + lv_obj_set_size(_dropdown_lora_cr, 55, 28); + lv_obj_align(_dropdown_lora_cr, LV_ALIGN_LEFT_MID, 115, 0); + lv_obj_set_style_bg_color(_dropdown_lora_cr, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_dropdown_lora_cr, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_dropdown_lora_cr, Theme::border(), 0); + lv_obj_set_style_text_font(_dropdown_lora_cr, &lv_font_montserrat_14, 0); + + // TX Power row + lv_obj_t* power_row = lv_obj_create(_lora_params_container); + lv_obj_set_width(power_row, LV_PCT(100)); + lv_obj_set_height(power_row, 28); + lv_obj_set_style_bg_opa(power_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(power_row, 0, 0); + lv_obj_set_style_pad_all(power_row, 0, 0); + lv_obj_clear_flag(power_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* power_label = lv_label_create(power_row); + lv_label_set_text(power_label, " TX Power:"); + lv_obj_align(power_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(power_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(power_label, &lv_font_montserrat_14, 0); + + _slider_lora_power = lv_slider_create(power_row); + lv_obj_set_size(_slider_lora_power, 100, 10); + lv_obj_align(_slider_lora_power, LV_ALIGN_LEFT_MID, 75, 0); + lv_slider_set_range(_slider_lora_power, 2, 22); + lv_obj_set_style_bg_color(_slider_lora_power, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_slider_lora_power, Theme::primary(), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(_slider_lora_power, Theme::textPrimary(), LV_PART_KNOB); + lv_obj_add_event_cb(_slider_lora_power, on_lora_power_changed, LV_EVENT_VALUE_CHANGED, this); + + _label_lora_power_value = lv_label_create(power_row); + lv_label_set_text(_label_lora_power_value, "17 dBm"); + lv_obj_align(_label_lora_power_value, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_text_color(_label_lora_power_value, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(_label_lora_power_value, &lv_font_montserrat_14, 0); + + // Initially hide LoRa params if not enabled + lv_obj_add_flag(_lora_params_container, LV_OBJ_FLAG_HIDDEN); +} + +void SettingsScreen::create_delivery_section(lv_obj_t* parent) { + create_section_header(parent, "== Delivery =="); + + // Propagation Nodes button row + lv_obj_t* prop_nodes_row = lv_obj_create(parent); + lv_obj_set_width(prop_nodes_row, LV_PCT(100)); + lv_obj_set_height(prop_nodes_row, 32); + lv_obj_set_style_bg_opa(prop_nodes_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(prop_nodes_row, 0, 0); + lv_obj_set_style_pad_all(prop_nodes_row, 0, 0); + lv_obj_clear_flag(prop_nodes_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* prop_label = lv_label_create(prop_nodes_row); + lv_label_set_text(prop_label, "Propagation Nodes:"); + lv_obj_align(prop_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(prop_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(prop_label, &lv_font_montserrat_14, 0); + + _btn_propagation_nodes = lv_btn_create(prop_nodes_row); + lv_obj_set_size(_btn_propagation_nodes, 70, 26); + lv_obj_align(_btn_propagation_nodes, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_btn_propagation_nodes, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_propagation_nodes, Theme::primaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_propagation_nodes, on_propagation_nodes_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* btn_label = lv_label_create(_btn_propagation_nodes); + lv_label_set_text(btn_label, "View"); + lv_obj_center(btn_label); + lv_obj_set_style_text_color(btn_label, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(btn_label, &lv_font_montserrat_14, 0); + + // Fallback to Propagation switch row + lv_obj_t* fallback_row = lv_obj_create(parent); + lv_obj_set_width(fallback_row, LV_PCT(100)); + lv_obj_set_height(fallback_row, 28); + lv_obj_set_style_bg_opa(fallback_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(fallback_row, 0, 0); + lv_obj_set_style_pad_all(fallback_row, 0, 0); + lv_obj_clear_flag(fallback_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* fallback_label = lv_label_create(fallback_row); + lv_label_set_text(fallback_label, "Fallback to Prop:"); + lv_obj_align(fallback_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(fallback_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(fallback_label, &lv_font_montserrat_14, 0); + + _switch_prop_fallback = lv_switch_create(fallback_row); + lv_obj_set_size(_switch_prop_fallback, 40, 20); + lv_obj_align(_switch_prop_fallback, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_prop_fallback, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_prop_fallback, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + + // Propagation Only switch row + lv_obj_t* prop_only_row = lv_obj_create(parent); + lv_obj_set_width(prop_only_row, LV_PCT(100)); + lv_obj_set_height(prop_only_row, 28); + lv_obj_set_style_bg_opa(prop_only_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(prop_only_row, 0, 0); + lv_obj_set_style_pad_all(prop_only_row, 0, 0); + lv_obj_clear_flag(prop_only_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* prop_only_label = lv_label_create(prop_only_row); + lv_label_set_text(prop_only_label, "Propagation Only:"); + lv_obj_align(prop_only_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(prop_only_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(prop_only_label, &lv_font_montserrat_14, 0); + + _switch_prop_only = lv_switch_create(prop_only_row); + lv_obj_set_size(_switch_prop_only, 40, 20); + lv_obj_align(_switch_prop_only, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_prop_only, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_prop_only, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); +} + +void SettingsScreen::create_gps_section(lv_obj_t* parent) { + create_section_header(parent, "== GPS Status =="); + + _label_gps_sats = create_label_row(parent, "Satellites: --"); + _label_gps_coords = create_label_row(parent, "Location: No fix"); + _label_gps_alt = create_label_row(parent, "Altitude: --"); + _label_gps_hdop = create_label_row(parent, "HDOP: --"); +} + +void SettingsScreen::create_system_section(lv_obj_t* parent) { + create_section_header(parent, "== System Info =="); + + _label_identity_hash = create_label_row(parent, "Identity: --"); + _label_lxmf_address = create_label_row(parent, "LXMF: --"); + _label_firmware = create_label_row(parent, "Firmware: v1.0.0"); + _label_storage = create_label_row(parent, "Storage: --"); + _label_ram = create_label_row(parent, "RAM: --"); +} + +void SettingsScreen::create_advanced_section(lv_obj_t* parent) { + create_section_header(parent, "== Advanced =="); + + // Announce interval row + lv_obj_t* announce_row = lv_obj_create(parent); + lv_obj_set_width(announce_row, LV_PCT(100)); + lv_obj_set_height(announce_row, 28); + lv_obj_set_style_bg_opa(announce_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(announce_row, 0, 0); + lv_obj_set_style_pad_all(announce_row, 0, 0); + lv_obj_clear_flag(announce_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* announce_label = lv_label_create(announce_row); + lv_label_set_text(announce_label, "Announce Interval:"); + lv_obj_align(announce_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(announce_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(announce_label, &lv_font_montserrat_14, 0); + + _ta_announce_interval = lv_textarea_create(announce_row); + lv_obj_set_size(_ta_announce_interval, 50, 24); + lv_obj_align(_ta_announce_interval, LV_ALIGN_RIGHT_MID, -30, 0); + lv_textarea_set_one_line(_ta_announce_interval, true); + lv_textarea_set_max_length(_ta_announce_interval, 5); + lv_textarea_set_accepted_chars(_ta_announce_interval, "0123456789"); + lv_obj_set_style_bg_color(_ta_announce_interval, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_ta_announce_interval, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_ta_announce_interval, Theme::border(), 0); + lv_obj_set_style_border_width(_ta_announce_interval, 1, 0); + lv_obj_set_style_radius(_ta_announce_interval, 4, 0); + lv_obj_set_style_pad_all(_ta_announce_interval, 4, 0); + lv_obj_set_style_text_font(_ta_announce_interval, &lv_font_montserrat_14, 0); + // Add to input group for keyboard navigation + lv_group_t* grp = LVGL::LVGLInit::get_default_group(); + if (grp) { + lv_group_add_obj(grp, _ta_announce_interval); + } + // Enable paste on long-press + TextAreaHelper::enable_paste(_ta_announce_interval); + + lv_obj_t* sec_label = lv_label_create(announce_row); + lv_label_set_text(sec_label, "sec"); + lv_obj_align(sec_label, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_text_color(sec_label, Theme::textMuted(), 0); + lv_obj_set_style_text_font(sec_label, &lv_font_montserrat_14, 0); + + // Sync interval row (for propagation node sync) + lv_obj_t* sync_row = lv_obj_create(parent); + lv_obj_set_width(sync_row, LV_PCT(100)); + lv_obj_set_height(sync_row, 28); + lv_obj_set_style_bg_opa(sync_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(sync_row, 0, 0); + lv_obj_set_style_pad_all(sync_row, 0, 0); + lv_obj_clear_flag(sync_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* sync_label = lv_label_create(sync_row); + lv_label_set_text(sync_label, "Prop Sync Interval:"); + lv_obj_align(sync_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(sync_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(sync_label, &lv_font_montserrat_14, 0); + + _ta_sync_interval = lv_textarea_create(sync_row); + lv_obj_set_size(_ta_sync_interval, 50, 24); + lv_obj_align(_ta_sync_interval, LV_ALIGN_RIGHT_MID, -30, 0); + lv_textarea_set_one_line(_ta_sync_interval, true); + lv_textarea_set_max_length(_ta_sync_interval, 5); + lv_textarea_set_accepted_chars(_ta_sync_interval, "0123456789"); + lv_obj_set_style_bg_color(_ta_sync_interval, Theme::surfaceInput(), 0); + lv_obj_set_style_text_color(_ta_sync_interval, Theme::textPrimary(), 0); + lv_obj_set_style_border_color(_ta_sync_interval, Theme::border(), 0); + lv_obj_set_style_border_width(_ta_sync_interval, 1, 0); + lv_obj_set_style_radius(_ta_sync_interval, 4, 0); + lv_obj_set_style_pad_all(_ta_sync_interval, 4, 0); + lv_obj_set_style_text_font(_ta_sync_interval, &lv_font_montserrat_14, 0); + if (grp) { + lv_group_add_obj(grp, _ta_sync_interval); + } + TextAreaHelper::enable_paste(_ta_sync_interval); + + lv_obj_t* min_label = lv_label_create(sync_row); + lv_label_set_text(min_label, "min"); + lv_obj_align(min_label, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_text_color(min_label, Theme::textMuted(), 0); + lv_obj_set_style_text_font(min_label, &lv_font_montserrat_14, 0); + + // GPS sync row + lv_obj_t* gps_sync_row = lv_obj_create(parent); + lv_obj_set_width(gps_sync_row, LV_PCT(100)); + lv_obj_set_height(gps_sync_row, 28); + lv_obj_set_style_bg_opa(gps_sync_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(gps_sync_row, 0, 0); + lv_obj_set_style_pad_all(gps_sync_row, 0, 0); + lv_obj_clear_flag(gps_sync_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* gps_sync_label = lv_label_create(gps_sync_row); + lv_label_set_text(gps_sync_label, "GPS Time Sync:"); + lv_obj_align(gps_sync_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(gps_sync_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(gps_sync_label, &lv_font_montserrat_14, 0); + + _switch_gps_sync = lv_switch_create(gps_sync_row); + lv_obj_set_size(_switch_gps_sync, 40, 20); + lv_obj_align(_switch_gps_sync, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_gps_sync, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_gps_sync, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); +} + +void SettingsScreen::load_settings() { + Preferences prefs; + prefs.begin(NVS_NAMESPACE, true); // read-only + + _settings.wifi_ssid = prefs.getString(KEY_WIFI_SSID, ""); + _settings.wifi_password = prefs.getString(KEY_WIFI_PASS, ""); + _settings.tcp_host = prefs.getString(KEY_TCP_HOST, "sideband.connect.reticulum.network"); + _settings.tcp_port = prefs.getUShort(KEY_TCP_PORT, 4965); + _settings.display_name = prefs.getString(KEY_DISPLAY_NAME, ""); + _settings.brightness = prefs.getUChar(KEY_BRIGHTNESS, 180); + _settings.keyboard_light = prefs.getBool(KEY_KB_LIGHT, false); + _settings.screen_timeout = prefs.getUShort(KEY_TIMEOUT, 60); + _settings.announce_interval = prefs.getUInt(KEY_ANNOUNCE_INT, 60); + _settings.sync_interval = prefs.getUInt(KEY_SYNC_INT, 3600); // Default 60 minutes + _settings.gps_time_sync = prefs.getBool(KEY_GPS_SYNC, true); + + // Notification settings + _settings.notification_sound = prefs.getBool(KEY_NOTIF_SND, true); + _settings.notification_volume = prefs.getUChar(KEY_NOTIF_VOL, 10); + + // Interface settings + _settings.tcp_enabled = prefs.getBool(KEY_TCP_ENABLED, true); + _settings.lora_enabled = prefs.getBool(KEY_LORA_ENABLED, false); + _settings.lora_frequency = prefs.getFloat(KEY_LORA_FREQ, 927.25f); + _settings.lora_bandwidth = prefs.getFloat(KEY_LORA_BW, 62.5f); + // Validate bandwidth - SX1262 valid values: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 + // If saved value is invalid (like 50 kHz), correct to nearest valid value + if (_settings.lora_bandwidth < 60.0f) { + _settings.lora_bandwidth = 62.5f; // 50 kHz -> 62.5 kHz + } + _settings.lora_sf = prefs.getUChar(KEY_LORA_SF, 7); + _settings.lora_cr = prefs.getUChar(KEY_LORA_CR, 5); + _settings.lora_power = prefs.getChar(KEY_LORA_POWER, 17); + _settings.auto_enabled = prefs.getBool(KEY_AUTO_ENABLED, false); + _settings.ble_enabled = prefs.getBool(KEY_BLE_ENABLED, false); + + // Propagation settings + _settings.prop_auto_select = prefs.getBool(KEY_PROP_AUTO, true); + _settings.prop_selected_node = prefs.getString(KEY_PROP_NODE, ""); + _settings.prop_fallback_enabled = prefs.getBool(KEY_PROP_FALLBACK, true); + _settings.prop_only = prefs.getBool(KEY_PROP_ONLY, false); + + prefs.end(); + + DEBUG("Settings loaded from NVS"); +} + +void SettingsScreen::save_settings() { + update_settings_from_ui(); + + Preferences prefs; + prefs.begin(NVS_NAMESPACE, false); // read-write + + prefs.putString(KEY_WIFI_SSID, _settings.wifi_ssid); + prefs.putString(KEY_WIFI_PASS, _settings.wifi_password); + prefs.putString(KEY_TCP_HOST, _settings.tcp_host); + prefs.putUShort(KEY_TCP_PORT, _settings.tcp_port); + prefs.putString(KEY_DISPLAY_NAME, _settings.display_name); + prefs.putUChar(KEY_BRIGHTNESS, _settings.brightness); + prefs.putBool(KEY_KB_LIGHT, _settings.keyboard_light); + prefs.putUShort(KEY_TIMEOUT, _settings.screen_timeout); + prefs.putUInt(KEY_ANNOUNCE_INT, _settings.announce_interval); + prefs.putUInt(KEY_SYNC_INT, _settings.sync_interval); + prefs.putBool(KEY_GPS_SYNC, _settings.gps_time_sync); + + // Notification settings + prefs.putBool(KEY_NOTIF_SND, _settings.notification_sound); + prefs.putUChar(KEY_NOTIF_VOL, _settings.notification_volume); + + // Interface settings + prefs.putBool(KEY_TCP_ENABLED, _settings.tcp_enabled); + prefs.putBool(KEY_LORA_ENABLED, _settings.lora_enabled); + prefs.putFloat(KEY_LORA_FREQ, _settings.lora_frequency); + prefs.putFloat(KEY_LORA_BW, _settings.lora_bandwidth); + prefs.putUChar(KEY_LORA_SF, _settings.lora_sf); + prefs.putUChar(KEY_LORA_CR, _settings.lora_cr); + prefs.putChar(KEY_LORA_POWER, _settings.lora_power); + prefs.putBool(KEY_AUTO_ENABLED, _settings.auto_enabled); + prefs.putBool(KEY_BLE_ENABLED, _settings.ble_enabled); + + // Propagation settings + prefs.putBool(KEY_PROP_AUTO, _settings.prop_auto_select); + prefs.putString(KEY_PROP_NODE, _settings.prop_selected_node); + prefs.putBool(KEY_PROP_FALLBACK, _settings.prop_fallback_enabled); + prefs.putBool(KEY_PROP_ONLY, _settings.prop_only); + + prefs.end(); + + INFO("Settings saved to NVS"); + + if (_save_callback) { + _save_callback(_settings); + } +} + +void SettingsScreen::update_ui_from_settings() { + LVGL_LOCK(); + if (_ta_wifi_ssid) { + lv_textarea_set_text(_ta_wifi_ssid, _settings.wifi_ssid.c_str()); + } + if (_ta_wifi_password) { + lv_textarea_set_text(_ta_wifi_password, _settings.wifi_password.c_str()); + } + if (_ta_tcp_host) { + lv_textarea_set_text(_ta_tcp_host, _settings.tcp_host.c_str()); + } + if (_ta_tcp_port) { + lv_textarea_set_text(_ta_tcp_port, String(_settings.tcp_port).c_str()); + } + if (_ta_display_name) { + lv_textarea_set_text(_ta_display_name, _settings.display_name.c_str()); + } + if (_slider_brightness) { + lv_slider_set_value(_slider_brightness, _settings.brightness, LV_ANIM_OFF); + if (_label_brightness_value) { + lv_label_set_text(_label_brightness_value, String(_settings.brightness).c_str()); + } + } + if (_switch_kb_light) { + if (_settings.keyboard_light) { + lv_obj_add_state(_switch_kb_light, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_kb_light, LV_STATE_CHECKED); + } + } + + // Notification settings + if (_switch_notification_sound) { + if (_settings.notification_sound) { + lv_obj_add_state(_switch_notification_sound, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_notification_sound, LV_STATE_CHECKED); + } + } + if (_slider_notification_volume) { + lv_slider_set_value(_slider_notification_volume, _settings.notification_volume, LV_ANIM_OFF); + if (_label_notification_volume_value) { + lv_label_set_text(_label_notification_volume_value, String(_settings.notification_volume).c_str()); + } + } + + if (_dropdown_timeout) { + // Map timeout to dropdown index + int idx = 1; // default 1 min + if (_settings.screen_timeout == 30) idx = 0; + else if (_settings.screen_timeout == 60) idx = 1; + else if (_settings.screen_timeout == 300) idx = 2; + else if (_settings.screen_timeout == 0) idx = 3; + lv_dropdown_set_selected(_dropdown_timeout, idx); + } + if (_ta_announce_interval) { + lv_textarea_set_text(_ta_announce_interval, String(_settings.announce_interval).c_str()); + } + if (_ta_sync_interval) { + // Display in minutes (stored in seconds) + lv_textarea_set_text(_ta_sync_interval, String(_settings.sync_interval / 60).c_str()); + } + if (_switch_gps_sync) { + if (_settings.gps_time_sync) { + lv_obj_add_state(_switch_gps_sync, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_gps_sync, LV_STATE_CHECKED); + } + } + + // Interface settings + if (_switch_tcp_enabled) { + if (_settings.tcp_enabled) { + lv_obj_add_state(_switch_tcp_enabled, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_tcp_enabled, LV_STATE_CHECKED); + } + } + if (_switch_lora_enabled) { + if (_settings.lora_enabled) { + lv_obj_add_state(_switch_lora_enabled, LV_STATE_CHECKED); + if (_lora_params_container) { + lv_obj_clear_flag(_lora_params_container, LV_OBJ_FLAG_HIDDEN); + } + } else { + lv_obj_clear_state(_switch_lora_enabled, LV_STATE_CHECKED); + if (_lora_params_container) { + lv_obj_add_flag(_lora_params_container, LV_OBJ_FLAG_HIDDEN); + } + } + } + if (_ta_lora_frequency) { + char freq_str[16]; + snprintf(freq_str, sizeof(freq_str), "%.2f", _settings.lora_frequency); + lv_textarea_set_text(_ta_lora_frequency, freq_str); + } + if (_dropdown_lora_bandwidth) { + // Map bandwidth to index: 62.5=0, 125=1, 250=2, 500=3 + int idx = 0; + if (_settings.lora_bandwidth < 100.0f) idx = 0; // 62.5 kHz + else if (_settings.lora_bandwidth < 200.0f) idx = 1; // 125 kHz + else if (_settings.lora_bandwidth < 400.0f) idx = 2; // 250 kHz + else idx = 3; // 500 kHz + lv_dropdown_set_selected(_dropdown_lora_bandwidth, idx); + } + if (_dropdown_lora_sf) { + // SF 7-12 maps to index 0-5 + lv_dropdown_set_selected(_dropdown_lora_sf, _settings.lora_sf - 7); + } + if (_dropdown_lora_cr) { + // CR 5-8 maps to index 0-3 + lv_dropdown_set_selected(_dropdown_lora_cr, _settings.lora_cr - 5); + } + if (_slider_lora_power) { + lv_slider_set_value(_slider_lora_power, _settings.lora_power, LV_ANIM_OFF); + if (_label_lora_power_value) { + char pwr_str[16]; + snprintf(pwr_str, sizeof(pwr_str), "%d dBm", _settings.lora_power); + lv_label_set_text(_label_lora_power_value, pwr_str); + } + } + if (_switch_auto_enabled) { + if (_settings.auto_enabled) { + lv_obj_add_state(_switch_auto_enabled, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_auto_enabled, LV_STATE_CHECKED); + } + } + if (_switch_ble_enabled) { + if (_settings.ble_enabled) { + lv_obj_add_state(_switch_ble_enabled, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_ble_enabled, LV_STATE_CHECKED); + } + } + + // Propagation settings + if (_switch_prop_fallback) { + if (_settings.prop_fallback_enabled) { + lv_obj_add_state(_switch_prop_fallback, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_prop_fallback, LV_STATE_CHECKED); + } + } + if (_switch_prop_only) { + if (_settings.prop_only) { + lv_obj_add_state(_switch_prop_only, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_prop_only, LV_STATE_CHECKED); + } + } +} + +void SettingsScreen::update_settings_from_ui() { + LVGL_LOCK(); + if (_ta_wifi_ssid) { + _settings.wifi_ssid = lv_textarea_get_text(_ta_wifi_ssid); + } + if (_ta_wifi_password) { + _settings.wifi_password = lv_textarea_get_text(_ta_wifi_password); + } + if (_ta_tcp_host) { + _settings.tcp_host = lv_textarea_get_text(_ta_tcp_host); + } + if (_ta_tcp_port) { + _settings.tcp_port = String(lv_textarea_get_text(_ta_tcp_port)).toInt(); + } + if (_ta_display_name) { + _settings.display_name = lv_textarea_get_text(_ta_display_name); + } + if (_slider_brightness) { + _settings.brightness = lv_slider_get_value(_slider_brightness); + } + if (_switch_kb_light) { + _settings.keyboard_light = lv_obj_has_state(_switch_kb_light, LV_STATE_CHECKED); + } + + // Notification settings + if (_switch_notification_sound) { + _settings.notification_sound = lv_obj_has_state(_switch_notification_sound, LV_STATE_CHECKED); + } + if (_slider_notification_volume) { + _settings.notification_volume = lv_slider_get_value(_slider_notification_volume); + } + + if (_dropdown_timeout) { + int idx = lv_dropdown_get_selected(_dropdown_timeout); + switch (idx) { + case 0: _settings.screen_timeout = 30; break; + case 1: _settings.screen_timeout = 60; break; + case 2: _settings.screen_timeout = 300; break; + case 3: _settings.screen_timeout = 0; break; + } + } + if (_ta_announce_interval) { + _settings.announce_interval = String(lv_textarea_get_text(_ta_announce_interval)).toInt(); + } + if (_ta_sync_interval) { + // UI shows minutes, store as seconds + _settings.sync_interval = String(lv_textarea_get_text(_ta_sync_interval)).toInt() * 60; + } + if (_switch_gps_sync) { + _settings.gps_time_sync = lv_obj_has_state(_switch_gps_sync, LV_STATE_CHECKED); + } + + // Interface settings + if (_switch_tcp_enabled) { + _settings.tcp_enabled = lv_obj_has_state(_switch_tcp_enabled, LV_STATE_CHECKED); + } + if (_switch_lora_enabled) { + _settings.lora_enabled = lv_obj_has_state(_switch_lora_enabled, LV_STATE_CHECKED); + } + if (_ta_lora_frequency) { + _settings.lora_frequency = String(lv_textarea_get_text(_ta_lora_frequency)).toFloat(); + } + if (_dropdown_lora_bandwidth) { + // Map index to bandwidth: 0=62.5, 1=125, 2=250, 3=500 + static const float bw_values[] = {62.5f, 125.0f, 250.0f, 500.0f}; + int idx = lv_dropdown_get_selected(_dropdown_lora_bandwidth); + if (idx >= 0 && idx < 4) { + _settings.lora_bandwidth = bw_values[idx]; + } + } + if (_dropdown_lora_sf) { + // Index 0-5 maps to SF 7-12 + _settings.lora_sf = lv_dropdown_get_selected(_dropdown_lora_sf) + 7; + } + if (_dropdown_lora_cr) { + // Index 0-3 maps to CR 5-8 + _settings.lora_cr = lv_dropdown_get_selected(_dropdown_lora_cr) + 5; + } + if (_slider_lora_power) { + _settings.lora_power = lv_slider_get_value(_slider_lora_power); + } + if (_switch_auto_enabled) { + _settings.auto_enabled = lv_obj_has_state(_switch_auto_enabled, LV_STATE_CHECKED); + } + if (_switch_ble_enabled) { + _settings.ble_enabled = lv_obj_has_state(_switch_ble_enabled, LV_STATE_CHECKED); + } + + // Propagation settings + if (_switch_prop_fallback) { + _settings.prop_fallback_enabled = lv_obj_has_state(_switch_prop_fallback, LV_STATE_CHECKED); + } + if (_switch_prop_only) { + _settings.prop_only = lv_obj_has_state(_switch_prop_only, LV_STATE_CHECKED); + } +} + +void SettingsScreen::update_gps_display() { + LVGL_LOCK(); + if (!_gps) { + lv_label_set_text(_label_gps_sats, "Satellites: N/A"); + lv_label_set_text(_label_gps_coords, "Location: GPS not available"); + lv_label_set_text(_label_gps_alt, "Altitude: --"); + lv_label_set_text(_label_gps_hdop, "HDOP: --"); + return; + } + + // Satellites + String sats = "Satellites: " + String(_gps->satellites.value()); + lv_label_set_text(_label_gps_sats, sats.c_str()); + + // Coordinates + if (_gps->location.isValid()) { + String coords = "Location: " + + String(_gps->location.lat(), 4) + ", " + + String(_gps->location.lng(), 4); + lv_label_set_text(_label_gps_coords, coords.c_str()); + lv_obj_set_style_text_color(_label_gps_coords, Theme::success(), 0); + } else { + lv_label_set_text(_label_gps_coords, "Location: No fix"); + lv_obj_set_style_text_color(_label_gps_coords, Theme::error(), 0); + } + + // Altitude + if (_gps->altitude.isValid()) { + String alt = "Altitude: " + String(_gps->altitude.meters(), 1) + "m"; + lv_label_set_text(_label_gps_alt, alt.c_str()); + } else { + lv_label_set_text(_label_gps_alt, "Altitude: --"); + } + + // HDOP (fix quality) + if (_gps->hdop.isValid()) { + double hdop = _gps->hdop.hdop() / 100.0; // TinyGPSPlus returns hdop * 100 + String quality; + if (hdop < 1.0) quality = "Ideal"; + else if (hdop < 2.0) quality = "Excellent"; + else if (hdop < 5.0) quality = "Good"; + else if (hdop < 10.0) quality = "Moderate"; + else quality = "Poor"; + + String hdop_str = "HDOP: " + String(hdop, 1) + " (" + quality + ")"; + lv_label_set_text(_label_gps_hdop, hdop_str.c_str()); + } else { + lv_label_set_text(_label_gps_hdop, "HDOP: --"); + } +} + +void SettingsScreen::update_system_info() { + LVGL_LOCK(); + // Identity hash + if (_identity_hash.size() > 0) { + String hash = "Identity: " + String(_identity_hash.toHex().substr(0, 16).c_str()) + "..."; + lv_label_set_text(_label_identity_hash, hash.c_str()); + } + + // LXMF address + if (_lxmf_address.size() > 0) { + String addr = "LXMF: " + String(_lxmf_address.toHex().substr(0, 16).c_str()) + "..."; + lv_label_set_text(_label_lxmf_address, addr.c_str()); + } + + // Storage + size_t total = SPIFFS.totalBytes(); + size_t used = SPIFFS.usedBytes(); + size_t free = total - used; + String storage = "Storage: " + String(free / 1024) + " KB free"; + lv_label_set_text(_label_storage, storage.c_str()); + + // RAM + size_t free_heap = ESP.getFreeHeap(); + String ram = "RAM: " + String(free_heap / 1024) + " KB free"; + lv_label_set_text(_label_ram, ram.c_str()); +} + +void SettingsScreen::set_identity_hash(const Bytes& hash) { + _identity_hash = hash; +} + +void SettingsScreen::set_lxmf_address(const Bytes& hash) { + _lxmf_address = hash; +} + +void SettingsScreen::set_gps(TinyGPSPlus* gps) { + _gps = gps; +} + +void SettingsScreen::refresh() { + update_gps_display(); + update_system_info(); +} + +void SettingsScreen::set_back_callback(BackCallback callback) { + _back_callback = callback; +} + +void SettingsScreen::set_save_callback(SaveCallback callback) { + _save_callback = callback; +} + +void SettingsScreen::set_wifi_reconnect_callback(WifiReconnectCallback callback) { + _wifi_reconnect_callback = callback; +} + +void SettingsScreen::set_brightness_change_callback(BrightnessChangeCallback callback) { + _brightness_change_callback = callback; +} + +void SettingsScreen::set_propagation_nodes_callback(PropagationNodesCallback callback) { + _propagation_nodes_callback = callback; +} + +void SettingsScreen::show() { + LVGL_LOCK(); + refresh(); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); + + // Add back/save buttons to focus group for trackball navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_add_obj(group, _btn_back); + if (_btn_save) lv_group_add_obj(group, _btn_save); + + // Focus on back button + if (_btn_back) { + lv_group_focus_obj(_btn_back); + } + } +} + +void SettingsScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) lv_group_remove_obj(_btn_back); + if (_btn_save) lv_group_remove_obj(_btn_save); + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* SettingsScreen::get_object() { + return _screen; +} + +void SettingsScreen::on_back_clicked(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + if (screen->_back_callback) { + screen->_back_callback(); + } +} + +void SettingsScreen::on_save_clicked(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + screen->save_settings(); +} + +void SettingsScreen::on_reconnect_clicked(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + + const char* ssid = lv_textarea_get_text(screen->_ta_wifi_ssid); + const char* pass = lv_textarea_get_text(screen->_ta_wifi_password); + + if (screen->_wifi_reconnect_callback) { + screen->_wifi_reconnect_callback(String(ssid), String(pass)); + } +} + +void SettingsScreen::on_brightness_changed(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + uint8_t brightness = lv_slider_get_value(screen->_slider_brightness); + + // Update label + lv_label_set_text(screen->_label_brightness_value, String(brightness).c_str()); + + // Apply immediately + if (screen->_brightness_change_callback) { + screen->_brightness_change_callback(brightness); + } +} + +void SettingsScreen::on_lora_enabled_changed(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + bool enabled = lv_obj_has_state(screen->_switch_lora_enabled, LV_STATE_CHECKED); + + // Show/hide LoRa parameters + if (screen->_lora_params_container) { + if (enabled) { + lv_obj_clear_flag(screen->_lora_params_container, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(screen->_lora_params_container, LV_OBJ_FLAG_HIDDEN); + } + } +} + +void SettingsScreen::on_lora_power_changed(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + int8_t power = lv_slider_get_value(screen->_slider_lora_power); + + // Update label + char pwr_str[16]; + snprintf(pwr_str, sizeof(pwr_str), "%d dBm", power); + lv_label_set_text(screen->_label_lora_power_value, pwr_str); +} + +void SettingsScreen::on_notification_volume_changed(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + uint8_t volume = lv_slider_get_value(screen->_slider_notification_volume); + + // Update label + lv_label_set_text(screen->_label_notification_volume_value, String(volume).c_str()); +} + +void SettingsScreen::on_propagation_nodes_clicked(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + if (screen->_propagation_nodes_callback) { + screen->_propagation_nodes_callback(); + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/SettingsScreen.h b/lib/tdeck_ui/UI/LXMF/SettingsScreen.h new file mode 100644 index 0000000..52517ab --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/SettingsScreen.h @@ -0,0 +1,347 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_SETTINGSSCREEN_H +#define UI_LXMF_SETTINGSSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include +#include "Bytes.h" +#include "Identity.h" + +// Forward declaration +class TinyGPSPlus; + +namespace UI { +namespace LXMF { + +/** + * Application settings structure + */ +struct AppSettings { + // Network + String wifi_ssid; + String wifi_password; + String tcp_host; + uint16_t tcp_port; + + // Identity + String display_name; + + // Display + uint8_t brightness; + uint16_t screen_timeout; // seconds, 0 = never + bool keyboard_light; // Enable keyboard backlight on keypress + + // Notifications + bool notification_sound; // Play sound on message received + uint8_t notification_volume; // Volume 0-100 + + // Interfaces + bool tcp_enabled; + bool lora_enabled; + float lora_frequency; // MHz + float lora_bandwidth; // kHz + uint8_t lora_sf; // Spreading factor (7-12) + uint8_t lora_cr; // Coding rate (5-8) + int8_t lora_power; // TX power dBm (2-22) + bool auto_enabled; // Enable AutoInterface (WiFi peer discovery) + bool ble_enabled; // Enable BLE mesh interface + + // Advanced + uint32_t announce_interval; // seconds + uint32_t sync_interval; // seconds (0 = disabled, default 3600 = hourly) + bool gps_time_sync; + + // Propagation + bool prop_auto_select; // Auto-select best propagation node + String prop_selected_node; // Hex string of selected node hash + bool prop_fallback_enabled; // Fall back to propagation on direct failure + bool prop_only; // Only send via propagation (no direct/opportunistic) + + // Defaults + AppSettings() : + tcp_host("sideband.connect.reticulum.network"), + tcp_port(4965), + brightness(180), + screen_timeout(60), + keyboard_light(false), + notification_sound(true), + notification_volume(10), + tcp_enabled(true), + lora_enabled(false), + lora_frequency(927.25f), + lora_bandwidth(62.5f), + lora_sf(7), + lora_cr(5), + lora_power(17), + auto_enabled(false), + ble_enabled(false), + announce_interval(60), + sync_interval(3600), + gps_time_sync(true), + prop_auto_select(true), + prop_selected_node(""), + prop_fallback_enabled(true), + prop_only(false) + {} +}; + +/** + * Settings Screen + * + * Allows configuration of WiFi, TCP server, display, and other settings. + * Also shows GPS status and system info. + * + * Layout: + * +---------------------------------------+ + * | [<] Settings [Save] | 36px + * +---------------------------------------+ + * | == Network == | + * | WiFi SSID: [__________________] | + * | Password: [******************] | + * | TCP Server: [_________________] | + * | TCP Port: [____] [Reconnect] | + * | | + * | == Identity == | + * | Display Name: [_______________] | + * | | + * | == Display == | + * | Brightness: [=======o------] 180 | + * | Timeout: [1 min v] | + * | | + * | == GPS Status == | + * | Satellites: 8 | + * | Location: 40.7128, -74.0060 | + * | Altitude: 10.5m | + * | HDOP: 1.2 (Excellent) | + * | | + * | == System Info == | + * | Identity: a1b2c3d4e5f6... | + * | LXMF: f7e8d9c0b1a2... | + * | Firmware: v1.0.0 | + * | Storage: 1.2 MB free | + * | RAM: 145 KB free | + * | | + * | == Advanced == | + * | Announce: [60] seconds | + * | GPS Sync: [ON] | + * +---------------------------------------+ + */ +class SettingsScreen { +public: + // Callback types + using BackCallback = std::function; + using SaveCallback = std::function; + using WifiReconnectCallback = std::function; + using BrightnessChangeCallback = std::function; + using PropagationNodesCallback = std::function; + + /** + * Create settings screen + * @param parent Parent LVGL object + */ + SettingsScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~SettingsScreen(); + + /** + * Load settings from NVS + */ + void load_settings(); + + /** + * Save settings to NVS + */ + void save_settings(); + + /** + * Get current settings + */ + const AppSettings& get_settings() const { return _settings; } + + /** + * Set identity hash for display + */ + void set_identity_hash(const RNS::Bytes& hash); + + /** + * Set LXMF delivery address hash + */ + void set_lxmf_address(const RNS::Bytes& hash); + + /** + * Set GPS pointer for status display + */ + void set_gps(TinyGPSPlus* gps); + + /** + * Refresh GPS and system info displays + */ + void refresh(); + + /** + * Set callback for back button + */ + void set_back_callback(BackCallback callback); + + /** + * Set callback for save button + */ + void set_save_callback(SaveCallback callback); + + /** + * Set callback for WiFi reconnect button + */ + void set_wifi_reconnect_callback(WifiReconnectCallback callback); + + /** + * Set callback for brightness changes (immediate) + */ + void set_brightness_change_callback(BrightnessChangeCallback callback); + + /** + * Set callback for propagation nodes button + */ + void set_propagation_nodes_callback(PropagationNodesCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + */ + lv_obj_t* get_object(); + +private: + // Main UI components + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _content; + lv_obj_t* _btn_back; + lv_obj_t* _btn_save; + + // Network section inputs + lv_obj_t* _ta_wifi_ssid; + lv_obj_t* _ta_wifi_password; + lv_obj_t* _ta_tcp_host; + lv_obj_t* _ta_tcp_port; + lv_obj_t* _btn_reconnect; + + // Identity section + lv_obj_t* _ta_display_name; + + // Display section + lv_obj_t* _slider_brightness; + lv_obj_t* _label_brightness_value; + lv_obj_t* _switch_kb_light; + lv_obj_t* _dropdown_timeout; + + // Notifications section + lv_obj_t* _switch_notification_sound; + lv_obj_t* _slider_notification_volume; + lv_obj_t* _label_notification_volume_value; + + // GPS status labels (read-only) + lv_obj_t* _label_gps_sats; + lv_obj_t* _label_gps_coords; + lv_obj_t* _label_gps_alt; + lv_obj_t* _label_gps_hdop; + + // System info labels (read-only) + lv_obj_t* _label_identity_hash; + lv_obj_t* _label_lxmf_address; + lv_obj_t* _label_firmware; + lv_obj_t* _label_storage; + lv_obj_t* _label_ram; + + // Interfaces section + lv_obj_t* _switch_tcp_enabled; + lv_obj_t* _switch_lora_enabled; + lv_obj_t* _ta_lora_frequency; + lv_obj_t* _dropdown_lora_bandwidth; + lv_obj_t* _dropdown_lora_sf; + lv_obj_t* _dropdown_lora_cr; + lv_obj_t* _slider_lora_power; + lv_obj_t* _label_lora_power_value; + lv_obj_t* _lora_params_container; // Container for LoRa params (shown/hidden based on enabled) + lv_obj_t* _switch_auto_enabled; + lv_obj_t* _switch_ble_enabled; + + // Advanced section + lv_obj_t* _ta_announce_interval; + lv_obj_t* _ta_sync_interval; + lv_obj_t* _switch_gps_sync; + + // Delivery/Propagation section + lv_obj_t* _btn_propagation_nodes; + lv_obj_t* _switch_prop_fallback; + lv_obj_t* _switch_prop_only; + + // Data + AppSettings _settings; + RNS::Bytes _identity_hash; + RNS::Bytes _lxmf_address; + TinyGPSPlus* _gps; + + // Callbacks + BackCallback _back_callback; + SaveCallback _save_callback; + WifiReconnectCallback _wifi_reconnect_callback; + BrightnessChangeCallback _brightness_change_callback; + PropagationNodesCallback _propagation_nodes_callback; + + // UI construction + void create_header(); + void create_content(); + void create_network_section(lv_obj_t* parent); + void create_identity_section(lv_obj_t* parent); + void create_display_section(lv_obj_t* parent); + void create_notifications_section(lv_obj_t* parent); + void create_interfaces_section(lv_obj_t* parent); + void create_gps_section(lv_obj_t* parent); + void create_system_section(lv_obj_t* parent); + void create_advanced_section(lv_obj_t* parent); + void create_delivery_section(lv_obj_t* parent); + + // Helpers + lv_obj_t* create_section_header(lv_obj_t* parent, const char* title); + lv_obj_t* create_label_row(lv_obj_t* parent, const char* label); + lv_obj_t* create_text_input(lv_obj_t* parent, const char* placeholder, + bool password = false, int max_len = 64); + + // Update UI from settings + void update_ui_from_settings(); + void update_settings_from_ui(); + void update_gps_display(); + void update_system_info(); + + // Event handlers + static void on_back_clicked(lv_event_t* event); + static void on_save_clicked(lv_event_t* event); + static void on_reconnect_clicked(lv_event_t* event); + static void on_brightness_changed(lv_event_t* event); + static void on_lora_enabled_changed(lv_event_t* event); + static void on_lora_power_changed(lv_event_t* event); + static void on_propagation_nodes_clicked(lv_event_t* event); + static void on_notification_volume_changed(lv_event_t* event); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_SETTINGSSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/StatusScreen.cpp b/lib/tdeck_ui/UI/LXMF/StatusScreen.cpp new file mode 100644 index 0000000..0d62bbd --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/StatusScreen.cpp @@ -0,0 +1,365 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "StatusScreen.h" +#include "Theme.h" + +#ifdef ARDUINO + +#include +#include "Log.h" +#include "../LVGL/LVGLInit.h" +#include "../LVGL/LVGLLock.h" + +using namespace RNS; + +namespace UI { +namespace LXMF { + +StatusScreen::StatusScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _content(nullptr), _btn_back(nullptr), + _btn_share(nullptr), _label_identity_value(nullptr), _label_lxmf_value(nullptr), + _label_wifi_status(nullptr), _label_wifi_ip(nullptr), _label_wifi_rssi(nullptr), + _label_rns_status(nullptr), _label_ble_header(nullptr), + _rns_connected(false), _ble_peer_count(0) { + // Initialize BLE peer labels array + for (size_t i = 0; i < MAX_BLE_PEERS; i++) { + _label_ble_peers[i] = nullptr; + } + + // Create screen object + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + // Create UI components + create_header(); + create_content(); + + // Hide by default + hide(); + + TRACE("StatusScreen created"); +} + +StatusScreen::~StatusScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void StatusScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + _btn_back = lv_btn_create(_header); + lv_obj_set_size(_btn_back, 50, 28); + lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(_btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0); + + // Title + lv_obj_t* title = lv_label_create(_header); + lv_label_set_text(title, "Status"); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 60, 0); + lv_obj_set_style_text_color(title, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + + // Share button (QR code icon) on the right + _btn_share = lv_btn_create(_header); + lv_obj_set_size(_btn_share, 60, 28); + lv_obj_align(_btn_share, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_set_style_bg_color(_btn_share, Theme::primary(), 0); + lv_obj_set_style_bg_color(_btn_share, Theme::primaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_share, on_share_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_share = lv_label_create(_btn_share); + lv_label_set_text(label_share, "Share"); + lv_obj_center(label_share); + lv_obj_set_style_text_color(label_share, Theme::textPrimary(), 0); +} + +void StatusScreen::create_content() { + _content = lv_obj_create(_screen); + lv_obj_set_size(_content, LV_PCT(100), 204); // 240 - 36 header + lv_obj_align(_content, LV_ALIGN_TOP_MID, 0, 36); + lv_obj_set_style_pad_all(_content, 8, 0); + lv_obj_set_style_bg_color(_content, Theme::surface(), 0); + lv_obj_set_style_border_width(_content, 0, 0); + lv_obj_set_style_radius(_content, 0, 0); + + // Enable vertical scrolling with flex layout + lv_obj_set_flex_flow(_content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_content, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_scroll_dir(_content, LV_DIR_VER); + lv_obj_set_scrollbar_mode(_content, LV_SCROLLBAR_MODE_AUTO); + + // Identity section + lv_obj_t* label_identity = lv_label_create(_content); + lv_label_set_text(label_identity, "Identity:"); + lv_obj_set_style_text_color(label_identity, Theme::textMuted(), 0); + + _label_identity_value = lv_label_create(_content); + lv_label_set_text(_label_identity_value, "Loading..."); + lv_obj_set_style_text_color(_label_identity_value, Theme::info(), 0); + lv_obj_set_style_text_font(_label_identity_value, &lv_font_montserrat_12, 0); + lv_obj_set_style_pad_left(_label_identity_value, 8, 0); + lv_obj_set_style_pad_bottom(_label_identity_value, 8, 0); + + // LXMF Address section + lv_obj_t* label_lxmf = lv_label_create(_content); + lv_label_set_text(label_lxmf, "LXMF Address:"); + lv_obj_set_style_text_color(label_lxmf, Theme::textMuted(), 0); + + _label_lxmf_value = lv_label_create(_content); + lv_label_set_text(_label_lxmf_value, "Loading..."); + lv_obj_set_style_text_color(_label_lxmf_value, Theme::success(), 0); + lv_obj_set_style_text_font(_label_lxmf_value, &lv_font_montserrat_12, 0); + lv_obj_set_style_pad_left(_label_lxmf_value, 8, 0); + lv_obj_set_style_pad_bottom(_label_lxmf_value, 8, 0); + + // WiFi section + _label_wifi_status = lv_label_create(_content); + lv_label_set_text(_label_wifi_status, "WiFi: Checking..."); + lv_obj_set_style_text_color(_label_wifi_status, Theme::textPrimary(), 0); + + _label_wifi_ip = lv_label_create(_content); + lv_label_set_text(_label_wifi_ip, ""); + lv_obj_set_style_text_color(_label_wifi_ip, Theme::textTertiary(), 0); + lv_obj_set_style_pad_left(_label_wifi_ip, 8, 0); + + _label_wifi_rssi = lv_label_create(_content); + lv_label_set_text(_label_wifi_rssi, ""); + lv_obj_set_style_text_color(_label_wifi_rssi, Theme::textTertiary(), 0); + lv_obj_set_style_pad_left(_label_wifi_rssi, 8, 0); + lv_obj_set_style_pad_bottom(_label_wifi_rssi, 8, 0); + + // RNS section + _label_rns_status = lv_label_create(_content); + lv_label_set_text(_label_rns_status, "RNS: Checking..."); + lv_obj_set_style_text_color(_label_rns_status, Theme::textPrimary(), 0); + lv_obj_set_width(_label_rns_status, lv_pct(100)); + lv_label_set_long_mode(_label_rns_status, LV_LABEL_LONG_WRAP); + lv_obj_set_style_pad_bottom(_label_rns_status, 8, 0); + + // BLE section header + _label_ble_header = lv_label_create(_content); + lv_label_set_text(_label_ble_header, "BLE: No peers"); + lv_obj_set_style_text_color(_label_ble_header, Theme::textPrimary(), 0); + + // Pre-create BLE peer labels (hidden until needed) + for (size_t i = 0; i < MAX_BLE_PEERS; i++) { + _label_ble_peers[i] = lv_label_create(_content); + lv_label_set_text(_label_ble_peers[i], ""); + lv_obj_set_style_text_color(_label_ble_peers[i], Theme::textTertiary(), 0); + lv_obj_set_style_text_font(_label_ble_peers[i], &lv_font_montserrat_12, 0); + lv_obj_set_style_pad_left(_label_ble_peers[i], 8, 0); + lv_obj_add_flag(_label_ble_peers[i], LV_OBJ_FLAG_HIDDEN); + } +} + +void StatusScreen::set_identity_hash(const Bytes& hash) { + LVGL_LOCK(); + _identity_hash = hash; + update_labels(); +} + +void StatusScreen::set_lxmf_address(const Bytes& hash) { + LVGL_LOCK(); + _lxmf_address = hash; + update_labels(); +} + +void StatusScreen::set_rns_status(bool connected, const String& server_name) { + LVGL_LOCK(); + _rns_connected = connected; + _rns_server = server_name; + update_labels(); +} + +void StatusScreen::set_ble_info(const BLEPeerInfo* peers, size_t count) { + LVGL_LOCK(); + // Copy peer data to internal storage + _ble_peer_count = (count <= MAX_BLE_PEERS) ? count : MAX_BLE_PEERS; + for (size_t i = 0; i < _ble_peer_count; i++) { + memcpy(&_ble_peers[i], &peers[i], sizeof(BLEPeerInfo)); + } + update_labels(); +} + +void StatusScreen::refresh() { + LVGL_LOCK(); + update_labels(); +} + +void StatusScreen::update_labels() { + // Update identity - use stack buffer to avoid String fragmentation + if (_identity_hash.size() > 0) { + lv_label_set_text(_label_identity_value, _identity_hash.toHex().c_str()); + } + + // Update LXMF address - use stack buffer to avoid String fragmentation + if (_lxmf_address.size() > 0) { + lv_label_set_text(_label_lxmf_value, _lxmf_address.toHex().c_str()); + } + + // Update WiFi status - use snprintf to avoid String concatenation + if (WiFi.status() == WL_CONNECTED) { + lv_label_set_text(_label_wifi_status, "WiFi: Connected"); + lv_obj_set_style_text_color(_label_wifi_status, Theme::success(), 0); + + char ip_text[32]; + snprintf(ip_text, sizeof(ip_text), "IP: %s", WiFi.localIP().toString().c_str()); + lv_label_set_text(_label_wifi_ip, ip_text); + + char rssi_text[24]; + snprintf(rssi_text, sizeof(rssi_text), "RSSI: %d dBm", WiFi.RSSI()); + lv_label_set_text(_label_wifi_rssi, rssi_text); + } else { + lv_label_set_text(_label_wifi_status, "WiFi: Disconnected"); + lv_obj_set_style_text_color(_label_wifi_status, Theme::error(), 0); + lv_label_set_text(_label_wifi_ip, ""); + lv_label_set_text(_label_wifi_rssi, ""); + } + + // Update RNS status - use snprintf to avoid String concatenation + if (_rns_connected) { + char rns_text[80]; + if (_rns_server.length() > 0) { + snprintf(rns_text, sizeof(rns_text), "RNS: Connected (%s)", _rns_server.c_str()); + } else { + snprintf(rns_text, sizeof(rns_text), "RNS: Connected"); + } + lv_label_set_text(_label_rns_status, rns_text); + lv_obj_set_style_text_color(_label_rns_status, Theme::success(), 0); + } else { + lv_label_set_text(_label_rns_status, "RNS: Disconnected"); + lv_obj_set_style_text_color(_label_rns_status, Theme::error(), 0); + } + + // Update BLE peer info + if (_ble_peer_count > 0) { + char ble_header[32]; + snprintf(ble_header, sizeof(ble_header), "BLE: %zu peer%s", + _ble_peer_count, _ble_peer_count == 1 ? "" : "s"); + lv_label_set_text(_label_ble_header, ble_header); + lv_obj_set_style_text_color(_label_ble_header, Theme::success(), 0); + + for (size_t i = 0; i < MAX_BLE_PEERS; i++) { + if (i < _ble_peer_count) { + // Format: "a1b2c3d4e5f6 -45 dBm\nAA:BB:CC:DD:EE:FF" + char peer_text[64]; + if (_ble_peers[i].identity[0] != '\0') { + snprintf(peer_text, sizeof(peer_text), "%s %d dBm\n%s", + _ble_peers[i].identity, _ble_peers[i].rssi, _ble_peers[i].mac); + } else { + snprintf(peer_text, sizeof(peer_text), "(no identity) %d dBm\n%s", + _ble_peers[i].rssi, _ble_peers[i].mac); + } + lv_label_set_text(_label_ble_peers[i], peer_text); + lv_obj_clear_flag(_label_ble_peers[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(_label_ble_peers[i], LV_OBJ_FLAG_HIDDEN); + } + } + } else { + lv_label_set_text(_label_ble_header, "BLE: No peers"); + lv_obj_set_style_text_color(_label_ble_header, Theme::textMuted(), 0); + + // Hide all peer labels + for (size_t i = 0; i < MAX_BLE_PEERS; i++) { + lv_obj_add_flag(_label_ble_peers[i], LV_OBJ_FLAG_HIDDEN); + } + } +} + +void StatusScreen::set_back_callback(BackCallback callback) { + _back_callback = callback; +} + +void StatusScreen::set_share_callback(ShareCallback callback) { + _share_callback = callback; +} + +void StatusScreen::show() { + LVGL_LOCK(); + refresh(); // Update status when shown + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); + + // Add buttons to focus group for trackball navigation + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) { + lv_group_add_obj(group, _btn_back); + } + if (_btn_share) { + lv_group_add_obj(group, _btn_share); + } + lv_group_focus_obj(_btn_back); + } +} + +void StatusScreen::hide() { + LVGL_LOCK(); + // Remove from focus group when hiding + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_back) { + lv_group_remove_obj(_btn_back); + } + if (_btn_share) { + lv_group_remove_obj(_btn_share); + } + } + + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t* StatusScreen::get_object() { + return _screen; +} + +void StatusScreen::on_back_clicked(lv_event_t* event) { + StatusScreen* screen = (StatusScreen*)lv_event_get_user_data(event); + + if (screen->_back_callback) { + screen->_back_callback(); + } +} + +void StatusScreen::on_share_clicked(lv_event_t* event) { + StatusScreen* screen = (StatusScreen*)lv_event_get_user_data(event); + + if (screen->_share_callback) { + screen->_share_callback(); + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/StatusScreen.h b/lib/tdeck_ui/UI/LXMF/StatusScreen.h new file mode 100644 index 0000000..082ada0 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/StatusScreen.h @@ -0,0 +1,171 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_STATUSSCREEN_H +#define UI_LXMF_STATUSSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include +#include "Bytes.h" +#include "Identity.h" + +namespace UI { +namespace LXMF { + +/** + * Status Screen + * + * Shows network and identity information: + * - Identity hash + * - LXMF delivery destination hash + * - WiFi status and IP + * - RNS connection status + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ ← Status │ 32px header + * ├─────────────────────────────────────┤ + * │ Identity: │ + * │ a1b2c3d4e5f6... │ + * │ │ + * │ LXMF Address: │ + * │ f7e8d9c0b1a2... │ + * │ │ + * │ WiFi: Connected │ + * │ IP: 192.168.1.100 │ + * │ RSSI: -65 dBm │ + * │ │ + * │ RNS: Connected │ + * └─────────────────────────────────────┘ + */ +class StatusScreen { +public: + using BackCallback = std::function; + using ShareCallback = std::function; + + /** + * Create status screen + * @param parent Parent LVGL object + */ + StatusScreen(lv_obj_t* parent = nullptr); + + /** + * Destructor + */ + ~StatusScreen(); + + /** + * Set identity hash to display + * @param hash The identity hash + */ + void set_identity_hash(const RNS::Bytes& hash); + + /** + * Set LXMF delivery destination hash + * @param hash The delivery destination hash + */ + void set_lxmf_address(const RNS::Bytes& hash); + + /** + * Set RNS connection status + * @param connected Whether connected to RNS server + * @param server_name Server hostname + */ + void set_rns_status(bool connected, const String& server_name = ""); + + /** + * BLE peer summary for display (matches BLEInterface::PeerSummary) + */ + struct BLEPeerInfo { + char identity[14]; // First 12 hex chars + null + char mac[18]; // "AA:BB:CC:DD:EE:FF" format + int8_t rssi; + }; + static constexpr size_t MAX_BLE_PEERS = 8; + + /** + * Set BLE peer info for display + * @param peers Array of peer summaries + * @param count Number of peers + */ + void set_ble_info(const BLEPeerInfo* peers, size_t count); + + /** + * Refresh WiFi and connection status + */ + void refresh(); + + /** + * Set callback for back button + * @param callback Function to call when back is pressed + */ + void set_back_callback(BackCallback callback); + + /** + * Set callback for share button + * @param callback Function to call when share is pressed + */ + void set_share_callback(ShareCallback callback); + + /** + * Show the screen + */ + void show(); + + /** + * Hide the screen + */ + void hide(); + + /** + * Get the root LVGL object + */ + lv_obj_t* get_object(); + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _content; + lv_obj_t* _btn_back; + lv_obj_t* _btn_share; + + // Labels for dynamic content + lv_obj_t* _label_identity_value; + lv_obj_t* _label_lxmf_value; + lv_obj_t* _label_wifi_status; + lv_obj_t* _label_wifi_ip; + lv_obj_t* _label_wifi_rssi; + lv_obj_t* _label_rns_status; + + // BLE peer labels (pre-allocated, hidden when unused) + lv_obj_t* _label_ble_header; + lv_obj_t* _label_ble_peers[MAX_BLE_PEERS]; // Each shows identity + rssi + mac + + RNS::Bytes _identity_hash; + RNS::Bytes _lxmf_address; + bool _rns_connected; + String _rns_server; + + // BLE peer data (cached for display) + BLEPeerInfo _ble_peers[MAX_BLE_PEERS]; + size_t _ble_peer_count; + + BackCallback _back_callback; + ShareCallback _share_callback; + + void create_header(); + void create_content(); + void update_labels(); + + static void on_back_clicked(lv_event_t* event); + static void on_share_clicked(lv_event_t* event); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_STATUSSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/Theme.h b/lib/tdeck_ui/UI/LXMF/Theme.h new file mode 100644 index 0000000..97c74ae --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/Theme.h @@ -0,0 +1,69 @@ +#pragma once + +#include + +namespace UI::LXMF::Theme { + +// Primary accent color (Columba Vibrant theme) +constexpr uint32_t PRIMARY = 0xB844C8; // Vibrant magenta-purple (buttons, sent bubbles) +constexpr uint32_t PRIMARY_PRESSED = 0x9C27B0; // Darker purple when pressed +constexpr uint32_t PRIMARY_LIGHT = 0xE8B4F0; // Light lavender (Purple80 from Vibrant) + +// Surface colors (purple-tinted like Vibrant theme) +constexpr uint32_t SURFACE = 0x1D1A1E; // Very dark purple-gray background +constexpr uint32_t SURFACE_HEADER = 0x2D2832; // Dark purple-gray header +constexpr uint32_t SURFACE_CONTAINER = 0x3D3344; // Purple-tinted list items +constexpr uint32_t SURFACE_ELEVATED = 0x4A454F; // VibrantSurface80 - pressed/focused +constexpr uint32_t SURFACE_INPUT = 0x332D41; // Dark mauve input fields + +// Button colors (purple-tinted) +constexpr uint32_t BTN_SECONDARY = 0x3D3344; // Purple-gray button +constexpr uint32_t BTN_SECONDARY_PRESSED = 0x4F2B54; // VibrantContainer80 + +// Border colors +constexpr uint32_t BORDER = 0x4A454F; // Purple-gray border +constexpr uint32_t BORDER_FOCUS = PRIMARY_LIGHT; // Light lavender focus + +// Text colors (Vibrant theme palette) +constexpr uint32_t TEXT_PRIMARY = 0xE8B4F0; // Light lavender (Purple80) +constexpr uint32_t TEXT_SECONDARY = 0xD4C0DC; // PurpleGrey80 +constexpr uint32_t TEXT_TERTIARY = 0xADA6B0; // VibrantOutline80 +constexpr uint32_t TEXT_MUTED = 0x7A7580; // Muted purple-gray + +// Status colors +constexpr uint32_t SUCCESS = 0x4CAF50; // Green +constexpr uint32_t SUCCESS_DARK = 0x2e7d32; // Dark green (buttons) +constexpr uint32_t SUCCESS_PRESSED = 0x388e3c; +constexpr uint32_t WARNING = 0xFFEB3B; // Yellow +constexpr uint32_t ERROR = 0xF44336; // Red +constexpr uint32_t INFO = 0xE8B4F0; // Light lavender (matches Vibrant) +constexpr uint32_t CHARGING = 0xFFB3C6; // Pink80 (coral-pink from Vibrant) +constexpr uint32_t BLUETOOTH = 0x2196F3; // Blue (keep Bluetooth icon blue) + +// Convenience functions +inline lv_color_t primary() { return lv_color_hex(PRIMARY); } +inline lv_color_t primaryPressed() { return lv_color_hex(PRIMARY_PRESSED); } +inline lv_color_t primaryLight() { return lv_color_hex(PRIMARY_LIGHT); } +inline lv_color_t surface() { return lv_color_hex(SURFACE); } +inline lv_color_t surfaceHeader() { return lv_color_hex(SURFACE_HEADER); } +inline lv_color_t surfaceContainer() { return lv_color_hex(SURFACE_CONTAINER); } +inline lv_color_t surfaceElevated() { return lv_color_hex(SURFACE_ELEVATED); } +inline lv_color_t surfaceInput() { return lv_color_hex(SURFACE_INPUT); } +inline lv_color_t btnSecondary() { return lv_color_hex(BTN_SECONDARY); } +inline lv_color_t btnSecondaryPressed() { return lv_color_hex(BTN_SECONDARY_PRESSED); } +inline lv_color_t border() { return lv_color_hex(BORDER); } +inline lv_color_t borderFocus() { return lv_color_hex(BORDER_FOCUS); } +inline lv_color_t textPrimary() { return lv_color_hex(TEXT_PRIMARY); } +inline lv_color_t textSecondary() { return lv_color_hex(TEXT_SECONDARY); } +inline lv_color_t textTertiary() { return lv_color_hex(TEXT_TERTIARY); } +inline lv_color_t textMuted() { return lv_color_hex(TEXT_MUTED); } +inline lv_color_t success() { return lv_color_hex(SUCCESS); } +inline lv_color_t successDark() { return lv_color_hex(SUCCESS_DARK); } +inline lv_color_t successPressed() { return lv_color_hex(SUCCESS_PRESSED); } +inline lv_color_t warning() { return lv_color_hex(WARNING); } +inline lv_color_t error() { return lv_color_hex(ERROR); } +inline lv_color_t info() { return lv_color_hex(INFO); } +inline lv_color_t charging() { return lv_color_hex(CHARGING); } +inline lv_color_t bluetooth() { return lv_color_hex(BLUETOOTH); } + +} // namespace UI::LXMF::Theme diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp new file mode 100644 index 0000000..a6d7f77 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -0,0 +1,651 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "UIManager.h" + +#ifdef ARDUINO + +#include +#include +#include "Log.h" +#include "tone/Tone.h" +#include "../LVGL/LVGLLock.h" + +using namespace RNS; + +// NVS keys for propagation settings +static const char* NVS_NAMESPACE = "propagation"; +static const char* KEY_AUTO_SELECT = "auto_select"; +static const char* KEY_NODE_HASH = "node_hash"; + +namespace UI { +namespace LXMF { + +UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::MessageStore& store) + : _reticulum(reticulum), _router(router), _store(store), + _current_screen(SCREEN_CONVERSATION_LIST), + _conversation_list_screen(nullptr), + _chat_screen(nullptr), + _compose_screen(nullptr), + _announce_list_screen(nullptr), + _status_screen(nullptr), + _qr_screen(nullptr), + _settings_screen(nullptr), + _propagation_nodes_screen(nullptr), + _propagation_manager(nullptr), + _ble_interface(nullptr), + _initialized(false) { +} + +UIManager::~UIManager() { + if (_conversation_list_screen) { + delete _conversation_list_screen; + } + if (_chat_screen) { + delete _chat_screen; + } + if (_compose_screen) { + delete _compose_screen; + } + if (_announce_list_screen) { + delete _announce_list_screen; + } + if (_status_screen) { + delete _status_screen; + } + if (_qr_screen) { + delete _qr_screen; + } + if (_settings_screen) { + delete _settings_screen; + } + if (_propagation_nodes_screen) { + delete _propagation_nodes_screen; + } +} + +bool UIManager::init() { + LVGL_LOCK(); + if (_initialized) { + return true; + } + + INFO("Initializing UIManager"); + + // Create all screens + _conversation_list_screen = new ConversationListScreen(); + _chat_screen = new ChatScreen(); + _compose_screen = new ComposeScreen(); + _announce_list_screen = new AnnounceListScreen(); + _status_screen = new StatusScreen(); + _qr_screen = new QRScreen(); + _settings_screen = new SettingsScreen(); + _propagation_nodes_screen = new PropagationNodesScreen(); + + // Set up callbacks for conversation list screen + _conversation_list_screen->set_conversation_selected_callback( + [this](const Bytes& peer_hash) { on_conversation_selected(peer_hash); } + ); + + _conversation_list_screen->set_compose_callback( + [this]() { on_new_message(); } + ); + + _conversation_list_screen->set_sync_callback( + [this]() { on_propagation_sync(); } + ); + + _conversation_list_screen->set_settings_callback( + [this]() { show_settings(); } + ); + + _conversation_list_screen->set_announces_callback( + [this]() { show_announces(); } + ); + + // Set up callbacks for chat screen + _chat_screen->set_back_callback( + [this]() { on_back_to_conversation_list(); } + ); + + _chat_screen->set_send_message_callback( + [this](const String& content) { on_send_message_from_chat(content); } + ); + + // Set up callbacks for compose screen + _compose_screen->set_cancel_callback( + [this]() { on_cancel_compose(); } + ); + + _compose_screen->set_send_callback( + [this](const Bytes& dest_hash, const String& message) { + on_send_message_from_compose(dest_hash, message); + } + ); + + // Set up callbacks for announce list screen + _announce_list_screen->set_announce_selected_callback( + [this](const Bytes& dest_hash) { on_announce_selected(dest_hash); } + ); + + _announce_list_screen->set_back_callback( + [this]() { on_back_from_announces(); } + ); + + _announce_list_screen->set_send_announce_callback( + [this]() { + INFO("Sending LXMF announce"); + _router.announce(); + } + ); + + // Set up callbacks for status screen + _status_screen->set_back_callback( + [this]() { on_back_from_status(); } + ); + + _status_screen->set_share_callback( + [this]() { on_share_from_status(); } + ); + + // Set up callbacks for QR screen + _qr_screen->set_back_callback( + [this]() { on_back_from_qr(); } + ); + + // Set up callbacks for settings screen + _settings_screen->set_back_callback( + [this]() { on_back_from_settings(); } + ); + + _settings_screen->set_propagation_nodes_callback( + [this]() { show_propagation_nodes(); } + ); + + // Set up callbacks for propagation nodes screen + _propagation_nodes_screen->set_back_callback( + [this]() { on_back_from_propagation_nodes(); } + ); + + _propagation_nodes_screen->set_node_selected_callback( + [this](const Bytes& node_hash) { on_propagation_node_selected(node_hash); } + ); + + _propagation_nodes_screen->set_auto_select_changed_callback( + [this](bool enabled) { on_propagation_auto_select_changed(enabled); } + ); + + _propagation_nodes_screen->set_sync_callback( + [this]() { on_propagation_sync(); } + ); + + // Load settings from NVS + _settings_screen->load_settings(); + + // Set identity and LXMF address on settings screen + _settings_screen->set_identity_hash(_router.identity().hash()); + _settings_screen->set_lxmf_address(_router.delivery_destination().hash()); + + // Set up callback for status button in conversation list + _conversation_list_screen->set_status_callback( + [this]() { show_status(); } + ); + + // Set identity hash and LXMF address on status screen + _status_screen->set_identity_hash(_router.identity().hash()); + _status_screen->set_lxmf_address(_router.delivery_destination().hash()); + + // Set identity and LXMF address on QR screen + _qr_screen->set_identity(_router.identity()); + _qr_screen->set_lxmf_address(_router.delivery_destination().hash()); + + // Register LXMF delivery callback + _router.register_delivery_callback( + [this](::LXMF::LXMessage& message) { on_message_received(message); } + ); + + // Load conversations and show conversation list + _conversation_list_screen->load_conversations(_store); + show_conversation_list(); + + _initialized = true; + INFO("UIManager initialized"); + + return true; +} + +void UIManager::update() { + LVGL_LOCK(); + // Process outbound LXMF messages + _router.process_outbound(); + + // Process inbound LXMF messages + _router.process_inbound(); + + // Update status indicators (WiFi/battery) on conversation list + static uint32_t last_status_update = 0; + uint32_t now = millis(); + if (now - last_status_update > 3000) { // Update every 3 seconds + last_status_update = now; + if (_conversation_list_screen) { + _conversation_list_screen->update_status(); + } + // Update status screen if visible + if (_current_screen == SCREEN_STATUS && _status_screen) { + _status_screen->refresh(); + } + } +} + +void UIManager::show_conversation_list() { + LVGL_LOCK(); + INFO("Showing conversation list"); + + _conversation_list_screen->refresh(); + _conversation_list_screen->show(); + _chat_screen->hide(); + _compose_screen->hide(); + _announce_list_screen->hide(); + _status_screen->hide(); + _settings_screen->hide(); + _propagation_nodes_screen->hide(); + + _current_screen = SCREEN_CONVERSATION_LIST; +} + +void UIManager::show_chat(const Bytes& peer_hash) { + LVGL_LOCK(); + std::string hash_hex = peer_hash.toHex().substr(0, 8); + std::string msg = "Showing chat with peer " + hash_hex + "..."; + INFO(msg.c_str()); + + _current_peer_hash = peer_hash; + + _chat_screen->load_conversation(peer_hash, _store); + _chat_screen->show(); + _conversation_list_screen->hide(); + _compose_screen->hide(); + _announce_list_screen->hide(); + _status_screen->hide(); + _settings_screen->hide(); + _propagation_nodes_screen->hide(); + + _current_screen = SCREEN_CHAT; +} + +void UIManager::show_compose() { + LVGL_LOCK(); + INFO("Showing compose screen"); + + _compose_screen->clear(); + _compose_screen->show(); + _conversation_list_screen->hide(); + _chat_screen->hide(); + _announce_list_screen->hide(); + _status_screen->hide(); + _settings_screen->hide(); + _propagation_nodes_screen->hide(); + + _current_screen = SCREEN_COMPOSE; +} + +void UIManager::show_announces() { + LVGL_LOCK(); + INFO("Showing announces screen"); + + _announce_list_screen->refresh(); + _announce_list_screen->show(); + _conversation_list_screen->hide(); + _chat_screen->hide(); + _compose_screen->hide(); + _status_screen->hide(); + _settings_screen->hide(); + _propagation_nodes_screen->hide(); + + _current_screen = SCREEN_ANNOUNCES; +} + +void UIManager::show_status() { + LVGL_LOCK(); + INFO("Showing status screen"); + + _status_screen->refresh(); + _status_screen->show(); + _conversation_list_screen->hide(); + _chat_screen->hide(); + _compose_screen->hide(); + _announce_list_screen->hide(); + _settings_screen->hide(); + _propagation_nodes_screen->hide(); + + _current_screen = SCREEN_STATUS; +} + +void UIManager::on_conversation_selected(const Bytes& peer_hash) { + show_chat(peer_hash); +} + +void UIManager::on_new_message() { + show_compose(); +} + +void UIManager::show_settings() { + LVGL_LOCK(); + INFO("Showing settings screen"); + + _settings_screen->refresh(); + _settings_screen->show(); + _conversation_list_screen->hide(); + _chat_screen->hide(); + _compose_screen->hide(); + _announce_list_screen->hide(); + _status_screen->hide(); + _propagation_nodes_screen->hide(); + + _current_screen = SCREEN_SETTINGS; +} + +void UIManager::show_propagation_nodes() { + LVGL_LOCK(); + INFO("Showing propagation nodes screen"); + + if (_propagation_manager) { + // Load settings from NVS + Preferences prefs; + prefs.begin(NVS_NAMESPACE, true); // read-only + bool auto_select = prefs.getBool(KEY_AUTO_SELECT, true); + + Bytes selected_hash; + size_t hash_len = prefs.getBytesLength(KEY_NODE_HASH); + if (hash_len > 0 && hash_len <= 32) { + uint8_t buf[32]; + prefs.getBytes(KEY_NODE_HASH, buf, hash_len); + selected_hash = Bytes(buf, hash_len); + } + prefs.end(); + + // If not auto-select and we have a saved hash, use it + if (!auto_select && selected_hash.size() > 0) { + _router.set_outbound_propagation_node(selected_hash); + } + + _propagation_nodes_screen->load_nodes(*_propagation_manager, selected_hash, auto_select); + } + + _propagation_nodes_screen->show(); + _conversation_list_screen->hide(); + _chat_screen->hide(); + _compose_screen->hide(); + _announce_list_screen->hide(); + _status_screen->hide(); + _settings_screen->hide(); + + _current_screen = SCREEN_PROPAGATION_NODES; +} + +void UIManager::set_propagation_node_manager(::LXMF::PropagationNodeManager* manager) { + _propagation_manager = manager; +} + +void UIManager::set_lora_interface(Interface* iface) { + if (_conversation_list_screen) { + _conversation_list_screen->set_lora_interface(iface); + } +} + +void UIManager::set_ble_interface(Interface* iface) { + _ble_interface = iface; + if (_conversation_list_screen) { + _conversation_list_screen->set_ble_interface(iface); + } +} + +void UIManager::set_gps(TinyGPSPlus* gps) { + if (_conversation_list_screen) { + _conversation_list_screen->set_gps(gps); + } +} + +void UIManager::on_back_to_conversation_list() { + show_conversation_list(); +} + +void UIManager::on_send_message_from_chat(const String& content) { + send_message(_current_peer_hash, content); +} + +void UIManager::on_send_message_from_compose(const Bytes& dest_hash, const String& message) { + send_message(dest_hash, message); + + // Switch to chat screen for this conversation + show_chat(dest_hash); +} + +void UIManager::on_cancel_compose() { + show_conversation_list(); +} + +void UIManager::on_announce_selected(const Bytes& dest_hash) { + std::string hash_hex = dest_hash.toHex().substr(0, 8); + std::string msg = "Announce selected: " + hash_hex + "..."; + INFO(msg.c_str()); + + // Go directly to chat screen with this destination + show_chat(dest_hash); +} + +void UIManager::on_back_from_announces() { + show_conversation_list(); +} + +void UIManager::on_back_from_status() { + show_conversation_list(); +} + +void UIManager::on_share_from_status() { + LVGL_LOCK(); + _status_screen->hide(); + _qr_screen->show(); + _current_screen = SCREEN_QR; +} + +void UIManager::on_back_from_qr() { + LVGL_LOCK(); + _qr_screen->hide(); + _status_screen->show(); + _current_screen = SCREEN_STATUS; +} + +void UIManager::on_back_from_settings() { + show_conversation_list(); +} + +void UIManager::on_back_from_propagation_nodes() { + show_settings(); +} + +void UIManager::on_propagation_node_selected(const Bytes& node_hash) { + std::string hash_hex = node_hash.toHex().substr(0, 16); + std::string msg = "Propagation node selected: " + hash_hex + "..."; + INFO(msg.c_str()); + + // Set the node in the router + _router.set_outbound_propagation_node(node_hash); + + // Save to NVS + Preferences prefs; + prefs.begin(NVS_NAMESPACE, false); + prefs.putBool(KEY_AUTO_SELECT, false); + prefs.putBytes(KEY_NODE_HASH, node_hash.data(), node_hash.size()); + prefs.end(); + DEBUG("Propagation node saved to NVS"); +} + +void UIManager::on_propagation_auto_select_changed(bool enabled) { + std::string msg = "Propagation auto-select changed: "; + msg += enabled ? "enabled" : "disabled"; + INFO(msg.c_str()); + + if (enabled) { + // Clear manual selection, router will use best node + _router.set_outbound_propagation_node(Bytes()); + } + + // Save to NVS + Preferences prefs; + prefs.begin(NVS_NAMESPACE, false); + prefs.putBool(KEY_AUTO_SELECT, enabled); + if (enabled) { + prefs.remove(KEY_NODE_HASH); // Clear saved node when auto-select enabled + } + prefs.end(); + DEBUG("Propagation auto-select saved to NVS"); +} + +void UIManager::on_propagation_sync() { + INFO("Requesting messages from propagation node"); + _router.request_messages_from_propagation_node(); +} + +void UIManager::set_rns_status(bool connected, const String& server_name) { + if (_status_screen) { + _status_screen->set_rns_status(connected, server_name); + } +} + +void UIManager::send_message(const Bytes& dest_hash, const String& content) { + std::string hash_hex = dest_hash.toHex().substr(0, 8); + std::string msg = "Sending message to " + hash_hex + "..."; + INFO(msg.c_str()); + + // Get our source destination (needed for signing) + Destination source = _router.delivery_destination(); + + // Create message content + Bytes content_bytes((const uint8_t*)content.c_str(), content.length()); + Bytes title; // Empty title + + // Look up destination identity + Identity dest_identity = Identity::recall(dest_hash); + + // Create destination object - either real or placeholder + Destination destination(Type::NONE); + if (dest_identity) { + destination = Destination(dest_identity, Type::Destination::OUT, Type::Destination::SINGLE, "lxmf", "delivery"); + INFO(" Destination identity known"); + } else { + WARNING(" Destination identity not known, message may fail until peer announces"); + } + + // Create message with destination and source objects + // Source is needed for signing + ::LXMF::LXMessage message(destination, source, content_bytes, title); + + // If destination identity was unknown, manually set the destination hash + if (!dest_identity) { + message.destination_hash(dest_hash); + DEBUG(" Set destination hash manually"); + } + + // Pack the message to generate hash and signature before saving + message.pack(); + + // Add to UI immediately (optimistic update) + if (_current_screen == SCREEN_CHAT && _current_peer_hash == dest_hash) { + _chat_screen->add_message(message, true); + } + + // Save to store (now has valid hash from pack()) + _store.save_message(message); + + // Queue for sending (pack already called, will use cached packed data) + _router.handle_outbound(message); + + INFO(" Message queued for delivery"); +} + +void UIManager::on_message_received(::LXMF::LXMessage& message) { + LVGL_LOCK(); + std::string source_hex = message.source_hash().toHex().substr(0, 8); + std::string msg = "Message received from " + source_hex + "..."; + INFO(msg.c_str()); + + // Save to store + _store.save_message(message); + + // Update UI if we're viewing this conversation + bool viewing_this_chat = (_current_screen == SCREEN_CHAT && _current_peer_hash == message.source_hash()); + if (viewing_this_chat) { + _chat_screen->add_message(message, false); + } + + // Play notification sound if enabled and not viewing this conversation + if (_settings_screen) { + const auto& settings = _settings_screen->get_settings(); + if (settings.notification_sound && !viewing_this_chat) { + Notification::tone_play(1000, 100, settings.notification_volume); // 1kHz beep, 100ms + } + } + + // Update conversation list unread count + // TODO: Track unread counts + _conversation_list_screen->refresh(); + + INFO(" Message processed"); +} + +void UIManager::on_message_delivered(::LXMF::LXMessage& message) { + LVGL_LOCK(); + std::string hash_hex = message.hash().toHex().substr(0, 8); + std::string msg = "Message delivered: " + hash_hex + "..."; + INFO(msg.c_str()); + + // Update UI if we're viewing this conversation + if (_current_screen == SCREEN_CHAT && _current_peer_hash == message.destination_hash()) { + _chat_screen->update_message_status(message.hash(), true); + } +} + +void UIManager::on_message_failed(::LXMF::LXMessage& message) { + LVGL_LOCK(); + std::string hash_hex = message.hash().toHex().substr(0, 8); + std::string msg = "Message delivery failed: " + hash_hex + "..."; + WARNING(msg.c_str()); + + // Update UI if we're viewing this conversation + if (_current_screen == SCREEN_CHAT && _current_peer_hash == message.destination_hash()) { + _chat_screen->update_message_status(message.hash(), false); + } +} + +void UIManager::refresh_current_screen() { + LVGL_LOCK(); + switch (_current_screen) { + case SCREEN_CONVERSATION_LIST: + _conversation_list_screen->refresh(); + break; + case SCREEN_CHAT: + _chat_screen->refresh(); + break; + case SCREEN_COMPOSE: + // No refresh needed + break; + case SCREEN_ANNOUNCES: + _announce_list_screen->refresh(); + break; + case SCREEN_STATUS: + _status_screen->refresh(); + break; + case SCREEN_SETTINGS: + _settings_screen->refresh(); + break; + case SCREEN_PROPAGATION_NODES: + _propagation_nodes_screen->refresh(); + break; + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h new file mode 100644 index 0000000..0b91fee --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -0,0 +1,227 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_UIMANAGER_H +#define UI_LXMF_UIMANAGER_H + +#ifdef ARDUINO +#include +#include +#include +#include "ConversationListScreen.h" +#include "ChatScreen.h" +#include "ComposeScreen.h" +#include "AnnounceListScreen.h" +#include "StatusScreen.h" +#include "QRScreen.h" +#include "SettingsScreen.h" +#include "PropagationNodesScreen.h" +#include "LXMF/LXMRouter.h" +#include "LXMF/PropagationNodeManager.h" +#include "LXMF/MessageStore.h" +#include "Reticulum.h" + +namespace UI { +namespace LXMF { + +/** + * UI Manager + * + * Manages all LXMF UI screens and coordinates between: + * - UI screens (ConversationList, Chat, Compose) + * - LXMF router (message sending/receiving) + * - Message store (persistence) + * - Reticulum (network layer) + * + * Responsibilities: + * - Screen navigation + * - Message delivery callbacks + * - UI updates on message events + * - Integration with LXMF router + */ +class UIManager { +public: + /** + * Create UI manager + * @param reticulum Reticulum instance + * @param router LXMF router instance + * @param store Message store instance + */ + UIManager(RNS::Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::MessageStore& store); + + /** + * Destructor + */ + ~UIManager(); + + /** + * Initialize UI and show conversation list + * @return true if initialization successful + */ + bool init(); + + /** + * Update UI (call periodically from main loop) + * Processes pending LXMF messages and updates UI + */ + void update(); + + /** + * Show conversation list screen + */ + void show_conversation_list(); + + /** + * Show chat screen for a specific peer + * @param peer_hash Peer destination hash + */ + void show_chat(const RNS::Bytes& peer_hash); + + /** + * Show compose new message screen + */ + void show_compose(); + + /** + * Show announce list screen + */ + void show_announces(); + + /** + * Show status screen + */ + void show_status(); + + /** + * Show settings screen + */ + void show_settings(); + + /** + * Show propagation nodes screen + */ + void show_propagation_nodes(); + + /** + * Set propagation node manager + * @param manager Propagation node manager instance + */ + void set_propagation_node_manager(::LXMF::PropagationNodeManager* manager); + + /** + * Set LoRa interface for RSSI display + * @param iface LoRa interface + */ + void set_lora_interface(RNS::Interface* iface); + + /** + * Set BLE interface for connection count display + * @param iface BLE interface + */ + void set_ble_interface(RNS::Interface* iface); + + /** + * Set GPS for satellite count display + * @param gps TinyGPSPlus instance + */ + void set_gps(TinyGPSPlus* gps); + + /** + * Get settings screen for external configuration + */ + SettingsScreen* get_settings_screen() { return _settings_screen; } + + /** + * Get status screen for external updates (e.g., BLE peer info) + */ + StatusScreen* get_status_screen() { return _status_screen; } + + /** + * Update RNS connection status displayed on status screen + * @param connected Whether connected to RNS server + * @param server_name Server hostname (optional) + */ + void set_rns_status(bool connected, const String& server_name = ""); + + /** + * Handle incoming LXMF message + * Called by LXMF router delivery callback + * @param message Received message + */ + void on_message_received(::LXMF::LXMessage& message); + + /** + * Handle message delivery confirmation + * @param message Message that was delivered + */ + void on_message_delivered(::LXMF::LXMessage& message); + + /** + * Handle message delivery failure + * @param message Message that failed to deliver + */ + void on_message_failed(::LXMF::LXMessage& message); + +private: + enum Screen { + SCREEN_CONVERSATION_LIST, + SCREEN_CHAT, + SCREEN_COMPOSE, + SCREEN_ANNOUNCES, + SCREEN_STATUS, + SCREEN_QR, + SCREEN_SETTINGS, + SCREEN_PROPAGATION_NODES + }; + + RNS::Reticulum& _reticulum; + ::LXMF::LXMRouter& _router; + ::LXMF::MessageStore& _store; + + Screen _current_screen; + RNS::Bytes _current_peer_hash; + + ConversationListScreen* _conversation_list_screen; + ChatScreen* _chat_screen; + ComposeScreen* _compose_screen; + AnnounceListScreen* _announce_list_screen; + StatusScreen* _status_screen; + QRScreen* _qr_screen; + SettingsScreen* _settings_screen; + PropagationNodesScreen* _propagation_nodes_screen; + + ::LXMF::PropagationNodeManager* _propagation_manager; + RNS::Interface* _ble_interface; + + bool _initialized; + + // Screen navigation handlers + void on_conversation_selected(const RNS::Bytes& peer_hash); + void on_new_message(); + void on_back_to_conversation_list(); + void on_send_message_from_chat(const String& content); + void on_send_message_from_compose(const RNS::Bytes& dest_hash, const String& message); + void on_cancel_compose(); + void on_announce_selected(const RNS::Bytes& dest_hash); + void on_back_from_announces(); + void on_back_from_status(); + void on_share_from_status(); + void on_back_from_qr(); + void on_back_from_settings(); + void on_back_from_propagation_nodes(); + void on_propagation_node_selected(const RNS::Bytes& node_hash); + void on_propagation_auto_select_changed(bool enabled); + void on_propagation_sync(); + + // LXMF message handling + void send_message(const RNS::Bytes& dest_hash, const String& content); + + // UI updates + void refresh_current_screen(); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_UIMANAGER_H diff --git a/lib/tdeck_ui/UI/TextAreaHelper.h b/lib/tdeck_ui/UI/TextAreaHelper.h new file mode 100644 index 0000000..4e04ced --- /dev/null +++ b/lib/tdeck_ui/UI/TextAreaHelper.h @@ -0,0 +1,70 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef ARDUINO +#include +#include +#include "Clipboard.h" + +namespace UI { + +/** + * @brief Helper for adding paste functionality to any LVGL text area + * + * Usage: + * lv_obj_t* ta = lv_textarea_create(parent); + * TextAreaHelper::enable_paste(ta); + */ +class TextAreaHelper { +public: + /** + * @brief Enable paste-on-long-press for a text area + * @param textarea LVGL textarea object + * + * When the user long-presses the textarea, a paste dialog + * will appear if the clipboard has content. + */ + static void enable_paste(lv_obj_t* textarea) { + lv_obj_add_event_cb(textarea, on_long_pressed, LV_EVENT_LONG_PRESSED, nullptr); + } + +private: + static void on_long_pressed(lv_event_t* event) { + lv_obj_t* textarea = lv_event_get_target(event); + + // Only show paste if clipboard has content + if (!Clipboard::has_content()) { + return; + } + + // Store textarea pointer in msgbox user data for the callback + static const char* btns[] = {"Paste", "Cancel", ""}; + lv_obj_t* mbox = lv_msgbox_create(NULL, "Paste", + "Paste from clipboard?", btns, false); + lv_obj_center(mbox); + lv_obj_set_user_data(mbox, textarea); + lv_obj_add_event_cb(mbox, on_paste_action, LV_EVENT_VALUE_CHANGED, nullptr); + } + + static void on_paste_action(lv_event_t* event) { + lv_obj_t* mbox = lv_event_get_current_target(event); + lv_obj_t* textarea = (lv_obj_t*)lv_obj_get_user_data(mbox); + + uint16_t btn_id = lv_msgbox_get_active_btn(mbox); + + if (btn_id == 0 && textarea) { // Paste button + const String& content = Clipboard::paste(); + if (content.length() > 0) { + lv_textarea_add_text(textarea, content.c_str()); + } + } + + lv_msgbox_close(mbox); + } +}; + +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/library.json b/lib/tdeck_ui/library.json new file mode 100644 index 0000000..083f5a6 --- /dev/null +++ b/lib/tdeck_ui/library.json @@ -0,0 +1,25 @@ +{ + "name": "tdeck_ui", + "version": "0.1.0", + "description": "T-Deck hardware drivers and LVGL UI for microReticulum", + "keywords": "tdeck, lvgl, ui, reticulum", + "license": "MIT", + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "dependencies": { + "lvgl": "^8.3.11", + "ArduinoJson": "^7.4.2", + "MsgPack": "^0.4.2", + "Crypto": "^0.4.0" + }, + "build": { + "flags": "-std=gnu++11 -I../../../../deps/microReticulum/src", + "srcFilter": [ + "+", + "+", + "+", + "+" + ], + "includeDir": "." + } +} diff --git a/lib/tone/Tone.cpp b/lib/tone/Tone.cpp new file mode 100644 index 0000000..5648a97 --- /dev/null +++ b/lib/tone/Tone.cpp @@ -0,0 +1,133 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "Tone.h" + +#ifdef ARDUINO +#include +#include +#include + +using namespace Hardware::TDeck; + +namespace Notification { + +// I2S configuration +static const uint32_t SAMPLE_RATE = 8000; +static const i2s_port_t I2S_PORT = I2S_NUM_0; + +// Tone state +static bool _initialized = false; +static bool _playing = false; +static uint32_t _tone_end_time = 0; +static uint16_t _current_freq = 0; +static uint16_t _current_amplitude = 0; +static uint32_t _sample_counter = 0; + +void tone_init() { + if (_initialized) return; + + // Configure I2S + i2s_config_t i2s_config = { + .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX), + .sample_rate = SAMPLE_RATE, + .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, + .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, + .communication_format = I2S_COMM_FORMAT_STAND_I2S, + .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, + .dma_buf_count = 8, + .dma_buf_len = 64, + .use_apll = false, + .tx_desc_auto_clear = true, + .fixed_mclk = 0 + }; + + esp_err_t err = i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL); + if (err != ESP_OK) { + Serial.printf("[TONE] Failed to install I2S driver: %d\n", err); + return; + } + + // Configure I2S pins + i2s_pin_config_t pin_config = { + .bck_io_num = Audio::I2S_BCK, + .ws_io_num = Audio::I2S_WS, + .data_out_num = Audio::I2S_DOUT, + .data_in_num = I2S_PIN_NO_CHANGE + }; + + err = i2s_set_pin(I2S_PORT, &pin_config); + if (err != ESP_OK) { + Serial.printf("[TONE] Failed to set I2S pins: %d\n", err); + return; + } + + _initialized = true; + Serial.println("[TONE] Audio initialized"); +} + +void tone_play(uint16_t frequency, uint16_t duration_ms, uint8_t volume) { + if (!_initialized) { + tone_init(); + } + + // Calculate amplitude from volume (0-100 -> 0-32767) + _current_amplitude = (volume * 327); // Max ~32700 + _current_freq = frequency; + _sample_counter = 0; + _tone_end_time = millis() + duration_ms; + _playing = true; + + // Generate and play the tone + const uint32_t samples_per_cycle = SAMPLE_RATE / frequency; + const uint32_t total_samples = (SAMPLE_RATE * duration_ms) / 1000; + + int16_t samples[128]; + size_t bytes_written; + uint32_t samples_written = 0; + + while (samples_written < total_samples) { + for (int i = 0; i < 128 && samples_written < total_samples; i++) { + // Generate square wave + if ((_sample_counter % samples_per_cycle) < (samples_per_cycle / 2)) { + samples[i] = _current_amplitude; + } else { + samples[i] = -_current_amplitude; + } + _sample_counter++; + samples_written++; + } + esp_err_t err = i2s_write(I2S_PORT, samples, sizeof(samples), &bytes_written, pdMS_TO_TICKS(2000)); + if (err != ESP_OK) { + Serial.printf("[TONE] I2S write timeout/error: %d\n", err); + break; // Abort tone on error + } + } + + // Write silence to flush DMA buffers + int16_t silence[128] = {0}; + // Silence flush - timeout OK to ignore here + i2s_write(I2S_PORT, silence, sizeof(silence), &bytes_written, pdMS_TO_TICKS(2000)); + + _playing = false; +} + +void tone_stop() { + if (!_initialized) return; + + _playing = false; + + // Write silence + int16_t silence[128] = {0}; + size_t bytes_written; + // Silence on stop - timeout OK to ignore + i2s_write(I2S_PORT, silence, sizeof(silence), &bytes_written, pdMS_TO_TICKS(2000)); +} + +bool tone_is_playing() { + return _playing; +} + +} // namespace Notification + +#endif // ARDUINO diff --git a/lib/tone/Tone.h b/lib/tone/Tone.h new file mode 100644 index 0000000..300a889 --- /dev/null +++ b/lib/tone/Tone.h @@ -0,0 +1,42 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +/** + * Simple I2S tone generator for T-Deck Plus speaker + * + * Based on LilyGo SimpleTone example. + * Uses native ESP32 I2S driver - no external libraries needed. + */ + +namespace Notification { + +/** + * Initialize the I2S audio driver + * Must be called once at startup before using tone_play() + */ +void tone_init(); + +/** + * Play a tone at the specified frequency + * @param frequency Frequency in Hz (e.g., 1000 for 1kHz) + * @param duration_ms Duration in milliseconds + * @param volume Volume level 0-100 (default 50) + */ +void tone_play(uint16_t frequency, uint16_t duration_ms, uint8_t volume = 50); + +/** + * Stop any currently playing tone + */ +void tone_stop(); + +/** + * Check if a tone is currently playing + * @return true if playing, false if silent + */ +bool tone_is_playing(); + +} // namespace Notification diff --git a/lib/tone/library.json b/lib/tone/library.json new file mode 100644 index 0000000..e772165 --- /dev/null +++ b/lib/tone/library.json @@ -0,0 +1,5 @@ +{ + "name": "tone", + "version": "1.0.0", + "description": "Simple I2S tone generator for T-Deck Plus speaker" +} diff --git a/lib/universal_filesystem/UniversalFileSystem.cpp b/lib/universal_filesystem/UniversalFileSystem.cpp new file mode 100644 index 0000000..1584bf7 --- /dev/null +++ b/lib/universal_filesystem/UniversalFileSystem.cpp @@ -0,0 +1,529 @@ +#include "UniversalFileSystem.h" + +#include +#include + +#ifdef ARDUINO +void UniversalFileSystem::listDir(const char* dir) { + Serial.print("DIR: "); + Serial.println(dir); +#ifdef BOARD_ESP32 + File root = SPIFFS.open(dir); + if (!root) { + Serial.println("Failed to opend directory"); + return; + } + File file = root.openNextFile(); + while (file) { + if (file.isDirectory()) { + Serial.print(" DIR: "); + } + else { + Serial.print(" FILE: "); + } + Serial.println(file.name()); + file.close(); + file = root.openNextFile(); + } + root.close(); +#elif BOARD_NRF52 + File root = InternalFS.open(dir); + if (!root) { + Serial.println("Failed to opend directory"); + return; + } + File file = root.openNextFile(); + while (file) { + if (file.isDirectory()) { + Serial.print(" DIR: "); + } + else { + Serial.print(" FILE: "); + } + Serial.println(file.name()); + file.close(); + file = root.openNextFile(); + } + root.close(); +#endif +} +#else +void UniversalFileSystem::listDir(const char* dir) { +} +#endif + +/*virtual*/ bool UniversalFileSystem::init() { + TRACE("UniversalFileSystem initializing..."); + +#ifdef ARDUINO +#ifdef BOARD_ESP32 + // Setup FileSystem + INFO("SPIFFS mounting FileSystem"); + if (!SPIFFS.begin(true)){ + ERROR("SPIFFS FileSystem mount failed"); + return false; + } + uint32_t size = SPIFFS.totalBytes() / (1024 * 1024); + Serial.print("size: "); + Serial.print(size); + Serial.println(" MB"); + uint32_t used = SPIFFS.usedBytes() / (1024 * 1024); + Serial.print("used: "); + Serial.print(used); + Serial.println(" MB"); + // ensure FileSystem is writable and format if not + RNS::Bytes test("test"); + size_t wrote = write_file("/test", test); + INFO("SPIFFS write test: wrote " + std::to_string(wrote) + " bytes"); + if (wrote < 4) { + WARNING("SPIFFS FileSystem is being formatted, please wait..."); + SPIFFS.format(); + } + else { + remove_file("/test"); + INFO("SPIFFS FileSystem write test passed"); + } + DEBUG("SPIFFS FileSystem is ready"); +#elif BOARD_NRF52 + // Initialize Internal File System + INFO("InternalFS mounting FileSystem"); + InternalFS.begin(); + INFO("InternalFS FileSystem is ready"); +#endif +#endif + return true; +} + +/*virtual*/ bool UniversalFileSystem::file_exists(const char* file_path) { +#ifdef ARDUINO +#ifdef BOARD_ESP32 + File file = SPIFFS.open(file_path, FILE_READ); + if (file) { +#elif BOARD_NRF52 + File file(InternalFS); + if (file.open(file_path, FILE_O_READ)) { +#else + if (false) { +#endif +#else + // Native + FILE* file = fopen(file_path, "r"); + if (file != nullptr) { +#endif + //TRACE("file_exists: file exists, closing file"); +#ifdef ARDUINO +#ifdef BOARD_ESP32 + file.close(); +#elif BOARD_NRF52 + file.close(); +#endif +#else + // Native + fclose(file); +#endif + return true; + } + else { + ERROR("file_exists: failed to open file " + std::string(file_path)); + return false; + } +} + +/*virtual*/ size_t UniversalFileSystem::read_file(const char* file_path, RNS::Bytes& data) { + size_t read = 0; +#ifdef ARDUINO +#ifdef BOARD_ESP32 + File file = SPIFFS.open(file_path, FILE_READ); + if (file) { + size_t size = file.size(); + read = file.readBytes((char*)data.writable(size), size); +#elif BOARD_NRF52 + //File file(InternalFS); + //if (file.open(file_path, FILE_O_READ)) { + File file = InternalFS.open(file_path, FILE_O_READ); + if (file) { + size_t size = file.size(); + read = file.readBytes((char*)data.writable(size), size); +#else + if (false) { + size_t size = 0; +#endif +#else + // Native + FILE* file = fopen(file_path, "r"); + if (file != nullptr) { + fseek(file, 0, SEEK_END); + size_t size = ftell(file); + rewind(file); + //size_t read = fread(data.writable(size), size, 1, file); + read = fread(data.writable(size), 1, size, file); +#endif + TRACE("read_file: read " + std::to_string(read) + " bytes from file " + std::string(file_path)); + if (read != size) { + ERROR("read_file: failed to read file " + std::string(file_path)); + data.clear(); + } + //TRACE("read_file: closing input file"); +#ifdef ARDUINO +#ifdef BOARD_ESP32 + file.close(); +#elif BOARD_NRF52 + file.close(); +#endif +#else + // Native + fclose(file); +#endif + } + else { + ERROR("read_file: failed to open input file " + std::string(file_path)); + } + return read; +} + +/*virtual*/ size_t UniversalFileSystem::write_file(const char* file_path, const RNS::Bytes& data) { + // CBA TODO Replace remove with working truncation + remove_file(file_path); + size_t wrote = 0; +#ifdef ARDUINO +#ifdef BOARD_ESP32 + File file = SPIFFS.open(file_path, FILE_WRITE); + if (file) { + wrote = file.write(data.data(), data.size()); +#elif BOARD_NRF52 + File file(InternalFS); + if (file.open(file_path, FILE_O_WRITE)) { + wrote = file.write(data.data(), data.size()); +#else + if (false) { +#endif +#else + // Native + FILE* file = fopen(file_path, "w"); + if (file != nullptr) { + //size_t wrote = fwrite(data.data(), data.size(), 1, file); + wrote = fwrite(data.data(), 1, data.size(), file); +#endif + TRACE("write_file: wrote " + std::to_string(wrote) + " bytes to file " + std::string(file_path)); + if (wrote < data.size()) { + WARNING("write_file: not all data was written to file " + std::string(file_path)); + } + //TRACE("write_file: closing output file"); +#ifdef ARDUINO +#ifdef BOARD_ESP32 + file.close(); +#elif BOARD_NRF52 + file.close(); +#endif +#else + // Native + fclose(file); +#endif + } + else { + ERROR("write_file: failed to open output file " + std::string(file_path)); + } + return wrote; +} + +/*virtual*/ RNS::FileStream UniversalFileSystem::open_file(const char* file_path, RNS::FileStream::MODE file_mode) { + TRACEF("open_file: opening file %s", file_path); +#ifdef ARDUINO +#ifdef BOARD_ESP32 + const char* mode; + if (file_mode == RNS::FileStream::MODE_READ) { + mode = FILE_READ; + } + else if (file_mode == RNS::FileStream::MODE_WRITE) { + mode = FILE_WRITE; + } + else if (file_mode == RNS::FileStream::MODE_APPEND) { + mode = FILE_APPEND; + } + else { + ERRORF("open_file: unsupported mode %d", file_mode); + return {RNS::Type::NONE}; + } + TRACEF("open_file: opening file %s in mode %s", file_path, mode); + //// Using copy constructor to create a File* instead of local + //File file = SPIFFS.open(file_path, mode); + //if (!file) { + File* file = new File(SPIFFS.open(file_path, mode)); + if (file == nullptr || !(*file)) { + ERRORF("open_file: failed to open output file %s", file_path); + return {RNS::Type::NONE}; + } + TRACEF("open_file: successfully opened file %s", file_path); + return RNS::FileStream(new UniversalFileStream(file)); +#elif BOARD_NRF52 + //File file = File(InternalFS); + File* file = new File(InternalFS); + int mode; + if (file_mode == RNS::FileStream::MODE_READ) { + mode = FILE_O_READ; + } + else if (file_mode == RNS::FileStream::MODE_WRITE) { + mode = FILE_O_WRITE; + // CBA TODO Replace remove with working truncation + if (InternalFS.exists(file_path)) { + InternalFS.remove(file_path); + } + } + else if (file_mode == RNS::FileStream::MODE_APPEND) { + // CBA This is the default write mode for nrf52 littlefs + mode = FILE_O_WRITE; + } + else { + ERRORF("open_file: unsupported mode %d", file_mode); + return {RNS::Type::NONE}; + } + if (!file->open(file_path, mode)) { + ERRORF("open_file: failed to open output file %s", file_path); + return {RNS::Type::NONE}; + } + + // Seek to beginning to overwrite (this is failing on nrf52) + //if (file_mode == RNS::FileStream::MODE_WRITE) { + // file->seek(0); + // file->truncate(0); + //} + TRACEF("open_file: successfully opened file %s", file_path); + return RNS::FileStream(new UniversalFileStream(file)); +#else + #warning("unsuppoprted"); + return RNS::FileStream(RNS::Type::NONE); +#endif +#else // ARDUINO + // Native + const char* mode; + if (file_mode == RNS::FileStream::MODE_READ) { + mode = "r"; + } + else if (file_mode == RNS::FileStream::MODE_WRITE) { + mode = "w"; + } + else if (file_mode == RNS::FileStream::MODE_APPEND) { + mode = "a"; + } + else { + ERRORF("open_file: unsupported mode %d", file_mode); + return {RNS::Type::NONE}; + } + TRACEF("open_file: opening file %s in mode %s", file_path, mode); + FILE* file = fopen(file_path, mode); + if (file == nullptr) { + ERRORF("open_file: failed to open output file %s", file_path); + return {RNS::Type::NONE}; + } + TRACEF("open_file: successfully opened file %s", file_path); + return RNS::FileStream(new UniversalFileStream(file)); +#endif +} + +/*virtual*/ bool UniversalFileSystem::remove_file(const char* file_path) { +#ifdef ARDUINO +#ifdef BOARD_ESP32 + return SPIFFS.remove(file_path); +#elif BOARD_NRF52 + return InternalFS.remove(file_path); +#else + return false; +#endif +#else + // Native + return (remove(file_path) == 0); +#endif +} + +/*virtual*/ bool UniversalFileSystem::rename_file(const char* from_file_path, const char* to_file_path) { +#ifdef ARDUINO +#ifdef BOARD_ESP32 + return SPIFFS.rename(from_file_path, to_file_path); +#elif BOARD_NRF52 + return InternalFS.rename(from_file_path, to_file_path); +#else + return false; +#endif +#else + // Native + return (rename(from_file_path, to_file_path) == 0); +#endif +} + +/*virtua*/ bool UniversalFileSystem::directory_exists(const char* directory_path) { + TRACE("directory_exists: checking for existence of directory " + std::string(directory_path)); +#ifdef ARDUINO +#ifdef BOARD_ESP32 + File file = SPIFFS.open(directory_path, FILE_READ); + if (file) { + bool is_directory = file.isDirectory(); + file.close(); + return is_directory; + } +#elif BOARD_NRF52 + File file(InternalFS); + if (file.open(directory_path, FILE_O_READ)) { + bool is_directory = file.isDirectory(); + file.close(); + return is_directory; + } +#else + if (false) { + return false; + } +#endif + else { + return false; + } +#else + // Native + return false; +#endif +} + +/*virtual*/ bool UniversalFileSystem::create_directory(const char* directory_path) { +#ifdef ARDUINO +#ifdef BOARD_ESP32 + // SPIFFS is a flat filesystem - directories are just part of the file path. + // mkdir() may fail or be a no-op, but files can still be written with the full path. + // Try to create but don't fail if it doesn't work. + SPIFFS.mkdir(directory_path); + DEBUG("create_directory: SPIFFS mkdir attempted for " + std::string(directory_path)); + return true; +#elif BOARD_NRF52 + if (!InternalFS.mkdir(directory_path)) { + ERROR("create_directory: failed to create directorty " + std::string(directory_path)); + return false; + } + return true; +#else + return false; +#endif +#else + // Native + struct stat st = {0}; + if (stat(directory_path, &st) == 0) { + return true; + } + return (mkdir(directory_path, 0700) == 0); +#endif +} + +/*virtua*/ bool UniversalFileSystem::remove_directory(const char* directory_path) { + TRACE("remove_directory: removing directory " + std::string(directory_path)); +#ifdef ARDUINO +#ifdef BOARD_ESP32 + //if (!LittleFS.rmdir_r(directory_path)) { + if (!SPIFFS.rmdir(directory_path)) { + ERROR("remove_directory: failed to remove directorty " + std::string(directory_path)); + return false; + } + return true; +#elif BOARD_NRF52 + if (!InternalFS.rmdir_r(directory_path)) { + ERROR("remove_directory: failed to remove directory " + std::string(directory_path)); + return false; + } + return true; +#else + return false; +#endif +#else + // Native + return false; +#endif +} + +/*virtua*/ std::list UniversalFileSystem::list_directory(const char* directory_path) { + TRACE("list_directory: listing directory " + std::string(directory_path)); + std::list files; +#ifdef ARDUINO +#ifdef BOARD_ESP32 + File root = SPIFFS.open(directory_path); +#elif BOARD_NRF52 + File root = InternalFS.open(directory_path); +#endif + if (!root) { + ERROR("list_directory: failed to open directory " + std::string(directory_path)); + return files; + } + File file = root.openNextFile(); + while (file) { + if (!file.isDirectory()) { + char* name = (char*)file.name(); + files.push_back(name); + } + // CBA Following close required to avoid leaking memory + file.close(); + file = root.openNextFile(); + } + TRACE("list_directory: returning directory listing"); + root.close(); + return files; +#else + // Native + return files; +#endif +} + + +#ifdef ARDUINO + +#ifdef BOARD_ESP32 + +/*virtual*/ size_t UniversalFileSystem::storage_size() { + return SPIFFS.totalBytes(); +} + +/*virtual*/ size_t UniversalFileSystem::storage_available() { + return (SPIFFS.totalBytes() - SPIFFS.usedBytes()); +} + +#elif BOARD_NRF52 + +static int _countLfsBlock(void *p, lfs_block_t block){ + lfs_size_t *size = (lfs_size_t*) p; + *size += 1; + return 0; +} + +static lfs_ssize_t getUsedBlockCount() { + lfs_size_t size = 0; + lfs_traverse(InternalFS._getFS(), _countLfsBlock, &size); + return size; +} + +static int totalBytes() { + const lfs_config* config = InternalFS._getFS()->cfg; + return config->block_size * config->block_count; +} + +static int usedBytes() { + const lfs_config* config = InternalFS._getFS()->cfg; + const int usedBlockCount = getUsedBlockCount(); + return config->block_size * usedBlockCount; +} + +/*virtual*/ size_t UniversalFileSystem::storage_size() { + //return totalBytes(); + return InternalFS.totalBytes(); +} + +/*virtual*/ size_t UniversalFileSystem::storage_available() { + //return (totalBytes() - usedBytes()); + return (InternalFS.totalBytes() - InternalFS.usedBytes()); +} + +#endif + +#else + +/*virtual*/ size_t UniversalFileSystem::storage_size() { + return 0; +} + +/*virtual*/ size_t UniversalFileSystem::storage_available() { + return 0; +} + +#endif diff --git a/lib/universal_filesystem/UniversalFileSystem.h b/lib/universal_filesystem/UniversalFileSystem.h new file mode 100644 index 0000000..3d00e44 --- /dev/null +++ b/lib/universal_filesystem/UniversalFileSystem.h @@ -0,0 +1,187 @@ +#pragma once + +#include +#include +#include + +#ifdef ARDUINO +#ifdef BOARD_ESP32 +//#include +#include +#elif BOARD_NRF52 +//#include +#include +using namespace Adafruit_LittleFS_Namespace; +#endif +#else +#include +#include +#include +#include +#include +#include +#endif + +class UniversalFileSystem : public RNS::FileSystemImpl { + +public: + UniversalFileSystem() {} + +public: + static void listDir(const char* dir); + +public: + virtual bool init(); + virtual bool file_exists(const char* file_path); + virtual size_t read_file(const char* file_path, RNS::Bytes& data); + virtual size_t write_file(const char* file_path, const RNS::Bytes& data); + virtual RNS::FileStream open_file(const char* file_path, RNS::FileStream::MODE file_mode); + virtual bool remove_file(const char* file_path); + virtual bool rename_file(const char* from_file_path, const char* to_file_path); + virtual bool directory_exists(const char* directory_path); + virtual bool create_directory(const char* directory_path); + virtual bool remove_directory(const char* directory_path); + virtual std::list list_directory(const char* directory_path); + virtual size_t storage_size(); + virtual size_t storage_available(); + +protected: + +#if defined(BOARD_ESP32) || defined(BOARD_NRF52) + class UniversalFileStream : public RNS::FileStreamImpl { + + private: + std::unique_ptr _file; + bool _closed = false; + + public: + UniversalFileStream(File* file) : RNS::FileStreamImpl(), _file(file) {} + virtual ~UniversalFileStream() { if (!_closed) close(); } + + public: + inline virtual const char* name() { return _file->name(); } + inline virtual size_t size() { return _file->size(); } + inline virtual void close() { _closed = true; _file->close(); } + + // Print overrides + inline virtual size_t write(uint8_t byte) { return _file->write(byte); } + inline virtual size_t write(const uint8_t *buffer, size_t size) { return _file->write(buffer, size); } + + // Stream overrides + inline virtual int available() { return _file->available(); } + inline virtual int read() { return _file->read(); } + inline virtual int peek() { return _file->peek(); } + inline virtual void flush() { _file->flush(); } + + }; +#else + class UniversalFileStream : public RNS::FileStreamImpl { + + private: + FILE* _file = nullptr; + bool _closed = false; + size_t _available = 0; + char _filename[1024]; + + public: + UniversalFileStream(FILE* file) : RNS::FileStreamImpl(), _file(file) { + _available = size(); + } + virtual ~UniversalFileStream() { if (!_closed) close(); } + + public: + inline virtual const char* name() { + assert(_file); +#if 0 + char proclnk[1024]; + snprintf(proclnk, sizeof(proclnk), "/proc/self/fd/%d", fileno(_file)); + int r = readlink(proclnk, _filename, sizeof(_filename)); + if (r < 0) { + return nullptr); + } + _filename[r] = '\0'; + return _filename; +#elif 0 + if (fcntl(fd, F_GETPATH, _filename) < 0) { + rerturn nullptr; + } + return _filename; +#else + return nullptr; +#endif + } + inline virtual size_t size() { + assert(_file); + struct stat st; + fstat(fileno(_file), &st); + return st.st_size; + } + inline virtual void close() { + assert(_file); + _closed = true; + fclose(_file); + _file = nullptr; + } + + // Print overrides + inline virtual size_t write(uint8_t byte) { + assert(_file); + int ch = fputc(byte, _file); + if (ch == EOF) { + return 0; + } + ++_available; + return 1; + } + inline virtual size_t write(const uint8_t *buffer, size_t size) { + assert(_file); + size_t wrote = fwrite(buffer, sizeof(uint8_t), size, _file); + _available += wrote; + return wrote; + } + + // Stream overrides + inline virtual int available() { +#if 0 + assert(_file); + int size = 0; + ioctl(fileno(_file), FIONREAD, &size); + TRACEF("FileStream::available: %d", size); + return size; +#else + return _available; +#endif + } + inline virtual int read() { + if (_available <= 0) { + return EOF; + } + assert(_file); + int ch = fgetc(_file); + if (ch == EOF) { + return ch; + } + --_available; + //TRACEF("FileStream::read: %c", ch); + return ch; + } + inline virtual int peek() { + if (_available <= 0) { + return EOF; + } + assert(_file); + int ch = fgetc(_file); + ungetc(ch, _file); + TRACEF("FileStream::peek: %c", ch); + return ch; + } + inline virtual void flush() { + assert(_file); + fflush(_file); + TRACE("FileStream::flush"); + } + + }; +#endif + +}; diff --git a/lib/universal_filesystem/library.json b/lib/universal_filesystem/library.json new file mode 100644 index 0000000..958e458 --- /dev/null +++ b/lib/universal_filesystem/library.json @@ -0,0 +1,7 @@ +{ + "name": "universal_filesystem", + "version": "0.1.0", + "description": "Universal filesystem abstraction for SPIFFS/LittleFS", + "frameworks": ["arduino"], + "platforms": ["espressif32"] +} diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..eb0ce2f --- /dev/null +++ b/partitions.csv @@ -0,0 +1,7 @@ +# ESP-IDF Partition Table for T-Deck Plus (8MB Flash) +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x300000, +app1, app, ota_1, 0x310000, 0x300000, +spiffs, data, spiffs, 0x610000, 0x1F0000, diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..30d233f --- /dev/null +++ b/platformio.ini @@ -0,0 +1,147 @@ +; T-Deck environment using Bluedroid BLE stack (fallback, uses more RAM) +[env:tdeck-bluedroid] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino + +; T-Deck Plus has ESP32-S3 with 8MB Flash + 8MB PSRAM +board_build.flash_mode = qio +board_build.partitions = partitions.csv +board_build.arduino.memory_type = qio_opi +board_upload.flash_size = 8MB + +; Enable PSRAM (required for LVGL buffers) +board_build.arduino.psram_type = opi + +; Serial monitor +monitor_speed = 115200 +monitor_filters = + esp32_exception_decoder + time + +; Dependencies +lib_deps = + lvgl/lvgl@^8.3.11 + bblanchon/ArduinoJson@^7.4.2 + hideakitai/MsgPack@^0.4.2 + rweather/Crypto@^0.4.0 + mikalhart/TinyGPSPlus@^1.0.3 + jgromes/RadioLib@^6.0 + WiFi + SPI + Wire + SPIFFS + tdeck_ui + universal_filesystem + sx1262_interface + tone + auto_interface + ble_interface + symlink://${PROJECT_DIR}/deps/microReticulum/lib/libbz2 + +; Library dependency finder mode (deep search) +lib_ldf_mode = deep+ +lib_extra_dirs = deps/microReticulum + +; Build configuration +build_type = release +build_flags = + -std=gnu++11 + -DBOARD_HAS_PSRAM + -DBOARD_ESP32 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 + -DLV_CONF_INCLUDE_SIMPLE + -I${PROJECT_DIR}/lib + -I${PROJECT_DIR}/deps/microReticulum/lib/libbz2 + -I${PROJECT_DIR}/deps/microReticulum/src + -DBZ_NO_STDIO + -DUSE_BLUEDROID + -Os + -DCORE_DEBUG_LEVEL=2 + ; Enable memory instrumentation (heap/stack monitoring) + ; Remove this flag to disable instrumentation and eliminate overhead + -DMEMORY_INSTRUMENTATION_ENABLED + ; Enable boot profiling (timing instrumentation for setup phases) + ; Remove this flag to disable boot profiling + -DBOOT_PROFILING_ENABLED + ; Reduce log verbosity during boot for faster startup + ; CORE_DEBUG_LEVEL=2 (WARNING) reduces INFO-level log output + -DBOOT_REDUCED_LOGGING + +; Default T-Deck environment using NimBLE BLE stack (uses ~100KB less RAM than Bluedroid) +[env:tdeck] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino + +; T-Deck Plus has ESP32-S3 with 8MB Flash + 8MB PSRAM +board_build.flash_mode = qio +board_build.partitions = partitions.csv +board_build.arduino.memory_type = qio_opi +board_upload.flash_size = 8MB + +; Enable PSRAM (required for LVGL buffers) +board_build.arduino.psram_type = opi + +; Serial monitor +monitor_speed = 115200 +monitor_filters = + esp32_exception_decoder + time + +; Library dependency finder mode (deep search) +lib_ldf_mode = deep+ +lib_extra_dirs = deps/microReticulum + +; Build type +build_type = release +lib_deps = + lvgl/lvgl@^8.3.11 + bblanchon/ArduinoJson@^7.4.2 + hideakitai/MsgPack@^0.4.2 + rweather/Crypto@^0.4.0 + mikalhart/TinyGPSPlus@^1.0.3 + jgromes/RadioLib@^6.0 + WiFi + SPI + Wire + SPIFFS + tdeck_ui + universal_filesystem + sx1262_interface + tone + auto_interface + h2zero/NimBLE-Arduino@^2.1.0 + ble_interface + symlink://${PROJECT_DIR}/deps/microReticulum/lib/libbz2 + +; Build configuration +build_flags = + -std=gnu++11 + -DBOARD_HAS_PSRAM + -DBOARD_ESP32 + ; Increase Arduino loop task stack from 8KB to 16KB + ; Ed25519 crypto operations require significant stack space + -DARDUINO_LOOP_STACK_SIZE=16384 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 + -DLV_CONF_INCLUDE_SIMPLE + -I${PROJECT_DIR}/lib + -I${PROJECT_DIR}/deps/microReticulum/lib/libbz2 + -I${PROJECT_DIR}/deps/microReticulum/src + -I.pio/libdeps/tdeck/TinyGPSPlus/src + -I.pio/libdeps/tdeck/NimBLE-Arduino/src + -DBZ_NO_STDIO + -DUSE_NIMBLE + -Os + -DCORE_DEBUG_LEVEL=2 + ; Enable memory instrumentation (heap/stack monitoring) + ; Remove this flag to disable instrumentation and eliminate overhead + -DMEMORY_INSTRUMENTATION_ENABLED + ; Enable boot profiling (timing instrumentation for setup phases) + ; Remove this flag to disable boot profiling + -DBOOT_PROFILING_ENABLED + ; Reduce log verbosity during boot for faster startup + ; CORE_DEBUG_LEVEL=2 (WARNING) reduces INFO-level log output + -DBOOT_REDUCED_LOGGING diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..435ab7e --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,78 @@ +# Required for Arduino framework +CONFIG_FREERTOS_HZ=1000 + +# Flash configuration for 8MB +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# Enable Bluetooth with NimBLE (uses ~100KB less RAM than Bluedroid) +CONFIG_BT_ENABLED=y +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=n + +# NimBLE role configuration - enable both central and peripheral for mesh +CONFIG_BT_NIMBLE_ROLE_CENTRAL=y +CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y +CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y +CONFIG_BT_NIMBLE_ROLE_OBSERVER=y + +# NimBLE connection settings +CONFIG_BT_NIMBLE_MAX_CONNECTIONS=3 +CONFIG_BT_NIMBLE_MAX_BONDS=10 + +# NimBLE GATT settings +CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=517 +CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=31 + +# Increase task stack size for stability +CONFIG_BT_NIMBLE_TASK_STACK_SIZE=8192 + +# Coexistence with WiFi +CONFIG_ESP32_WIFI_SW_COEXIST_ENABLE=y + +# Enable C++ exceptions (required for microReticulum) +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_COMPILER_CXX_EXCEPTIONS_EMG_POOL_SIZE=0 + +# Enable PSRAM (8MB external RAM) for zero heap fragmentation +CONFIG_ESP32S3_SPIRAM_SUPPORT=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y + +# Configure malloc to use PSRAM +CONFIG_SPIRAM_USE_MALLOC=y +# Allocations smaller than this go to internal RAM (bytes) +# Lowered from 4096 to 64 to move Bytes allocations (16-200 bytes) to PSRAM +# This reduces internal heap fragmentation at cost of slightly slower access +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=64 +# Reserve this much internal RAM for DMA/ISR (bytes) +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 + +# ============================================================================ +# Boot Time Optimizations +# These settings reduce boot time. See BOOT_OPTIMIZATIONS.md for details. +# ============================================================================ + +# Disable PSRAM memory test at boot (~2 seconds saved for 8MB PSRAM) +# The memory test verifies PSRAM integrity but is redundant for stable hardware. +# To re-enable for debugging: comment out this line or set to y +CONFIG_SPIRAM_MEMTEST=n + +# Reduce bootloader log verbosity (saves serial output time) +# Bootloader messages are rarely needed in production +CONFIG_BOOTLOADER_LOG_LEVEL_WARN=y +CONFIG_BOOTLOADER_LOG_LEVEL=2 + +# Skip ROM bootloader output (saves serial time) +CONFIG_BOOT_ROM_LOG_ALWAYS_OFF=y + +# ============================================================================ +# Task Watchdog Timer (TWDT) +# Detects task starvation and deadlock conditions +# ============================================================================ +CONFIG_ESP_TASK_WDT_EN=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=10 +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y diff --git a/src/HDLC.h b/src/HDLC.h new file mode 100644 index 0000000..960a3ca --- /dev/null +++ b/src/HDLC.h @@ -0,0 +1,105 @@ +#pragma once + +#include "Bytes.h" + +#include + +namespace RNS { + +/** + * HDLC framing implementation for TCP transport. + * + * Matches Python RNS TCPInterface HDLC framing (RNS/Interfaces/TCPInterface.py). + * + * Wire format: [FLAG][escaped_payload][FLAG] + * Where: + * FLAG = 0x7E + * ESC = 0x7D + * + * Escape sequences: + * 0x7E in payload -> 0x7D 0x5E + * 0x7D in payload -> 0x7D 0x5D + */ +class HDLC { +public: + static constexpr uint8_t FLAG = 0x7E; + static constexpr uint8_t ESC = 0x7D; + static constexpr uint8_t ESC_MASK = 0x20; + + /** + * Escape data for transmission. + * Order matches Python RNS: escape ESC first, then FLAG. + * + * @param data Raw payload data + * @return Escaped data (without FLAG bytes) + */ + static Bytes escape(const Bytes& data) { + Bytes result; + result.reserve(data.size() * 2); // worst case: every byte needs escaping + + for (size_t i = 0; i < data.size(); ++i) { + uint8_t byte = data.data()[i]; + if (byte == ESC) { + // Escape ESC byte: 0x7D -> 0x7D 0x5D + result.append(ESC); + result.append(static_cast(ESC ^ ESC_MASK)); + } else if (byte == FLAG) { + // Escape FLAG byte: 0x7E -> 0x7D 0x5E + result.append(ESC); + result.append(static_cast(FLAG ^ ESC_MASK)); + } else { + result.append(byte); + } + } + return result; + } + + /** + * Unescape received data. + * + * @param data Escaped payload data (without FLAG bytes) + * @return Unescaped data, or empty Bytes on error + */ + static Bytes unescape(const Bytes& data) { + Bytes result; + result.reserve(data.size()); + + bool in_escape = false; + for (size_t i = 0; i < data.size(); ++i) { + uint8_t byte = data.data()[i]; + if (in_escape) { + // XOR with ESC_MASK to restore original byte + result.append(static_cast(byte ^ ESC_MASK)); + in_escape = false; + } else if (byte == ESC) { + in_escape = true; + } else { + result.append(byte); + } + } + + // If we ended mid-escape, that's an error + if (in_escape) { + return Bytes(); // empty = error + } + return result; + } + + /** + * Create a framed packet for transmission. + * + * @param data Raw payload data + * @return Framed data: [FLAG][escaped_data][FLAG] + */ + static Bytes frame(const Bytes& data) { + Bytes escaped = escape(data); + Bytes framed; + framed.reserve(escaped.size() + 2); + framed.append(FLAG); + framed.append(escaped); + framed.append(FLAG); + return framed; + } +}; + +} // namespace RNS diff --git a/src/TCPClientInterface.cpp b/src/TCPClientInterface.cpp new file mode 100644 index 0000000..9bf5a8a --- /dev/null +++ b/src/TCPClientInterface.cpp @@ -0,0 +1,496 @@ +#include "TCPClientInterface.h" +#include "HDLC.h" + +#include +#include + +#include + +#ifdef ARDUINO +// ESP32 lwIP socket headers +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +using namespace RNS; + +TCPClientInterface::TCPClientInterface(const char* name /*= "TCPClientInterface"*/) + : RNS::InterfaceImpl(name) { + + _IN = true; + _OUT = true; + _bitrate = BITRATE_GUESS; + _HW_MTU = HW_MTU; +} + +/*virtual*/ TCPClientInterface::~TCPClientInterface() { + stop(); +} + +/*virtual*/ bool TCPClientInterface::start() { + _online = false; + + TRACE("TCPClientInterface: target host: " + _target_host); + TRACE("TCPClientInterface: target port: " + std::to_string(_target_port)); + + if (_target_host.empty()) { + ERROR("TCPClientInterface: No target host configured"); + return false; + } + + // WiFi connection is handled externally (in main.cpp) + // Attempt initial connection + if (!connect()) { + INFO("TCPClientInterface: Initial connection failed, will retry in background"); + // Don't return false - we'll reconnect in loop() + } + + return true; +} + +bool TCPClientInterface::connect() { + TRACE("TCPClientInterface: Connecting to " + _target_host + ":" + std::to_string(_target_port)); + +#ifdef ARDUINO + // Set connection timeout + _client.setTimeout(CONNECT_TIMEOUT_MS); + + if (!_client.connect(_target_host.c_str(), _target_port)) { + DEBUG("TCPClientInterface: Connection failed"); + return false; + } + + // Configure socket options + configure_socket(); + + INFO("TCPClientInterface: Connected to " + _target_host + ":" + std::to_string(_target_port)); + _online = true; + _reconnected = true; // Signal that we (re)connected - main loop should announce + _last_data_received = millis(); // Reset stale timer + _frame_buffer.clear(); + return true; + +#else + // Resolve target host + struct in_addr target_addr; + if (inet_aton(_target_host.c_str(), &target_addr) == 0) { + struct hostent* host_ent = gethostbyname(_target_host.c_str()); + if (host_ent == nullptr || host_ent->h_addr_list[0] == nullptr) { + ERROR("TCPClientInterface: Unable to resolve host " + _target_host); + return false; + } + _target_address = *((in_addr_t*)(host_ent->h_addr_list[0])); + } else { + _target_address = target_addr.s_addr; + } + + // Create TCP socket + _socket = socket(PF_INET, SOCK_STREAM, 0); + if (_socket < 0) { + ERROR("TCPClientInterface: Unable to create socket, error " + std::to_string(errno)); + return false; + } + + // Set non-blocking for connect timeout + int flags = fcntl(_socket, F_GETFL, 0); + fcntl(_socket, F_SETFL, flags | O_NONBLOCK); + + // Connect to server + sockaddr_in server_addr; + server_addr.sin_family = AF_INET; + server_addr.sin_addr.s_addr = _target_address; + server_addr.sin_port = htons(_target_port); + + int result = ::connect(_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)); + if (result < 0 && errno != EINPROGRESS) { + close(_socket); + _socket = -1; + ERROR("TCPClientInterface: Connect failed, error " + std::to_string(errno)); + return false; + } + + // Wait for connection with timeout + fd_set write_fds; + FD_ZERO(&write_fds); + FD_SET(_socket, &write_fds); + struct timeval timeout; + timeout.tv_sec = CONNECT_TIMEOUT_MS / 1000; + timeout.tv_usec = (CONNECT_TIMEOUT_MS % 1000) * 1000; + + result = select(_socket + 1, nullptr, &write_fds, nullptr, &timeout); + if (result <= 0) { + close(_socket); + _socket = -1; + DEBUG("TCPClientInterface: Connection timeout"); + return false; + } + + // Check if connection succeeded + int sock_error = 0; + socklen_t len = sizeof(sock_error); + getsockopt(_socket, SOL_SOCKET, SO_ERROR, &sock_error, &len); + if (sock_error != 0) { + close(_socket); + _socket = -1; + DEBUG("TCPClientInterface: Connection failed, error " + std::to_string(sock_error)); + return false; + } + + // Restore blocking mode for normal operation + fcntl(_socket, F_SETFL, flags); + + // Configure socket options + configure_socket(); + + INFO("TCPClientInterface: Connected to " + _target_host + ":" + std::to_string(_target_port)); + _online = true; + _frame_buffer.clear(); + return true; +#endif +} + +void TCPClientInterface::configure_socket() { +#ifdef ARDUINO + // Get underlying socket fd for setsockopt + int fd = _client.fd(); + if (fd < 0) { + DEBUG("TCPClientInterface: Could not get socket fd for configuration"); + return; + } + + // TCP_NODELAY - disable Nagle's algorithm + int flag = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); + + // Enable TCP keepalive + setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &flag, sizeof(flag)); + + // Keepalive parameters (may not all be available on ESP32 lwIP) +#ifdef TCP_KEEPIDLE + int keepidle = TCP_KEEPIDLE_SEC; + setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); +#endif +#ifdef TCP_KEEPINTVL + int keepintvl = TCP_KEEPINTVL_SEC; + setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); +#endif +#ifdef TCP_KEEPCNT + int keepcnt = TCP_KEEPCNT_PROBES; + setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); +#endif + + TRACE("TCPClientInterface: Socket configured with TCP_NODELAY and keepalive"); + +#else + // TCP_NODELAY + int flag = 1; + setsockopt(_socket, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); + + // Enable TCP keepalive + setsockopt(_socket, SOL_SOCKET, SO_KEEPALIVE, &flag, sizeof(flag)); + + // Keepalive parameters + int keepidle = TCP_KEEPIDLE_SEC; + int keepintvl = TCP_KEEPINTVL_SEC; + int keepcnt = TCP_KEEPCNT_PROBES; + setsockopt(_socket, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); + setsockopt(_socket, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); + setsockopt(_socket, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); + + // TCP_USER_TIMEOUT (Linux 2.6.37+) +#ifdef TCP_USER_TIMEOUT + int user_timeout = 24000; // 24 seconds, matches Python RNS + setsockopt(_socket, IPPROTO_TCP, TCP_USER_TIMEOUT, &user_timeout, sizeof(user_timeout)); +#endif + + TRACE("TCPClientInterface: Socket configured with TCP_NODELAY, keepalive, and timeouts"); +#endif +} + +void TCPClientInterface::disconnect() { + DEBUG("TCPClientInterface: Disconnecting"); + +#ifdef ARDUINO + _client.stop(); +#else + if (_socket >= 0) { + close(_socket); + _socket = -1; + } +#endif + + _online = false; + _frame_buffer.clear(); +} + +void TCPClientInterface::handle_disconnect() { + if (_online) { + INFO("TCPClientInterface: Connection lost, will attempt reconnection"); + disconnect(); + // Reset connect attempt timer to enforce wait before reconnection + _last_connect_attempt = millis(); + } +} + +/*virtual*/ void TCPClientInterface::stop() { + disconnect(); +} + +/*virtual*/ void TCPClientInterface::loop() { + // Periodic status logging + static uint32_t last_status_log = 0; + static uint32_t loop_count = 0; + static uint32_t total_rx = 0; + loop_count++; + uint32_t now = millis(); + if (now - last_status_log >= 5000) { // Every 5 seconds + last_status_log = now; + int avail = _client.available(); + Serial.printf("[TCP] connected=%d online=%d avail=%d loops=%u rx=%u buf=%d\n", + _client.connected(), _online, avail, loop_count, total_rx, (int)_frame_buffer.size()); + loop_count = 0; + } + + // Handle reconnection if not connected + if (!_online) { + if (_initiator) { +#ifdef ARDUINO + uint32_t now = millis(); +#else + uint32_t now = static_cast(Utilities::OS::time() * 1000); +#endif + if (now - _last_connect_attempt >= RECONNECT_WAIT_MS) { + _last_connect_attempt = now; + // Skip reconnection if memory is too low - prevents fragmentation + uint32_t max_block = ESP.getMaxAllocHeap(); + if (max_block < 20000) { + Serial.printf("[TCP] Skipping reconnect - low memory (max_block=%u)\n", max_block); + } else { + DEBUG("TCPClientInterface: Attempting reconnection..."); + connect(); + } + } + } + return; + } + + // Check connection status + // Note: ESP32 WiFiClient.connected() has known bugs where it returns false incorrectly + // See: https://github.com/espressif/arduino-esp32/issues/1714 + // Workaround: only disconnect if connected() is false AND no data available +#ifdef ARDUINO + if (!_client.connected() && _client.available() == 0) { + Serial.printf("[TCP] Connection closed (connected=false, available=0)\n"); + handle_disconnect(); + return; + } + + // Stale connection detection disabled - was causing frequent reconnects + // TODO: investigate why this triggers even when receiving data + // if (_last_data_received > 0 && (now - _last_data_received) > STALE_CONNECTION_MS) { + // WARNING("TCPClientInterface: Connection appears stale, forcing reconnection"); + // handle_disconnect(); + // return; + // } + + // Read available data + int avail = _client.available(); + if (avail > 0) { + Serial.printf("[TCP] Reading %d bytes\n", avail); + total_rx += avail; + _last_data_received = now; // Update stale timer on any data receipt + size_t start_pos = _frame_buffer.size(); + while (_client.available() > 0) { + uint8_t byte = _client.read(); + _frame_buffer.append(byte); + } + // Dump first 20 bytes of new data + Serial.printf("[TCP] First bytes: "); + size_t dump_len = (_frame_buffer.size() - start_pos); + if (dump_len > 20) dump_len = 20; + for (size_t i = 0; i < dump_len; ++i) { + Serial.printf("%02x ", _frame_buffer.data()[start_pos + i]); + } + Serial.printf("\n"); + } +#else + // Non-blocking read + uint8_t buf[4096]; + ssize_t len = recv(_socket, buf, sizeof(buf), MSG_DONTWAIT); + if (len > 0) { + DEBUG("TCPClientInterface: Received " + std::to_string(len) + " bytes"); + _frame_buffer.append(buf, len); + } else if (len == 0) { + // Connection closed by peer + DEBUG("TCPClientInterface: recv returned 0 - connection closed"); + handle_disconnect(); + return; + } else { + int err = errno; + if (err != EAGAIN && err != EWOULDBLOCK) { + // Socket error + ERROR("TCPClientInterface: recv error " + std::to_string(err)); + handle_disconnect(); + return; + } + // EAGAIN/EWOULDBLOCK - normal for non-blocking, just no data yet + } +#endif + + // Process any complete frames + extract_and_process_frames(); +} + +void TCPClientInterface::extract_and_process_frames() { + // Find and process complete HDLC frames: [FLAG][data][FLAG] + static uint32_t frame_count = 0; + + while (true) { + if (_frame_buffer.size() == 0) break; + + // Find first FLAG byte + int start = -1; + for (size_t i = 0; i < _frame_buffer.size(); ++i) { + if (_frame_buffer.data()[i] == HDLC::FLAG) { + start = static_cast(i); + break; + } + } + + if (start < 0) { + // No FLAG found, discard buffer (garbage data before any frame) + Serial.printf("[HDLC] No FLAG in %d bytes, clearing\n", (int)_frame_buffer.size()); + _frame_buffer.clear(); + break; + } + + // Discard data before first FLAG + if (start > 0) { + Serial.printf("[HDLC] Discarding %d bytes before FLAG\n", start); + _frame_buffer = _frame_buffer.mid(start); + } + + // Find end FLAG (skip the start FLAG at position 0) + int end = -1; + for (size_t i = 1; i < _frame_buffer.size(); ++i) { + if (_frame_buffer.data()[i] == HDLC::FLAG) { + end = static_cast(i); + break; + } + } + + if (end < 0) { + // Incomplete frame, wait for more data + break; + } + + // Extract frame content between FLAGS (excluding the FLAGS) + Bytes frame_content = _frame_buffer.mid(1, end - 1); + frame_count++; + Serial.printf("[HDLC] Frame #%u: %d escaped bytes\n", frame_count, (int)frame_content.size()); + + // Remove processed frame from buffer (keep data after end FLAG) + _frame_buffer = _frame_buffer.mid(end); + + // Skip empty frames (consecutive FLAGs) + if (frame_content.size() == 0) { + Serial.printf("[HDLC] Empty frame, skipping\n"); + continue; + } + + // Unescape frame + Bytes unescaped = HDLC::unescape(frame_content); + if (unescaped.size() == 0) { + Serial.printf("[HDLC] Unescape failed!\n"); + DEBUG("TCPClientInterface: HDLC unescape error, discarding frame"); + continue; + } + + // Validate minimum frame size (matches Python RNS HEADER_MINSIZE check) + if (unescaped.size() < Type::Reticulum::HEADER_MINSIZE) { + TRACE("TCPClientInterface: Frame too small (" + std::to_string(unescaped.size()) + " bytes), discarding"); + continue; + } + + // Pass to transport layer + Serial.printf("[TCP] Processing frame: %d bytes\n", (int)unescaped.size()); + DEBUG(toString() + ": Received frame, " + std::to_string(unescaped.size()) + " bytes"); + InterfaceImpl::handle_incoming(unescaped); + } +} + +/*virtual*/ void TCPClientInterface::send_outgoing(const Bytes& data) { + DEBUG(toString() + ".send_outgoing: data: " + std::to_string(data.size()) + " bytes"); + + // Log first 50 bytes of raw packet (before HDLC framing) + std::string hex_preview; + size_t preview_len = (data.size() < 50) ? data.size() : 50; + for (size_t i = 0; i < preview_len; ++i) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02x", data.data()[i]); + hex_preview += buf; + } + if (data.size() > 50) hex_preview += "..."; + INFO("WIRE TX raw (" + std::to_string(data.size()) + " bytes): " + hex_preview); + + if (!_online) { + DEBUG("TCPClientInterface: Not connected, cannot send"); + return; + } + + try { + // Frame with HDLC + Bytes framed = HDLC::frame(data); + + // Log HDLC framed output for debugging + std::string framed_hex; + size_t flen = (framed.size() < 30) ? framed.size() : 30; + for (size_t i = 0; i < flen; ++i) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02x", framed.data()[i]); + framed_hex += buf; + } + if (framed.size() > 30) framed_hex += "..."; + INFO("WIRE TX framed (" + std::to_string(framed.size()) + " bytes): " + framed_hex); + +#ifdef ARDUINO + size_t written = _client.write(framed.data(), framed.size()); + if (written != framed.size()) { + ERROR("TCPClientInterface: Write incomplete, " + std::to_string(written) + + " of " + std::to_string(framed.size()) + " bytes"); + handle_disconnect(); + return; + } + _client.flush(); +#else + ssize_t written = send(_socket, framed.data(), framed.size(), MSG_NOSIGNAL); + if (written < 0) { + ERROR("TCPClientInterface: send error " + std::to_string(errno)); + handle_disconnect(); + return; + } + if (static_cast(written) != framed.size()) { + ERROR("TCPClientInterface: Write incomplete, " + std::to_string(written) + + " of " + std::to_string(framed.size()) + " bytes"); + handle_disconnect(); + return; + } +#endif + + // Perform post-send housekeeping + InterfaceImpl::handle_outgoing(data); + + } catch (std::exception& e) { + ERROR("TCPClientInterface: Exception during send: " + std::string(e.what())); + handle_disconnect(); + } +} diff --git a/src/TCPClientInterface.h b/src/TCPClientInterface.h new file mode 100644 index 0000000..fb6c57a --- /dev/null +++ b/src/TCPClientInterface.h @@ -0,0 +1,124 @@ +#pragma once + +#include "Interface.h" +#include "Bytes.h" +#include "Type.h" + +#ifdef ARDUINO +#include +#include +#else +#include +#endif + +#include +#include + +#define DEFAULT_TCP_PORT 4965 + +/** + * TCPClientInterface - TCP client interface for microReticulum. + * + * Connects to a Python RNS TCPServerInterface or another TCP server. + * Uses HDLC framing for wire protocol compatibility with Python RNS. + * + * Features: + * - Automatic reconnection with configurable retry interval + * - TCP keepalive for connection health monitoring + * - HDLC framing (0x7E flags with byte stuffing) + * + * Usage: + * TCPClientInterface* tcp = new TCPClientInterface("tcp0"); + * tcp->set_target_host("192.168.1.100"); + * tcp->set_target_port(4965); + * Interface interface(tcp); + * interface.start(); + * Transport::register_interface(interface); + */ +class TCPClientInterface : public RNS::InterfaceImpl { + +public: + // Match Python RNS constants + static const uint32_t BITRATE_GUESS = 10 * 1000 * 1000; // 10 Mbps + static const uint32_t HW_MTU = 1064; // Match UDPInterface + + // Reconnection parameters (match Python RNS) + static const uint32_t RECONNECT_WAIT_MS = 5000; // 5 seconds between reconnect attempts + static const uint32_t CONNECT_TIMEOUT_MS = 5000; // 5 second connection timeout + + // TCP keepalive parameters (match Python RNS) + static const int TCP_KEEPIDLE_SEC = 5; + static const int TCP_KEEPINTVL_SEC = 2; + static const int TCP_KEEPCNT_PROBES = 12; + +public: + TCPClientInterface(const char* name = "TCPClient"); + virtual ~TCPClientInterface(); + + // Configuration (call before start()) + void set_target_host(const std::string& host) { _target_host = host; } + void set_target_port(int port) { _target_port = port; } + + // InterfaceImpl overrides + virtual bool start(); + virtual void stop(); + virtual void loop(); + + virtual inline std::string toString() const { + return "TCPClientInterface[" + _name + "/" + _target_host + ":" + std::to_string(_target_port) + "]"; + } + +protected: + virtual void send_outgoing(const RNS::Bytes& data); + +private: + // Connection management + bool connect(); + void disconnect(); + void configure_socket(); + void handle_disconnect(); + + // HDLC frame processing + void process_incoming(); + void extract_and_process_frames(); + + // Target server + std::string _target_host; + int _target_port = DEFAULT_TCP_PORT; + + // Connection state + bool _initiator = true; + uint32_t _last_connect_attempt = 0; + bool _reconnected = false; // Set when connection re-established after being offline + uint32_t _last_data_received = 0; // Track last data receipt for stale connection detection + static const uint32_t STALE_CONNECTION_MS = 120000; // Consider connection stale after 2 min no data + +public: + // Check and clear reconnection flag (for announcing after reconnect) + bool check_reconnected() { + if (_reconnected) { + _reconnected = false; + return true; + } + return false; + } + +private: + + // HDLC frame buffer for partial frame reassembly + RNS::Bytes _frame_buffer; + + // Read buffer for incoming data + RNS::Bytes _read_buffer; + + // Platform-specific socket +#ifdef ARDUINO + WiFiClient _client; + // WiFi credentials (for ESP32 WiFi connection) + std::string _wifi_ssid; + std::string _wifi_password; +#else + int _socket = -1; + in_addr_t _target_address = INADDR_NONE; +#endif +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..9216b1f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,1437 @@ +// LXMF Messenger for LilyGO T-Deck Plus +// Complete LXMF messaging application with LVGL UI + +#include +#include +#include +#include +#include +#include + +// Reticulum +#include +#include + +// Filesystem +#include +#include +#include +#include +#include + +// TCP Client Interface +#include "TCPClientInterface.h" + +// LoRa Interface +#include "SX1262Interface.h" + +// Auto Interface (IPv6 peer discovery) +#include "AutoInterface.h" + +// BLE Mesh Interface +#include "BLEInterface.h" + +// LXMF +#include +#include +#include + +// Hardware drivers +#include +#include +#include +#include +#include + +// GPS +#include + +// UI +#include +#include +#include +#include + +// Audio notifications +#include "tone/Tone.h" + +// Logging +#include + +// SD Card logging for crash debugging +#include + +// Memory instrumentation +#ifdef MEMORY_INSTRUMENTATION_ENABLED +#include +#endif + +// Boot profiling +#ifdef BOOT_PROFILING_ENABLED +#include +#endif + +// Firmware version for web flasher detection +#define FIRMWARE_VERSION "1.0.0" +#define FIRMWARE_NAME "microReticulum" + +using namespace RNS; +using namespace LXMF; +using namespace Hardware::TDeck; + +// Application settings (loaded from NVS) +UI::LXMF::AppSettings app_settings; + +// Global instances +Reticulum* reticulum = nullptr; +Identity* identity = nullptr; +LXMRouter* router = nullptr; +MessageStore* message_store = nullptr; +PropagationNodeManager* propagation_manager = nullptr; +UI::LXMF::UIManager* ui_manager = nullptr; +TCPClientInterface* tcp_interface_impl = nullptr; +Interface* tcp_interface = nullptr; +SX1262Interface* lora_interface_impl = nullptr; +Interface* lora_interface = nullptr; +AutoInterface* auto_interface_impl = nullptr; +Interface* auto_interface = nullptr; +BLEInterface* ble_interface_impl = nullptr; +Interface* ble_interface = nullptr; + +// Timing +uint32_t last_ui_update = 0; +uint32_t last_announce = 0; +uint32_t last_sync = 0; +uint32_t last_status_check = 0; +const uint32_t STATUS_CHECK_INTERVAL = 1000; // 1 second +const uint32_t INITIAL_SYNC_DELAY = 45000; // 45 seconds after boot before first sync +bool initial_sync_done = false; + +// Connection tracking +bool last_tcp_online = false; +bool last_lora_online = false; + +// Screen timeout +bool screen_off = false; +uint8_t saved_brightness = 180; // Save brightness before turning off + +// Keyboard backlight timeout (5 seconds) +static const uint32_t KB_LIGHT_TIMEOUT_MS = 5000; +static uint32_t last_keypress_time = 0; +static bool kb_light_on = false; + +// GPS +TinyGPSPlus gps; +HardwareSerial GPSSerial(1); // UART1 for GPS +bool gps_time_synced = false; + +/** + * Calculate timezone offset from longitude + * Each 15 degrees of longitude = 1 hour offset from UTC + * Positive = East of Greenwich (ahead of UTC) + * Negative = West of Greenwich (behind UTC) + */ +int calculate_timezone_offset_hours(double longitude) { + // Simple calculation: divide longitude by 15, round to nearest hour + int offset = (int)round(longitude / 15.0); + // Clamp to valid range (-12 to +14) + if (offset < -12) offset = -12; + if (offset > 14) offset = 14; + return offset; +} + +/** + * Try to sync time from GPS + * Returns true if successful, false if no valid fix + */ +bool sync_time_from_gps(uint32_t timeout_ms = 30000) { + INFO("Attempting GPS time sync..."); + + uint32_t start = millis(); + bool got_time = false; + bool got_location = false; + + while (millis() - start < timeout_ms) { + while (GPSSerial.available() > 0) { + if (gps.encode(GPSSerial.read())) { + // Check if we have valid date/time + if (gps.date.isValid() && gps.time.isValid() && gps.date.year() >= 2024) { + got_time = true; + } + // Check if we have valid location (for timezone) + if (gps.location.isValid()) { + got_location = true; + } + // If we have both, we can sync + if (got_time && got_location) { + break; + } + } + } + if (got_time && got_location) break; + delay(10); + } + + if (!got_time) { + WARNING("GPS time not available"); + return false; + } + + // Build UTC time from GPS + struct tm gps_time; + gps_time.tm_year = gps.date.year() - 1900; + gps_time.tm_mon = gps.date.month() - 1; + gps_time.tm_mday = gps.date.day(); + gps_time.tm_hour = gps.time.hour(); + gps_time.tm_min = gps.time.minute(); + gps_time.tm_sec = gps.time.second(); + gps_time.tm_isdst = 0; // GPS time is UTC, no DST + + // Convert to Unix timestamp (UTC) + time_t gps_unix = mktime(&gps_time); + // mktime assumes local time, adjust back to UTC + // Actually, we'll set TZ to UTC first + setenv("TZ", "UTC0", 1); + tzset(); + gps_unix = mktime(&gps_time); + + // Set the system time + struct timeval tv; + tv.tv_sec = gps_unix; + tv.tv_usec = 0; + settimeofday(&tv, nullptr); + + // Set timezone based on location if available + if (got_location) { + double longitude = gps.location.lng(); + int tz_offset = calculate_timezone_offset_hours(longitude); + + // Build POSIX TZ string (e.g., "EST5" for UTC-5) + // Note: POSIX uses opposite sign convention! + char tz_str[32]; + if (tz_offset >= 0) { + snprintf(tz_str, sizeof(tz_str), "GPS%d", -tz_offset); + } else { + snprintf(tz_str, sizeof(tz_str), "GPS+%d", -tz_offset); + } + setenv("TZ", tz_str, 1); + tzset(); + + String msg = " GPS location: " + String(gps.location.lat(), 4) + ", " + String(longitude, 4); + INFO(msg.c_str()); + msg = " Timezone offset: UTC" + String(tz_offset >= 0 ? "+" : "") + String(tz_offset); + INFO(msg.c_str()); + } else { + // No location, use default Eastern Time + WARNING("GPS location not available, using Eastern Time"); + setenv("TZ", "EST5EDT,M3.2.0,M11.1.0", 1); + tzset(); + } + + // Set the time offset for Utilities::OS::time() + time_t now = time(nullptr); + uint64_t uptime_ms = millis(); + uint64_t unix_ms = (uint64_t)now * 1000; + RNS::Utilities::OS::setTimeOffset(unix_ms - uptime_ms); + + // Display synced time + struct tm timeinfo; + getLocalTime(&timeinfo); + char time_str[64]; + strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z", &timeinfo); + String msg = " GPS time synced: " + String(time_str); + INFO(msg.c_str()); + + gps_time_synced = true; + return true; +} + +bool try_l76k_init() { + // Try to initialize L76K GPS module (matches LilyGo example) + for (int attempt = 0; attempt < 3; attempt++) { + // Stop NMEA output temporarily + GPSSerial.write("$PCAS03,0,0,0,0,0,0,0,0,0,0,,,0,0*02\r\n"); + delay(50); + + // Drain buffer with timeout + uint32_t timeout = millis() + 500; + while (GPSSerial.available() && millis() < timeout) { + GPSSerial.read(); + } + // Note: Avoid flush() - blocks indefinitely in Arduino Core 3.x if TX can't complete + delay(100); + + // Request version + GPSSerial.write("$PCAS06,0*1B\r\n"); + timeout = millis() + 500; + while (!GPSSerial.available() && millis() < timeout) { + delay(10); + } + + if (GPSSerial.available()) { + String response = GPSSerial.readStringUntil('\n'); + if (response.startsWith("$GPTXT,01,01,02")) { + INFO(" L76K GPS detected!"); + return true; + } + } + delay(200); + } + return false; +} + +void setup_gps() { + INFO("Initializing GPS..."); + + String gps_msg = " GPS UART: ESP32 RX=" + String(Pin::GPS_RX) + ", TX=" + String(Pin::GPS_TX); + INFO(gps_msg.c_str()); + + bool gps_found = false; + + // Try u-blox at 38400 baud FIRST (T-Deck Plus default) + // This avoids sending L76K commands that could confuse u-blox + GPSSerial.begin(38400, SERIAL_8N1, Pin::GPS_RX, Pin::GPS_TX); + GPSSerial.setTimeout(500); + delay(500); // Give GPS time to start up + + uint32_t timeout = millis() + 1000; + while (!GPSSerial.available() && millis() < timeout) { + delay(10); + } + + if (GPSSerial.available()) { + INFO(" u-blox GPS detected at 38400 baud"); + gps_found = true; + } else { + // Try L76K at 9600 baud + INFO(" No data at 38400, trying L76K at 9600..."); + GPSSerial.end(); + GPSSerial.begin(9600, SERIAL_8N1, Pin::GPS_RX, Pin::GPS_TX); + GPSSerial.setTimeout(500); + delay(200); + + if (try_l76k_init()) { + // L76K initialization commands + GPSSerial.write("$PCAS04,5*1C\r\n"); // GPS + GLONASS mode + delay(100); + GPSSerial.write("$PCAS03,1,1,1,1,1,1,1,1,1,1,,,0,0*02\r\n"); // Enable all NMEA + delay(100); + GPSSerial.write("$PCAS11,3*1E\r\n"); // Vehicle mode + delay(100); + gps_found = true; + INFO(" L76K GPS initialized (GPS+GLONASS, Vehicle mode)"); + } else { + // Last try: check for any GPS at 9600 + timeout = millis() + 1000; + while (!GPSSerial.available() && millis() < timeout) { + delay(10); + } + if (GPSSerial.available()) { + INFO(" GPS detected at 9600 baud"); + gps_found = true; + } + } + } + + // Drain buffer + while (GPSSerial.available()) { + GPSSerial.read(); + } + + if (!gps_found) { + WARNING(" No GPS module detected!"); + } +} + +void load_app_settings() { + INFO("Loading application settings from NVS..."); + + Preferences prefs; + prefs.begin("settings", true); // Read-only + + // Network + app_settings.wifi_ssid = prefs.getString("wifi_ssid", ""); + app_settings.wifi_password = prefs.getString("wifi_pass", ""); + app_settings.tcp_host = prefs.getString("tcp_host", "sideband.connect.reticulum.network"); + app_settings.tcp_port = prefs.getUShort("tcp_port", 4965); + + // Identity + app_settings.display_name = prefs.getString("disp_name", ""); + + // Display + app_settings.brightness = prefs.getUChar("brightness", 180); + app_settings.keyboard_light = prefs.getBool("kb_light", false); + app_settings.screen_timeout = prefs.getUShort("timeout", 60); + + // Notifications + app_settings.notification_sound = prefs.getBool("notif_snd", true); + app_settings.notification_volume = prefs.getUChar("notif_vol", 10); + + // Interfaces + app_settings.tcp_enabled = prefs.getBool("tcp_en", true); + app_settings.lora_enabled = prefs.getBool("lora_en", false); + app_settings.lora_frequency = prefs.getFloat("lora_freq", 927.25f); + app_settings.lora_bandwidth = prefs.getFloat("lora_bw", 50.0f); + app_settings.lora_sf = prefs.getUChar("lora_sf", 7); + app_settings.lora_cr = prefs.getUChar("lora_cr", 5); + app_settings.lora_power = prefs.getChar("lora_pwr", 17); + app_settings.auto_enabled = prefs.getBool("auto_en", false); + app_settings.ble_enabled = prefs.getBool("ble_en", false); + + // Advanced + app_settings.announce_interval = prefs.getULong("announce", 60); + app_settings.sync_interval = prefs.getULong("sync_int", 3600); // Default 60 minutes + app_settings.gps_time_sync = prefs.getBool("gps_sync", true); + + // Propagation + app_settings.prop_auto_select = prefs.getBool("prop_auto", true); + app_settings.prop_selected_node = prefs.getString("prop_node", ""); + app_settings.prop_fallback_enabled = prefs.getBool("prop_fall", true); + + prefs.end(); + + // Log loaded settings (hide password) + String msg = " WiFi SSID: " + (app_settings.wifi_ssid.length() > 0 ? app_settings.wifi_ssid : "(not set)"); + INFO(msg.c_str()); + msg = " TCP Server: " + app_settings.tcp_host + ":" + String(app_settings.tcp_port); + INFO(msg.c_str()); + msg = " Brightness: " + String(app_settings.brightness); + INFO(msg.c_str()); +} + +void setup_wifi() { + // Check if WiFi credentials are configured + if (app_settings.wifi_ssid.length() == 0) { + WARNING("WiFi not configured - skipping WiFi setup"); + return; + } + + String msg = "Connecting to WiFi: " + app_settings.wifi_ssid; + INFO(msg.c_str()); + + WiFi.mode(WIFI_STA); + WiFi.begin(app_settings.wifi_ssid.c_str(), app_settings.wifi_password.c_str()); + + BOOT_PROFILE_WAIT_START("wifi_connect"); + uint32_t start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 30000) { + delay(500); + Serial.print("."); + } + Serial.println(); + BOOT_PROFILE_WAIT_END("wifi_connect"); + + if (WiFi.status() == WL_CONNECTED) { + INFO("WiFi connected!"); + msg = " IP address: " + WiFi.localIP().toString(); + INFO(msg.c_str()); + msg = " RSSI: " + String(WiFi.RSSI()) + " dBm"; + INFO(msg.c_str()); + + // Try GPS time sync first (if GPS is initialized and we haven't synced already) + if (!gps_time_synced) { + // Fallback to NTP if GPS didn't work + INFO("Syncing time via NTP (GPS not available)..."); + + // Use configTzTime for proper timezone handling on ESP32 + // Eastern Time: EST5EDT = UTC-5, DST starts 2nd Sunday March, ends 1st Sunday Nov + configTzTime("EST5EDT,M3.2.0,M11.1.0", "pool.ntp.org", "time.nist.gov"); + + // Wait for time to be set (max 10 seconds) + struct tm timeinfo; + int retry = 0; + while (!getLocalTime(&timeinfo) && retry < 20) { + delay(500); + retry++; + } + + if (retry < 20) { + // Set the time offset for Utilities::OS::time() + time_t now = time(nullptr); + uint64_t uptime_ms = millis(); + uint64_t unix_ms = (uint64_t)now * 1000; + RNS::Utilities::OS::setTimeOffset(unix_ms - uptime_ms); + + char time_str[64]; + strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z", &timeinfo); + msg = " NTP time synced: " + String(time_str); + INFO(msg.c_str()); + } else { + WARNING("NTP time sync failed!"); + } + } else { + INFO("Time already synced via GPS"); + } + } else { + ERROR("WiFi connection failed!"); + } +} + +void setup_hardware() { + INFO("\n=== Hardware Initialization ==="); + + // Initialize SPIFFS for persistence via UniversalFileSystem + // NOTE: Do NOT call SPIFFS.begin() here - UniversalFileSystem::init() handles it + static RNS::FileSystem fs = new UniversalFileSystem(); + if (!fs.init()) { + ERROR("FileSystem mount failed!"); + } else { + INFO("FileSystem mounted"); + RNS::Utilities::OS::register_filesystem(fs); + INFO("Filesystem registered"); +#ifdef BOOT_PROFILING_ENABLED + RNS::Instrumentation::BootProfiler::setFilesystemReady(true); +#endif + } + + // Initialize I2C for keyboard and touch + Wire.begin(Pin::I2C_SDA, Pin::I2C_SCL); + Wire.setClock(I2C::FREQUENCY); + INFO("I2C initialized"); + + // Initialize power + pinMode(Pin::POWER_EN, OUTPUT); + digitalWrite(Pin::POWER_EN, HIGH); + INFO("Power enabled"); +} + +void setup_lvgl_and_ui() { + INFO("\n=== LVGL & UI Initialization ==="); + + // Initialize LVGL with all hardware drivers + if (!UI::LVGL::LVGLInit::init()) { + ERROR("LVGL initialization failed!"); + while (1) delay(1000); + } + + INFO("LVGL initialized"); + + // Start LVGL on its own FreeRTOS task for responsive UI + // Core 1, priority 2 (higher than default to ensure smooth rendering) + // All LVGL API calls from other code are protected by mutex (LVGL_LOCK) + if (!UI::LVGL::LVGLInit::start_task(2, 1)) { + ERROR("Failed to start LVGL task!"); + while (1) delay(1000); + } + + INFO("LVGL task started on core 1"); + + // Initialize memory monitoring (if enabled) +#ifdef MEMORY_INSTRUMENTATION_ENABLED + INFO("Initializing memory monitor..."); + if (RNS::Instrumentation::MemoryMonitor::init(30000)) { + INFO("Memory monitor started (30s interval)"); + + // Register LVGL task for stack monitoring + TaskHandle_t lvgl_task = UI::LVGL::LVGLInit::get_task_handle(); + if (lvgl_task != nullptr) { + RNS::Instrumentation::MemoryMonitor::registerTask(lvgl_task, "lvgl"); + INFO(" Registered LVGL task"); + } + } else { + WARNING("Failed to start memory monitor"); + } +#endif +} + +void setup_reticulum() { + INFO("\n=== Reticulum Initialization ==="); + + // Create Reticulum instance (no auto-init) + reticulum = new Reticulum(); + + // Load or create identity using NVS (Non-Volatile Storage) + // NVS is preserved across flashes unlike SPIFFS + Preferences prefs; + prefs.begin("reticulum", false); // namespace "reticulum", read-write + + size_t key_len = prefs.getBytesLength("identity"); + INFO("Checking for identity in NVS..."); + Serial.printf("NVS identity key length: %u\n", key_len); + + if (key_len == 64) { // Private key is 64 bytes + INFO("Identity found in NVS, loading..."); + uint8_t key_data[64]; + prefs.getBytes("identity", key_data, 64); + Bytes private_key(key_data, 64); + identity = new Identity(false); // Create without generating keys + if (identity->load_private_key(private_key)) { + INFO(" Identity loaded successfully from NVS"); + } else { + ERROR(" Failed to load identity from NVS, creating new"); + identity = new Identity(); + Bytes priv_key = identity->get_private_key(); + prefs.putBytes("identity", priv_key.data(), priv_key.size()); + INFO(" New identity saved to NVS"); + } + } else { + INFO("No identity in NVS, creating new identity"); + identity = new Identity(); + Bytes priv_key = identity->get_private_key(); + size_t written = prefs.putBytes("identity", priv_key.data(), priv_key.size()); + Serial.printf(" Wrote %u bytes to NVS\n", written); + INFO(" Identity saved to NVS"); + } + prefs.end(); + + std::string identity_hex = identity->get_public_key().toHex().substr(0, 16); + std::string msg = " Identity: " + identity_hex + "..."; + INFO(msg.c_str()); + + // Add TCP client interface (if enabled and WiFi connected) + if (app_settings.tcp_enabled && WiFi.status() == WL_CONNECTED) { + String server_addr = app_settings.tcp_host + ":" + String(app_settings.tcp_port); + msg = std::string("Connecting to RNS server at ") + server_addr.c_str(); + INFO(msg.c_str()); + + tcp_interface_impl = new TCPClientInterface("tcp0"); + tcp_interface_impl->set_target_host(app_settings.tcp_host.c_str()); + tcp_interface_impl->set_target_port(app_settings.tcp_port); + tcp_interface = new Interface(tcp_interface_impl); + + if (!tcp_interface->start()) { + ERROR("Failed to connect to RNS server!"); + } else { + INFO("Connected to RNS server"); + Transport::register_interface(*tcp_interface); + } + } else if (!app_settings.tcp_enabled) { + INFO("TCP interface disabled in settings"); + } else { + WARNING("WiFi not connected - skipping TCP interface"); + } + + // Add LoRa interface (if enabled) + if (app_settings.lora_enabled) { + INFO("Initializing LoRa interface..."); + + lora_interface_impl = new SX1262Interface("LoRa"); + + // Apply configuration from settings + SX1262Config lora_config; + lora_config.frequency = app_settings.lora_frequency; + lora_config.bandwidth = app_settings.lora_bandwidth; + lora_config.spreading_factor = app_settings.lora_sf; + lora_config.coding_rate = app_settings.lora_cr; + lora_config.tx_power = app_settings.lora_power; + lora_interface_impl->set_config(lora_config); + + lora_interface = new Interface(lora_interface_impl); + + if (!lora_interface->start()) { + ERROR("Failed to initialize LoRa interface!"); + } else { + INFO("LoRa interface started"); + Transport::register_interface(*lora_interface); + } + } else { + INFO("LoRa interface disabled in settings"); + } + + // Add Auto interface (if enabled and WiFi connected) + if (app_settings.auto_enabled && WiFi.status() == WL_CONNECTED) { + INFO("Initializing AutoInterface (IPv6 peer discovery)..."); + + auto_interface_impl = new AutoInterface("Auto"); + auto_interface = new Interface(auto_interface_impl); + + if (!auto_interface->start()) { + ERROR("Failed to initialize AutoInterface!"); + } else { + INFO("AutoInterface started"); + Transport::register_interface(*auto_interface); + } + } else if (app_settings.auto_enabled) { + WARNING("AutoInterface enabled but WiFi not connected - skipping"); + } else { + INFO("AutoInterface disabled in settings"); + } + + // Add BLE Mesh interface (if enabled) + // Uses NimBLE stack by default (env:tdeck), Bluedroid available via env:tdeck-bluedroid + // NimBLE uses ~100KB less internal RAM than Bluedroid + if (app_settings.ble_enabled) { + INFO("Initializing BLE Mesh interface..."); + + ble_interface_impl = new BLEInterface("BLE"); + // Testing: DUAL mode with WiFi radio completely disabled + ble_interface_impl->setRole(RNS::BLE::Role::DUAL); + ble_interface_impl->setLocalIdentity(identity->get_public_key().left(16)); + // Set device name to TD-XXXXXX format (last 6 hex chars of identity) for T-Deck + std::string ble_name = "TD-" + identity->get_public_key().toHex().substr(26, 6); + ble_interface_impl->setDeviceName(ble_name); + ble_interface = new Interface(ble_interface_impl); + + if (!ble_interface->start()) { + ERROR("Failed to initialize BLE interface!"); + } else { + INFO("BLE Mesh interface started"); + Transport::register_interface(*ble_interface); + + // Start BLE on its own FreeRTOS task (core 0, priority 1) + // This prevents BLE operations from blocking the main loop + if (ble_interface_impl->start_task(1, 0)) { + INFO("BLE task started on core 0"); + } else { + WARNING("Failed to start BLE task, will run in main loop"); + } + } + } else { + INFO("BLE Mesh interface disabled in settings"); + } + + // Start Transport (initializes Transport identity and enables packet processing) + reticulum->start(); +} + +void setup_lxmf() { + INFO("\n=== LXMF Initialization ==="); + + // Create message store + message_store = new MessageStore("/lxmf"); + INFO("Message store ready"); + + // Create LXMF router + router = new LXMRouter(*identity, "/lxmf"); + INFO("LXMF router created"); + + // Create and register propagation node manager + propagation_manager = new PropagationNodeManager(); + Transport::register_announce_handler(HAnnounceHandler(propagation_manager)); + INFO("Propagation node manager registered"); + + // Configure propagation settings + router->set_propagation_node_manager(propagation_manager); + router->set_fallback_to_propagation(app_settings.prop_fallback_enabled); + router->set_propagation_only(app_settings.prop_only); + if (!app_settings.prop_selected_node.isEmpty()) { + // Use stored node (works for both manual and auto-select as initial/fallback) + Bytes selected_node; + selected_node.assignHex(app_settings.prop_selected_node.c_str()); + router->set_outbound_propagation_node(selected_node); + if (app_settings.prop_auto_select) { + INFO((" Propagation node: auto-select (using last known: " + app_settings.prop_selected_node.substring(0, 16) + "...)").c_str()); + } else { + INFO((" Selected propagation node: " + app_settings.prop_selected_node.substring(0, 16) + "...").c_str()); + } + } else { + INFO(" Propagation node: auto-select (no cached node)"); + } + INFO((" Fallback to propagation: " + String(app_settings.prop_fallback_enabled ? "enabled" : "disabled")).c_str()); + INFO((" Propagation only: " + String(app_settings.prop_only ? "enabled" : "disabled")).c_str()); + + // Set display name from settings for announces + if (!app_settings.display_name.isEmpty()) { + router->set_display_name(app_settings.display_name.c_str()); + } + + // Only do network stuff if TCP interface exists + if (tcp_interface) { + // Wait for TCP connection to stabilize before announcing + INFO("Waiting 3 seconds for TCP connection to stabilize..."); + BOOT_PROFILE_WAIT_START("tcp_stabilize"); + delay(3000); + BOOT_PROFILE_WAIT_END("tcp_stabilize"); + + // Check TCP status before announcing + if (tcp_interface->online()) { + INFO("TCP interface online: YES"); + // Announce delivery destination + INFO("Sending LXMF announce..."); + router->announce(); + last_announce = millis(); + } else { + INFO("TCP interface online: NO"); + } + } else { + WARNING("No TCP interface - network features disabled until WiFi configured"); + } + + std::string dest_hash = router->delivery_destination().hash().toHex(); + std::string msg = " Delivery destination: " + dest_hash; + INFO(msg.c_str()); +} + +void setup_ui_manager() { + INFO("\n=== UI Manager Initialization ==="); + + // Create UI manager + ui_manager = new UI::LXMF::UIManager(*reticulum, *router, *message_store); + + if (!ui_manager->init()) { + ERROR("UI manager initialization failed!"); + while (1) delay(1000); + } + + // Set initial RNS connection status (check all interfaces) + { + bool tcp_online = tcp_interface && tcp_interface->online(); + bool lora_online = lora_interface && lora_interface->online(); + last_tcp_online = tcp_online; + last_lora_online = lora_online; + + String status_str; + if (tcp_online && lora_online) { + status_str = "TCP+LoRa"; + } else if (tcp_online) { + status_str = "TCP: " + app_settings.tcp_host; + } else if (lora_online) { + status_str = "LoRa"; + } + ui_manager->set_rns_status(tcp_online || lora_online, status_str); + } + + // Set propagation node manager + if (propagation_manager) { + ui_manager->set_propagation_node_manager(propagation_manager); + } + + // Set LoRa interface for RSSI display + if (lora_interface) { + ui_manager->set_lora_interface(lora_interface); + } + + // Set BLE interface for connection count display + if (ble_interface) { + ui_manager->set_ble_interface(ble_interface); + } + + // Set GPS for satellite count display + ui_manager->set_gps(&gps); + + // Configure settings screen + UI::LXMF::SettingsScreen* settings = ui_manager->get_settings_screen(); + if (settings) { + // Pass GPS for status display + settings->set_gps(&gps); + + // Set brightness change callback (immediate) + settings->set_brightness_change_callback([](uint8_t brightness) { + // Apply brightness immediately via display backlight + ledcWrite(0, brightness); // Channel 0 is backlight on T-Deck + INFO(("Brightness changed to " + String(brightness)).c_str()); + }); + + // Set WiFi reconnect callback + settings->set_wifi_reconnect_callback([](const String& ssid, const String& password) { + INFO(("Reconnecting WiFi to: " + ssid).c_str()); + WiFi.disconnect(); + delay(100); + WiFi.begin(ssid.c_str(), password.c_str()); + + // Wait for connection (with timeout) + uint32_t start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) { + delay(100); + } + + if (WiFi.status() == WL_CONNECTED) { + INFO(("WiFi connected! IP: " + WiFi.localIP().toString()).c_str()); + } else { + WARNING("WiFi reconnection failed"); + } + }); + + // Set save callback (update app_settings and apply) + settings->set_save_callback([](const UI::LXMF::AppSettings& new_settings) { + // Check what changed + bool wifi_settings_changed = (new_settings.wifi_ssid != app_settings.wifi_ssid) || + (new_settings.wifi_password != app_settings.wifi_password); + bool tcp_settings_changed = (new_settings.tcp_enabled != app_settings.tcp_enabled) || + (new_settings.tcp_host != app_settings.tcp_host) || + (new_settings.tcp_port != app_settings.tcp_port); + bool lora_settings_changed = (new_settings.lora_enabled != app_settings.lora_enabled) || + (new_settings.lora_frequency != app_settings.lora_frequency) || + (new_settings.lora_bandwidth != app_settings.lora_bandwidth) || + (new_settings.lora_sf != app_settings.lora_sf) || + (new_settings.lora_cr != app_settings.lora_cr) || + (new_settings.lora_power != app_settings.lora_power); + bool auto_settings_changed = (new_settings.auto_enabled != app_settings.auto_enabled); + bool ble_settings_changed = (new_settings.ble_enabled != app_settings.ble_enabled); + + app_settings = new_settings; + + // Handle WiFi credential changes - auto reconnect + if (wifi_settings_changed && new_settings.wifi_ssid.length() > 0) { + INFO(("WiFi credentials changed, reconnecting to: " + new_settings.wifi_ssid).c_str()); + WiFi.disconnect(); + delay(100); + WiFi.begin(new_settings.wifi_ssid.c_str(), new_settings.wifi_password.c_str()); + + // Wait for connection (with timeout) + uint32_t start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) { + delay(100); + } + + if (WiFi.status() == WL_CONNECTED) { + INFO(("WiFi connected! IP: " + WiFi.localIP().toString()).c_str()); + } else { + WARNING("WiFi connection failed"); + } + } + + // Update router display name + if (router && !new_settings.display_name.isEmpty()) { + router->set_display_name(new_settings.display_name.c_str()); + } + + // Handle TCP interface changes at runtime + if (tcp_settings_changed) { + if (tcp_interface_impl) { + INFO("Stopping TCP interface..."); + tcp_interface_impl->stop(); + // Note: We don't delete it to avoid Transport issues + } + + if (new_settings.tcp_enabled && WiFi.status() == WL_CONNECTED) { + INFO("Starting TCP interface..."); + if (tcp_interface_impl) { + tcp_interface_impl->set_target_host(new_settings.tcp_host.c_str()); + tcp_interface_impl->set_target_port(new_settings.tcp_port); + tcp_interface_impl->start(); + } + } else if (!new_settings.tcp_enabled) { + INFO("TCP interface disabled"); + } + } + + // Handle LoRa interface changes at runtime + if (lora_settings_changed) { + if (lora_interface_impl) { + INFO("Stopping LoRa interface..."); + lora_interface_impl->stop(); + } + + if (new_settings.lora_enabled) { + INFO("Starting LoRa interface with new settings..."); + + // Create interface if it doesn't exist yet + if (!lora_interface_impl) { + INFO("Creating new LoRa interface..."); + lora_interface_impl = new SX1262Interface("LoRa"); + lora_interface = new Interface(lora_interface_impl); + } + + SX1262Config lora_config; + lora_config.frequency = new_settings.lora_frequency; + lora_config.bandwidth = new_settings.lora_bandwidth; + lora_config.spreading_factor = new_settings.lora_sf; + lora_config.coding_rate = new_settings.lora_cr; + lora_config.tx_power = new_settings.lora_power; + lora_interface_impl->set_config(lora_config); + + if (lora_interface->start()) { + INFO("LoRa interface started"); + // Register with transport if not already registered + Transport::register_interface(*lora_interface); + } else { + ERROR("Failed to start LoRa interface!"); + } + } else { + INFO("LoRa interface disabled"); + } + } + + // Handle Auto interface changes at runtime + if (auto_settings_changed) { + if (auto_interface_impl) { + INFO("Stopping AutoInterface..."); + auto_interface_impl->stop(); + } + + if (new_settings.auto_enabled && WiFi.status() == WL_CONNECTED) { + INFO("Starting AutoInterface..."); + + // Create interface if it doesn't exist yet + if (!auto_interface_impl) { + INFO("Creating new AutoInterface..."); + auto_interface_impl = new AutoInterface("Auto"); + auto_interface = new Interface(auto_interface_impl); + } + + if (auto_interface->start()) { + INFO("AutoInterface started"); + // Register with transport if not already registered + Transport::register_interface(*auto_interface); + } else { + ERROR("Failed to start AutoInterface!"); + } + } else if (new_settings.auto_enabled) { + WARNING("AutoInterface enabled but WiFi not connected"); + } else { + INFO("AutoInterface disabled"); + } + } + + // Handle BLE interface changes at runtime + if (ble_settings_changed) { + if (ble_interface_impl) { + INFO("Stopping BLE interface..."); + ble_interface_impl->stop(); + } + + if (new_settings.ble_enabled) { + INFO("Starting BLE interface..."); + + // Create interface if it doesn't exist yet + if (!ble_interface_impl) { + INFO("Creating new BLE interface..."); + ble_interface_impl = new BLEInterface("BLE"); + // Testing: DUAL mode with WiFi radio completely disabled + ble_interface_impl->setRole(RNS::BLE::Role::DUAL); + ble_interface_impl->setLocalIdentity(identity->get_public_key().left(16)); + std::string ble_name = "TD-" + identity->get_public_key().toHex().substr(26, 6); + ble_interface_impl->setDeviceName(ble_name); + ble_interface = new Interface(ble_interface_impl); + } + + if (ble_interface->start()) { + INFO("BLE interface started"); + // Register with transport if not already registered + Transport::register_interface(*ble_interface); + } else { + ERROR("Failed to start BLE interface!"); + } + } else { + INFO("BLE interface disabled"); + } + } + + // Apply propagation settings to router + if (router) { + router->set_fallback_to_propagation(new_settings.prop_fallback_enabled); + router->set_propagation_only(new_settings.prop_only); + + // When auto-select is enabled, save the current effective node for next boot + if (new_settings.prop_auto_select && propagation_manager) { + Bytes effective = propagation_manager->get_effective_node(); + if (effective.size() > 0) { + app_settings.prop_selected_node = String(effective.toHex().c_str()); + // Also persist to NVS + Preferences prefs; + prefs.begin("lxmf", false); + prefs.putString("prop_node", app_settings.prop_selected_node); + prefs.end(); + INFO((" Cached effective propagation node: " + app_settings.prop_selected_node.substring(0, 16) + "...").c_str()); + } + } + } + + INFO("Settings saved"); + }); + } + + // Apply initial brightness from settings + ledcWrite(0, app_settings.brightness); + + INFO("UI manager ready"); +} + +void setup() { + // Initialize serial + Serial.begin(115200); + delay(100); + + INFO("\n"); + INFO("╔══════════════════════════════════════╗"); + INFO("║ LXMF Messenger for T-Deck Plus ║"); + INFO("║ microReticulum + LVGL UI ║"); + INFO("╚══════════════════════════════════════╝"); + INFO(""); + + // Initialize hardware + BOOT_PROFILE_START("hardware"); + setup_hardware(); + BOOT_PROFILE_END("hardware"); + + // Initialize audio for notifications + BOOT_PROFILE_START("audio"); + Notification::tone_init(); + BOOT_PROFILE_END("audio"); + + // Load application settings from NVS (before WiFi/GPS) + BOOT_PROFILE_START("settings"); + load_app_settings(); + BOOT_PROFILE_END("settings"); + + // Initialize GPS and try to sync time (before WiFi) + BOOT_PROFILE_START("gps"); + setup_gps(); + if (app_settings.gps_time_sync) { + INFO("\n=== Time Synchronization ==="); + BOOT_PROFILE_WAIT_START("gps_sync"); + if (!sync_time_from_gps(15000)) { // 15 second timeout for GPS + INFO("GPS time sync not available, will try NTP after WiFi"); + } + BOOT_PROFILE_WAIT_END("gps_sync"); + } else { + INFO("GPS time sync disabled in settings"); + } + BOOT_PROFILE_END("gps"); + + // Initialize WiFi + BOOT_PROFILE_START("wifi"); + setup_wifi(); + BOOT_PROFILE_END("wifi"); + + // Initialize LVGL and hardware drivers + BOOT_PROFILE_START("lvgl"); + setup_lvgl_and_ui(); + BOOT_PROFILE_END("lvgl"); + + // NOTE: SD card logging disabled - shares SPI with display and causes blank screen + // TODO: Need to reinitialize display SPI after SD.begin() or use separate bus + + // Initialize Reticulum + BOOT_PROFILE_START("reticulum"); + setup_reticulum(); + BOOT_PROFILE_END("reticulum"); + + // Initialize LXMF + BOOT_PROFILE_START("lxmf"); + setup_lxmf(); + BOOT_PROFILE_END("lxmf"); + + // Initialize UI manager + BOOT_PROFILE_START("ui_manager"); + setup_ui_manager(); + BOOT_PROFILE_END("ui_manager"); + + // Register delivered callback to update message status in storage and UI + router->register_delivered_callback([](LXMF::LXMessage& msg) { + INFO(">>> APP DELIVERED CALLBACK ENTRY"); + Serial.flush(); + + INFO(">>> Getting message hash"); + Serial.flush(); + RNS::Bytes msg_hash = msg.hash(); + INFO("Delivery confirmed for message: " + msg_hash.toHex().substr(0, 16) + "..."); + Serial.flush(); + + // Update message state in storage + if (message_store) { + INFO(">>> Updating message state in store"); + Serial.flush(); + message_store->update_message_state(msg_hash, LXMF::Type::Message::DELIVERED); + INFO(">>> State updated, loading full message"); + Serial.flush(); + + // Load full message for UI update (need destination_hash) + LXMF::LXMessage full_msg = message_store->load_message(msg_hash); + INFO(">>> Message loaded, checking hash"); + Serial.flush(); + if (full_msg.hash()) { + INFO(">>> Setting state on full message"); + Serial.flush(); + full_msg.state(LXMF::Type::Message::DELIVERED); + if (ui_manager) { + INFO(">>> Calling UI manager on_message_delivered"); + Serial.flush(); + ui_manager->on_message_delivered(full_msg); + INFO(">>> UI manager returned"); + Serial.flush(); + } + } + } + INFO(">>> APP DELIVERED CALLBACK EXIT"); + Serial.flush(); + }); + + // Boot profiling complete + BOOT_PROFILE_COMPLETE(); +#ifdef BOOT_PROFILING_ENABLED + BOOT_PROFILE_SAVE(); +#endif + + INFO("\n"); + INFO("╔══════════════════════════════════════╗"); + INFO("║ System Ready - Enjoy! ║"); + INFO("╚══════════════════════════════════════╝"); + INFO(""); + + // Show startup message + INFO("Press any key to start messaging"); +} + +// Serial command buffer for web flasher detection +static String serial_cmd_buffer = ""; + +void loop() { + // Handle serial commands for web flasher detection + while (Serial.available()) { + char c = Serial.read(); + if (c == '\n' || c == '\r') { + serial_cmd_buffer.trim(); + if (serial_cmd_buffer == "VERSION") { + Serial.println(String(FIRMWARE_NAME) + " v" + FIRMWARE_VERSION); + } + serial_cmd_buffer = ""; + } else if (serial_cmd_buffer.length() < 32) { + serial_cmd_buffer += c; + } + } + + // Handle LVGL rendering (must be called frequently for smooth UI) + UI::LVGL::LVGLInit::task_handler(); + + // Process Reticulum + reticulum->loop(); + + // Process TCP interface + if (tcp_interface) { + tcp_interface->loop(); + } + + // Process LoRa interface + if (lora_interface) { + lora_interface->loop(); + } + + // Process BLE interface (skip if running on its own task) + if (ble_interface && ble_interface_impl && !ble_interface_impl->is_task_running()) { + ble_interface->loop(); + } + + // Process LXMF router queues + if (router) { + router->process_outbound(); + router->process_inbound(); + } + + // Update UI manager (processes LXMF messages) + if (ui_manager) { + ui_manager->update(); + } + + // Periodic announce (using interval from settings) + if (app_settings.announce_interval > 0) { // 0 = disabled + uint32_t announce_interval_ms = app_settings.announce_interval * 1000; + if (millis() - last_announce > announce_interval_ms) { + // Announce if any interface is online + bool has_online_interface = (tcp_interface && tcp_interface->online()) || + (lora_interface && lora_interface->online()) || + (ble_interface && ble_interface->online()); + if (router && has_online_interface) { + router->announce(); + last_announce = millis(); + INFO("Periodic announce sent (interval: " + std::to_string(app_settings.announce_interval) + "s)"); + } + } + } + + // Periodic propagation sync (fetch messages from prop node) + if (app_settings.sync_interval > 0 && router) { // 0 = disabled + bool should_sync = false; + uint32_t now = millis(); + + // Initial sync after boot delay + if (!initial_sync_done && now > INITIAL_SYNC_DELAY) { + should_sync = true; + initial_sync_done = true; + INFO("Initial propagation sync after boot delay"); + } + // Periodic sync + else if (initial_sync_done) { + uint32_t sync_interval_ms = app_settings.sync_interval * 1000; + if (now - last_sync > sync_interval_ms) { + should_sync = true; + } + } + + if (should_sync) { + // Only sync if TCP is online (propagation nodes need network) + bool tcp_online = tcp_interface && tcp_interface->online(); + if (tcp_online) { + router->request_messages_from_propagation_node(); + last_sync = now; + INFO("Periodic propagation sync (interval: " + std::to_string(app_settings.sync_interval / 60) + " min)"); + } + } + } + + // Check for TCP reconnection (handles rapid disconnect/reconnect) + if (tcp_interface_impl && tcp_interface_impl->check_reconnected()) { + INFO("TCP interface reconnected - sending announce"); + if (router) { + delay(500); // Brief stabilization delay + router->announce(); + last_announce = millis(); + } + last_tcp_online = true; + } + + // Periodic RNS status check (check all interfaces) + if (millis() - last_status_check > STATUS_CHECK_INTERVAL) { + last_status_check = millis(); + + bool tcp_online = tcp_interface && tcp_interface->online(); + bool lora_online = lora_interface && lora_interface->online(); + + // Check if status changed + if (tcp_online != last_tcp_online || lora_online != last_lora_online) { + last_tcp_online = tcp_online; + last_lora_online = lora_online; + + String status_str; + if (tcp_online && lora_online) { + status_str = "TCP+LoRa"; + } else if (tcp_online) { + status_str = "TCP: " + app_settings.tcp_host; + } else if (lora_online) { + status_str = "LoRa"; + } + + if (ui_manager) { + ui_manager->set_rns_status(tcp_online || lora_online, status_str); + } + + if (!tcp_online && !lora_online) { + WARNING("All RNS interfaces offline"); + } + } + + // Update BLE peer info on status screen (every 3 seconds) + static uint32_t last_ble_update = 0; + if (millis() - last_ble_update > 3000) { + last_ble_update = millis(); + if (ble_interface_impl && ui_manager && ui_manager->get_status_screen()) { + BLEInterface::PeerSummary peers[BLEInterface::MAX_PEER_SUMMARIES]; + size_t count = ble_interface_impl->getConnectedPeerSummaries(peers, BLEInterface::MAX_PEER_SUMMARIES); + + // Cast is safe - both structs have identical memory layout + ui_manager->get_status_screen()->set_ble_info( + reinterpret_cast(peers), count); + } + } + } + + // Read GPS data continuously (TinyGPSPlus needs constant feeding) + while (GPSSerial.available() > 0) { + gps.encode(GPSSerial.read()); + } + + // Screen timeout handling + if (app_settings.screen_timeout > 0) { // 0 = never timeout + uint32_t inactive_ms; + { + LVGL_LOCK(); + inactive_ms = lv_disp_get_inactive_time(NULL); + } + uint32_t timeout_ms = app_settings.screen_timeout * 1000; + + if (!screen_off && inactive_ms > timeout_ms) { + // Screen has been inactive long enough - turn off backlight + saved_brightness = app_settings.brightness; + ledcWrite(0, 0); // Turn off backlight (channel 0) + screen_off = true; + DEBUG("Screen timeout - backlight off"); + } + else if (screen_off && inactive_ms < 1000) { + // Activity detected - turn screen back on + ledcWrite(0, saved_brightness); + screen_off = false; + DEBUG("Activity detected - backlight on"); + } + } + + // Keyboard backlight timeout handling + if (app_settings.keyboard_light) { + uint32_t key_time = Keyboard::get_last_key_time(); + if (key_time > 0 && key_time > last_keypress_time) { + // New key detected + last_keypress_time = key_time; + if (!kb_light_on) { + Keyboard::backlight_on(); + kb_light_on = true; + } + } + + // Check for timeout + if (kb_light_on && (millis() - last_keypress_time > KB_LIGHT_TIMEOUT_MS)) { + Keyboard::backlight_off(); + kb_light_on = false; + } + } else { + // Ensure light is off when setting disabled + if (kb_light_on) { + Keyboard::backlight_off(); + kb_light_on = false; + } + } + + // Periodic heap monitoring (every 5 seconds) + static uint32_t last_heap_check = 0; + static uint32_t last_free_heap = 0; + static uint32_t last_table_check = 0; + if (millis() - last_heap_check > 5000) { + last_heap_check = millis(); + uint32_t free_heap = ESP.getFreeHeap(); + uint32_t min_heap = ESP.getMinFreeHeap(); + uint32_t max_block = ESP.getMaxAllocHeap(); + int32_t delta = (last_free_heap > 0) ? ((int32_t)free_heap - (int32_t)last_free_heap) : 0; + UBaseType_t stack_hwm = uxTaskGetStackHighWaterMark(NULL); + + Serial.printf("[HEAP] free=%u min=%u max_block=%u delta=%+d stack_hwm=%u\n", + free_heap, min_heap, max_block, delta, stack_hwm); + // PSRAM diagnostics + uint32_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + uint32_t psram_total = ESP.getPsramSize(); + uint32_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + uint32_t internal_max_block = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); + Serial.printf("[PSRAM] free=%u/%u [INTERNAL] free=%u max_block=%u\n", + psram_free, psram_total, internal_free, internal_max_block); + Serial.flush(); + + // Threshold warnings + if (free_heap < 20000) { + Serial.println("[HEAP] CRITICAL: Free heap below 20KB!"); + // Print Transport table sizes for debugging + Serial.printf("[TABLES] ann=%zu dest=%zu rev=%zu link=%zu held=%zu rate=%zu path=%zu\n", + RNS::Transport::announce_table_count(), + RNS::Transport::destination_table_count(), + RNS::Transport::reverse_table_count(), + RNS::Transport::link_table_count(), + RNS::Transport::held_announces_count(), + RNS::Transport::announce_rate_table_count(), + RNS::Transport::path_requests_count()); + Serial.printf("[TABLES] pend_link=%zu act_link=%zu rcpt=%zu pkt_hash=%zu iface=%zu dest_pool=%zu\n", + RNS::Transport::pending_links_count(), + RNS::Transport::active_links_count(), + RNS::Transport::receipts_count(), + RNS::Transport::packet_hashlist_count(), + RNS::Transport::interfaces_count(), + RNS::Transport::destinations_count()); + Serial.printf("[IDENTITY] known_dest=%zu known_ratch=%zu\n", + RNS::Identity::known_destinations_count(), + RNS::Identity::known_ratchets_count()); + } else if (free_heap < 50000) { + Serial.println("[HEAP] WARNING: Free heap below 50KB"); + } + + // Fragmentation warning (large gap between free heap and max allocatable block) + if (max_block < free_heap / 2) { + Serial.printf("[HEAP] WARNING: Fragmentation detected (max_block=%u, free=%u)\n", + max_block, free_heap); + } + + // Periodic table diagnostics (every 30 seconds) + if (millis() - last_table_check > 30000) { + last_table_check = millis(); + Serial.printf("[DIAG] ikd=%zu ikr=%zu ann=%zu dest=%zu pkt=%zu held=%zu rev=%zu link=%zu\n", + RNS::Identity::known_destinations_count(), + RNS::Identity::known_ratchets_count(), + RNS::Transport::announce_table_count(), + RNS::Transport::destination_table_count(), + RNS::Transport::packet_hashlist_count(), + RNS::Transport::held_announces_count(), + RNS::Transport::reverse_table_count(), + RNS::Transport::link_table_count()); + } + + last_free_heap = free_heap; + } + + // Small delay to prevent tight loop + delay(5); +}