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 <noreply@anthropic.com>
This commit is contained in:
torlando-tech
2026-02-06 19:39:52 -05:00
commit ac6ceca9f8
90 changed files with 28320 additions and 0 deletions

108
.github/workflows/release-firmware.yml vendored Normal file
View File

@@ -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 }}

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.pio/
.vscode/
*.pyc

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "deps/microReticulum"]
path = deps/microReticulum
url = https://github.com/torlando-tech/microReticulum.git
branch = feat/t-deck

661
LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.

1
deps/microReticulum vendored Submodule

Submodule deps/microReticulum added at ad1776dae6

563
docs/flasher/index.html Normal file
View File

@@ -0,0 +1,563 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pyxis T-Deck Flasher</title>
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--bg: #0f172a;
--card: #1e293b;
--text: #e2e8f0;
--muted: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem 1rem;
line-height: 1.6;
}
.container { max-width: 700px; margin: 0 auto; }
header { text-align: center; margin-bottom: 2.5rem; }
h1 { color: var(--primary); font-size: 2rem; margin-bottom: 0.5rem; }
.subtitle { color: var(--muted); font-size: 1.1rem; }
.card {
background: var(--card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.card h2 { font-size: 1.25rem; margin-bottom: 1rem; }
.flash-section { text-align: center; padding: 2rem; }
.status-box {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
text-align: left;
}
.status-box .label { color: var(--muted); font-size: 0.875rem; }
.status-box .value { font-size: 1.1rem; font-weight: 500; }
.status-detected { color: var(--success); }
.status-not-detected { color: var(--muted); }
.status-checking { color: var(--warning); }
.status-flashing { color: var(--primary); }
.button-group {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
button {
font-family: inherit;
font-size: 1rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: white; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.checkbox-group {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.checkbox-group input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.checkbox-group label { cursor: pointer; color: var(--danger); }
.hidden { display: none !important; }
.progress-container {
margin: 1.5rem 0;
text-align: left;
}
.progress-bar {
height: 24px;
background: rgba(0,0,0,0.3);
border-radius: 12px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--success));
border-radius: 12px;
transition: width 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
}
.progress-text {
font-size: 0.875rem;
color: var(--muted);
}
#log {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
font-family: monospace;
font-size: 0.8rem;
max-height: 200px;
overflow-y: auto;
text-align: left;
}
#log .log-entry { margin-bottom: 0.25rem; }
#log .log-info { color: var(--muted); }
#log .log-success { color: var(--success); }
#log .log-error { color: var(--danger); }
.version { margin-top: 1rem; font-size: 0.875rem; color: var(--muted); }
.requirements ul { list-style: none; padding-left: 0; }
.requirements li {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.requirements li:last-child { border-bottom: none; }
.warning {
background: rgba(245, 158, 11, 0.15);
border-left: 3px solid var(--warning);
padding: 1rem;
margin-top: 1rem;
border-radius: 0 8px 8px 0;
font-size: 0.9rem;
}
.warning strong { color: var(--warning); }
footer {
text-align: center;
margin-top: 2rem;
color: var(--muted);
font-size: 0.875rem;
}
footer a { color: var(--primary); text-decoration: none; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>Pyxis T-Deck Flasher</h1>
<p class="subtitle">Flash your LilyGO T-Deck Plus with Pyxis firmware</p>
</header>
<div class="card flash-section">
<h2>Install Firmware</h2>
<div class="status-box">
<div class="label">Device Status</div>
<div class="value" id="device-status">
<span class="status-not-detected">Not connected</span>
</div>
</div>
<div class="button-group">
<button id="connect-btn" class="btn-primary">Connect & Detect</button>
<button id="flash-btn" class="btn-primary hidden" disabled>Flash Firmware</button>
<button id="disconnect-btn" class="btn-danger hidden">Disconnect</button>
</div>
<div id="erase-option" class="checkbox-group hidden">
<input type="checkbox" id="erase-checkbox">
<label for="erase-checkbox">Full install (erase settings & messages)</label>
</div>
<div id="progress-container" class="progress-container hidden">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 0%">0%</div>
</div>
<div class="progress-text" id="progress-text">Preparing...</div>
</div>
<div id="log"></div>
<p class="version">Firmware version: <span id="firmware-version">dev</span></p>
</div>
<div class="card requirements">
<h2>Requirements</h2>
<ul>
<li><strong>Browser:</strong> Chrome, Edge, or Opera (Web Serial API required)</li>
<li><strong>Device:</strong> LilyGO T-Deck Plus (ESP32-S3, 8MB Flash)</li>
<li><strong>Cable:</strong> USB-C data cable (not charge-only)</li>
</ul>
</div>
<div class="card">
<h2>How It Works</h2>
<ul style="list-style: none; padding: 0;">
<li style="padding: 0.5rem 0;"><strong>1.</strong> Click "Connect & Detect" and select your T-Deck</li>
<li style="padding: 0.5rem 0;"><strong>2.</strong> Flasher checks if microReticulum is installed</li>
<li style="padding: 0.5rem 0;"><strong>3.</strong> Click "Flash Firmware" - settings preserved by default</li>
<li style="padding: 0.5rem 0;"><strong>4.</strong> Only check "Full install" for a clean wipe</li>
</ul>
<div class="warning">
<strong>Tip:</strong> Update mode only flashes the app, preserving your settings and messages.
</div>
</div>
<footer>
<p>
<a href="https://github.com/liamcottle/reticulum-meshchat" target="_blank">GitHub</a>
&nbsp;|&nbsp;
<a href="https://github.com/liamcottle/reticulum-meshchat/releases" target="_blank">Releases</a>
</p>
<p style="margin-top: 0.5rem;">Powered by esptool-js</p>
</footer>
</div>
<script type="module">
import { ESPLoader, Transport } from 'https://unpkg.com/esptool-js/bundle.js';
const connectBtn = document.getElementById('connect-btn');
const flashBtn = document.getElementById('flash-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const eraseOption = document.getElementById('erase-option');
const eraseCheckbox = document.getElementById('erase-checkbox');
const deviceStatus = document.getElementById('device-status');
const logDiv = document.getElementById('log');
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
let port = null;
let transport = null;
let esploader = null;
let isInstalled = false;
let detectedVersion = null;
let chip = null;
// Firmware file offsets
const FIRMWARE_FILES = {
full: [
{ offset: 0x0, name: 'bootloader.bin', path: 'firmware/bootloader.bin' },
{ offset: 0x8000, name: 'partitions.bin', path: 'firmware/partitions.bin' },
{ offset: 0xe000, name: 'boot_app0.bin', path: 'firmware/boot_app0.bin' },
{ offset: 0x10000, name: 'firmware.bin', path: 'firmware/firmware.bin' }
],
update: [
{ offset: 0x10000, name: 'firmware.bin', path: 'firmware/firmware.bin' }
]
};
function log(message, type = 'info') {
logDiv.classList.remove('hidden');
const entry = document.createElement('div');
entry.className = `log-entry log-${type}`;
const time = new Date().toLocaleTimeString();
entry.textContent = `[${time}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
}
function updateStatus(html) {
deviceStatus.innerHTML = html;
}
function setProgress(percent, text) {
progressContainer.classList.remove('hidden');
progressFill.style.width = `${percent}%`;
progressFill.textContent = `${Math.round(percent)}%`;
progressText.textContent = text;
}
async function connect() {
connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...';
log('Requesting serial port...');
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
log('Port opened, detecting firmware...');
updateStatus('<span class="status-checking">Detecting firmware...</span>');
// Detect if microReticulum is installed
await detectFirmware();
} catch (error) {
log(`Connection error: ${error.message}`, 'error');
updateStatus('<span class="status-not-detected">Connection failed</span>');
connectBtn.textContent = 'Connect & Detect';
connectBtn.disabled = false;
await cleanup();
}
}
async function detectFirmware() {
try {
// Wait for streams to be ready
await new Promise(r => setTimeout(r, 100));
if (!port.readable || !port.writable) {
throw new Error('Port streams not available');
}
// Send VERSION command
log('Sending VERSION command...');
const writer = port.writable.getWriter();
const encoder = new TextEncoder();
await writer.write(encoder.encode('VERSION\n'));
await new Promise(r => setTimeout(r, 200));
await writer.write(encoder.encode('VERSION\n'));
writer.releaseLock();
await new Promise(r => setTimeout(r, 300));
// Read response
const reader = port.readable.getReader();
const decoder = new TextDecoder();
let response = '';
const startTime = Date.now();
try {
while (Date.now() - startTime < 2000) {
const result = await Promise.race([
reader.read(),
new Promise(r => setTimeout(() => r({ done: true }), 500))
]);
if (result.done) break;
if (result.value) {
response += decoder.decode(result.value);
if (response.includes('microReticulum')) break;
}
}
} finally {
reader.releaseLock();
}
log(`Response: ${response.trim().substring(0, 100) || '(no response)'}`);
if (response.includes('microReticulum')) {
const match = response.match(/microReticulum v([\d.]+)/);
detectedVersion = match ? match[1] : 'unknown';
isInstalled = true;
updateStatus(`<span class="status-detected">microReticulum v${detectedVersion} - Update mode</span>`);
log(`Detected v${detectedVersion} - will preserve settings`, 'success');
flashBtn.textContent = 'Update Firmware';
} else {
isInstalled = false;
updateStatus('<span class="status-not-detected">Not installed - Install mode</span>');
log('microReticulum not detected');
flashBtn.textContent = 'Install Firmware';
}
eraseOption.classList.remove('hidden');
flashBtn.classList.remove('hidden');
flashBtn.disabled = false;
disconnectBtn.classList.remove('hidden');
connectBtn.classList.add('hidden');
} catch (error) {
log(`Detection error: ${error.message}`, 'error');
updateStatus('<span class="status-not-detected">Detection failed - Install mode</span>');
isInstalled = false;
flashBtn.textContent = 'Install Firmware';
eraseOption.classList.remove('hidden');
flashBtn.classList.remove('hidden');
flashBtn.disabled = false;
disconnectBtn.classList.remove('hidden');
connectBtn.classList.add('hidden');
}
}
async function flash() {
const useFullInstall = eraseCheckbox.checked || !isInstalled;
const files = useFullInstall ? FIRMWARE_FILES.full : FIRMWARE_FILES.update;
flashBtn.disabled = true;
disconnectBtn.disabled = true;
log(`Starting ${useFullInstall ? 'full install' : 'update'}...`);
updateStatus(`<span class="status-flashing">Flashing...</span>`);
try {
// Close current serial connection and reopen via Transport for bootloader
try { await port.close(); } catch (e) {}
log('Entering bootloader mode...');
transport = new Transport(port, true);
const flashOptions = {
transport,
baudrate: 921600,
romBaudrate: 115200,
};
esploader = new ESPLoader(flashOptions);
chip = await esploader.main();
log(`Connected to ${chip} bootloader`, 'success');
// Load firmware files
const fileArray = [];
let totalSize = 0;
for (const file of files) {
log(`Loading ${file.name}...`);
const response = await fetch(file.path);
if (!response.ok) throw new Error(`Failed to load ${file.name}`);
const arrayBuffer = await response.arrayBuffer();
// Convert to binary string as expected by esptool-js
const bytes = new Uint8Array(arrayBuffer);
let binaryString = '';
for (let i = 0; i < bytes.length; i++) {
binaryString += String.fromCharCode(bytes[i]);
}
fileArray.push({
data: binaryString,
address: file.offset
});
totalSize += arrayBuffer.byteLength;
log(`Loaded ${file.name} (${arrayBuffer.byteLength} bytes)`);
}
log(`Total size: ${totalSize} bytes`);
setProgress(0, 'Writing to flash...');
// Flash all files
log('Starting flash write...');
await esploader.writeFlash({
fileArray,
flashSize: 'keep',
flashMode: 'keep',
flashFreq: 'keep',
eraseAll: false,
compress: true,
reportProgress: (fileIndex, written, total) => {
const fileName = files[fileIndex]?.name || 'file';
const percent = (written / total) * 100;
setProgress(percent, `Writing ${fileName}... ${Math.round(percent)}%`);
if (written === total) {
log(`${fileName} complete`, 'success');
}
}
});
setProgress(100, 'Verifying...');
log('Flash complete!', 'success');
// Reset device to run new firmware
log('Resetting device...');
await transport.setDTR(false);
await transport.setRTS(true);
await new Promise(r => setTimeout(r, 100));
await transport.setRTS(false);
setProgress(100, 'Done!');
updateStatus('<span class="status-detected">Flash complete! Device is restarting...</span>');
await cleanup();
flashBtn.classList.add('hidden');
disconnectBtn.classList.add('hidden');
eraseOption.classList.add('hidden');
connectBtn.classList.remove('hidden');
connectBtn.textContent = 'Connect & Detect';
connectBtn.disabled = false;
} catch (error) {
log(`Flash error: ${error.message}`, 'error');
updateStatus('<span class="status-not-detected">Flash failed</span>');
setProgress(0, 'Failed');
flashBtn.disabled = false;
disconnectBtn.disabled = false;
}
}
async function cleanup() {
try {
if (transport) await transport.disconnect();
if (port) await port.close();
} catch (e) {}
transport = null;
esploader = null;
port = null;
}
async function disconnect() {
log('Disconnecting...');
await cleanup();
updateStatus('<span class="status-not-detected">Disconnected</span>');
flashBtn.classList.add('hidden');
disconnectBtn.classList.add('hidden');
eraseOption.classList.add('hidden');
progressContainer.classList.add('hidden');
connectBtn.classList.remove('hidden');
connectBtn.textContent = 'Connect & Detect';
connectBtn.disabled = false;
}
connectBtn.addEventListener('click', connect);
flashBtn.addEventListener('click', flash);
disconnectBtn.addEventListener('click', disconnect);
eraseCheckbox.addEventListener('change', () => {
if (isInstalled) {
flashBtn.textContent = eraseCheckbox.checked ? 'Full Install' : 'Update Firmware';
}
});
// Load manifest version
fetch('manifest-install.json')
.then(r => r.json())
.then(data => {
document.getElementById('firmware-version').textContent = data.version;
})
.catch(() => {});
</script>
</body>
</html>

View File

@@ -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 }
]
}
]
}

View File

@@ -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 }
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
#pragma once
#include "Interface.h"
#include "Identity.h"
#include "Bytes.h"
#include "Type.h"
#include "AutoInterfacePeer.h"
#ifdef ARDUINO
#include <WiFi.h>
#include <WiFiUdp.h>
#include <IPv6Address.h>
#include <lwip/ip6_addr.h>
#include <lwip/netdb.h>
#include <lwip/sockets.h>
#else
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#endif
#include <vector>
#include <deque>
#include <string>
#include <cstdint>
// 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<AutoInterfacePeer> _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<DequeEntry> _packet_deque;
// Receive buffer
RNS::Bytes _buffer;
};

View File

@@ -0,0 +1,74 @@
#pragma once
#include <string>
#include <cstdint>
#ifdef ARDUINO
#include <WiFi.h>
#include <IPv6Address.h>
#else
#include <netinet/in.h>
#include <arpa/inet.h>
#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
};

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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<uint16_t>((data_size + _payload_size - 1) / _payload_size);
}
std::vector<Bytes> BLEFragmenter::fragment(const Bytes& data, uint16_t sequence_base) {
std::vector<Bytes> 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<uint8_t>(type);
// Bytes 1-2: Sequence number (big-endian)
ptr[1] = static_cast<uint8_t>((sequence >> 8) & 0xFF);
ptr[2] = static_cast<uint8_t>(sequence & 0xFF);
// Bytes 3-4: Total fragments (big-endian)
ptr[3] = static_cast<uint8_t>((total_fragments >> 8) & 0xFF);
ptr[4] = static_cast<uint8_t>(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<Fragment::Type>(type_byte);
// Bytes 1-2: Sequence number (big-endian)
sequence = (static_cast<uint16_t>(ptr[1]) << 8) | static_cast<uint16_t>(ptr[2]);
// Bytes 3-4: Total fragments (big-endian)
total_fragments = (static_cast<uint16_t>(ptr[3]) << 8) | static_cast<uint16_t>(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

View File

@@ -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 <vector>
#include <cstdint>
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<Bytes> 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

View File

@@ -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<BLEIdentityManager*>(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<BLEIdentityManager*>(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<BLEIdentityManager*>(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

View File

@@ -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 <functional>
#include <cstdint>
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<void(
const Bytes& mac_address,
const Bytes& peer_identity,
bool is_central)>;
/**
* @brief Callback when handshake fails
*
* @param mac_address The peer's MAC address
* @param reason Description of the failure
*/
using HandshakeFailedCallback = std::function<void(
const Bytes& mac_address,
const std::string& reason)>;
/**
* @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<void(
const Bytes& old_mac,
const Bytes& new_mac,
const Bytes& identity)>;
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

View File

@@ -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 <Arduino.h>
#include <esp_heap_caps.h>
#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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<Bytes> 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<std::recursive_mutex> 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<std::recursive_mutex> lock(_mutex);
// Cast away const for read-only access to non-const getConnectedPeers()
auto& mutable_peer_manager = const_cast<BLE::BLEPeerManager&>(_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<std::string, float> BLEInterface::get_stats() const {
std::map<std::string, float> stats;
stats["central_connections"] = 0.0f;
stats["peripheral_connections"] = 0.0f;
try {
std::lock_guard<std::recursive_mutex> 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<BLE::BLEPeerManager&>(_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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<std::recursive_mutex> 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<int>(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<std::recursive_mutex> 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<std::recursive_mutex> lock(_mutex);
std::vector<Bytes> 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<std::recursive_mutex> 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<BLEInterface*>(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

View File

@@ -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 <map>
#include <mutex>
#ifdef ARDUINO
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#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<std::string, float> 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<RNS::Bytes, RNS::BLE::BLEFragmenter> _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<PendingHandshake> _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<PendingData> _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);
};

View File

@@ -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<int>(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<int>(duration * 1000)) + "ms, result: " +
std::to_string(static_cast<int>(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<GATTOperation> 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<int>(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<uint8_t>((mtu >> 8) & 0xFF);
ptr[1] = static_cast<uint8_t>(mtu & 0xFF);
return *this;
}
GATTOperationBuilder& GATTOperationBuilder::withTimeout(uint32_t timeout_ms) {
_op.timeout_ms = timeout_ms;
return *this;
}
GATTOperationBuilder& GATTOperationBuilder::withCallback(
std::function<void(OperationResult, const Bytes&)> callback) {
_op.callback = callback;
return *this;
}
GATTOperation GATTOperationBuilder::build() {
return std::move(_op);
}
}} // namespace RNS::BLE

View File

@@ -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 <queue>
#include <functional>
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<GATTOperation> _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<void(OperationResult, const Bytes&)> callback);
GATTOperation build();
private:
GATTOperation _op;
};
}} // namespace RNS::BLE

View File

@@ -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 <cmath>
#include <cstring>
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<int8_t>(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<int8_t>(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<BLEPeerManager*>(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<BLEPeerManager*>(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<PeerInfo*> BLEPeerManager::getConnectedPeers() {
std::vector<PeerInfo*> 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<PeerInfo*> BLEPeerManager::getAllPeers() {
std::vector<PeerInfo*> 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<int8_t>(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<float>(peer.connection_successes) /
static_cast<float>(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<float>((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<float>(rssi - Scoring::RSSI_MIN) /
static_cast<float>(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<BLEPeerManager*>(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<BLEPeerManager*>(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<BLEPeerManager*>(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

View File

@@ -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 <cstdint>
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<PeerInfo*> getConnectedPeers();
/**
* @brief Get all peers (for iteration)
*/
std::vector<PeerInfo*> 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

View File

@@ -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<NimBLEPlatform>();
#endif
#if defined(ESP32) && defined(USE_BLUEDROID)
case PlatformType::ESP_IDF:
INFO("BLEPlatformFactory: Creating Bluedroid platform");
return std::make_shared<BluedroidPlatform>();
#endif
#if defined(ZEPHYR) || defined(CONFIG_BT)
case PlatformType::ZEPHYR:
// Future: return std::make_shared<ZephyrPlatform>();
ERROR("BLEPlatformFactory: Zephyr platform not yet implemented");
return nullptr;
#endif
default:
ERROR("BLEPlatformFactory: No platform available for type " +
std::to_string(static_cast<int>(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

View File

@@ -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 <memory>
#include <vector>
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<IBLEPlatform>;
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<void(OperationResult, const Bytes&)> 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<ConnectionHandle> 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

View File

@@ -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

View File

@@ -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 <functional>
#include <cstdint>
#include <cstring>
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<void(const Bytes& peer_identity, const Bytes& packet)>;
/**
* @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<void(const Bytes& peer_identity, const std::string& reason)>;
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

View File

@@ -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 <functional>
#include <memory>
#include <string>
#include <cstdint>
#include <cstring>
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<uint8_t>(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<void(OperationResult, const Bytes&)> 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<void(const ScanResult& result)>;
using OnScanComplete = std::function<void()>;
// Connection callbacks (central mode - we initiated)
using OnConnected = std::function<void(const ConnectionHandle& conn)>;
using OnDisconnected = std::function<void(const ConnectionHandle& conn, uint8_t reason)>;
using OnMTUChanged = std::function<void(const ConnectionHandle& conn, uint16_t mtu)>;
using OnServicesDiscovered = std::function<void(const ConnectionHandle& conn, bool success)>;
// Data callbacks
using OnDataReceived = std::function<void(const ConnectionHandle& conn, const Bytes& data)>;
using OnNotifyEnabled = std::function<void(const ConnectionHandle& conn, bool enabled)>;
// Peripheral-mode callbacks (they connected to us)
using OnCentralConnected = std::function<void(const ConnectionHandle& conn)>;
using OnCentralDisconnected = std::function<void(const ConnectionHandle& conn)>;
using OnWriteReceived = std::function<void(const ConnectionHandle& conn, const Bytes& data)>;
using OnReadRequested = std::function<Bytes(const ConnectionHandle& conn)>;
}
//=============================================================================
// 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<uint16_t>((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

View File

@@ -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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 <esp_bt.h>
#include <esp_bt_main.h>
#include <esp_gap_ble_api.h>
#include <esp_gatts_api.h>
#include <esp_gattc_api.h>
#include <esp_bt_defs.h>
#include <esp_gatt_common_api.h>
#include <map>
#include <vector>
#include <queue>
#include <functional>
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<void(OperationResult, const Bytes&)> 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<ConnectionHandle> 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<uint16_t, BluedroidConnection> _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<uint16_t> _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

File diff suppressed because it is too large Load Diff

View File

@@ -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 <NimBLEDevice.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
// Undefine NimBLE's backward compatibility macros to avoid conflict with our types
#undef BLEAddress
#include <atomic>
#include <map>
#include <vector>
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<void(OperationResult, const Bytes&)> 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<ConnectionHandle> 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<uint16_t, NimBLEClient*> _clients;
// Connection tracking
std::map<uint16_t, ConnectionHandle> _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<std::string, NimBLEAdvertisedDevice> _discovered_devices;
// Insertion-order tracking for FIFO eviction of discovered devices
std::vector<std::string> _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<int> _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

188
lib/lv_conf.h Normal file
View File

@@ -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 <stdint.h>
/*====================
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 <Arduino.h>
#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 */

93
lib/lv_mem_hybrid.h Normal file
View File

@@ -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 <esp_heap_caps.h>
#include <stdlib.h>
#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

View File

@@ -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 <SPI.h>
#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);
}

View File

@@ -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 <RadioLib.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#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;
};

411
lib/tdeck_ui/Display.cpp Normal file
View File

@@ -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 <Wire.h>
#ifdef DISPLAY_TYPE_SH1106
#include <Adafruit_SH110X.h>
#elif defined(DISPLAY_TYPE_SSD1306)
#include <Adafruit_SSD1306.h>
#endif
#include <Adafruit_GFX.h>
#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

139
lib/tdeck_ui/Display.h Normal file
View File

@@ -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 <stdint.h>
#include <string>
// 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

View File

@@ -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 <stdint.h>
#ifdef ARDUINO
#ifdef ESP32
#include <pgmspace.h>
#elif defined(__AVR__)
#include <avr/pgmspace.h>
#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

View File

@@ -0,0 +1,171 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#ifndef HARDWARE_TDECK_CONFIG_H
#define HARDWARE_TDECK_CONFIG_H
#include <cstdint>
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

View File

@@ -0,0 +1,269 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "Display.h"
#ifdef ARDUINO
#include "Log.h"
#include <esp_heap_caps.h>
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

View File

@@ -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 <Arduino.h>
#include <SPI.h>
#include <lvgl.h>
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

View File

@@ -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

View File

@@ -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 <Arduino.h>
#include <Wire.h>
#include <lvgl.h>
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

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "SDLogger.h"
#ifdef ARDUINO
#include <Arduino.h>
#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

View File

@@ -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 <Log.h>
#ifdef ARDUINO
#include <SD.h>
#include <SPI.h>
#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

View File

@@ -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

View File

@@ -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 <Arduino.h>
#include <Wire.h>
#include <lvgl.h>
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

View File

@@ -0,0 +1,348 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "Trackball.h"
#ifdef ARDUINO
#include "Log.h"
#include <driver/gpio.h>
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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
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

View File

@@ -0,0 +1,8 @@
#include "Clipboard.h"
namespace UI {
String Clipboard::_content = "";
bool Clipboard::_has_content = false;
} // namespace UI

View File

@@ -0,0 +1,53 @@
#pragma once
#include <Arduino.h>
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

View File

@@ -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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
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

View File

@@ -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 <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
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

View File

@@ -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 <MsgPack.h>
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<uint8_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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <vector>
#include <functional>
#include <string>
#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<void(const RNS::Bytes& destination_hash)>;
using BackCallback = std::function<void()>;
using RefreshCallback = std::function<void()>;
using SendAnnounceCallback = std::function<void()>;
/**
* 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<AnnounceItem> _announces;
std::vector<lv_obj_t*> _announce_containers; // For focus group management
std::vector<RNS::Bytes> _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

View File

@@ -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 <MsgPack.h>
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<int>(::LXMF::Type::Message::DELIVERED));
item.failed = (meta.state == static_cast<int>(::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<int>(::LXMF::Type::Message::DELIVERED));
item.failed = (meta.state == static_cast<int>(::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<uint8_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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <vector>
#include <deque>
#include <map>
#include <functional>
#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<void()>;
using SendMessageCallback = std::function<void(const String& content)>;
/**
* 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<MessageItem> _messages;
// Map message hash to bubble row for targeted updates
std::map<RNS::Bytes, lv_obj_t*> _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<RNS::Bytes> _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

View File

@@ -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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <functional>
#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<void()>;
using SendCallback = std::function<void(const RNS::Bytes& dest_hash, const String& message)>;
/**
* 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

View File

@@ -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 <WiFi.h>
#include <MsgPack.h>
#include <TinyGPSPlus.h>
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<Bytes> 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<Bytes> 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<uint8_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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <vector>
#include <functional>
#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<void(const RNS::Bytes& peer_hash)>;
using ComposeCallback = std::function<void()>;
using SyncCallback = std::function<void()>;
using SettingsCallback = std::function<void()>;
using AnnouncesCallback = std::function<void()>;
using StatusCallback = std::function<void()>;
/**
* 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<ConversationItem> _conversations;
std::vector<lv_obj_t*> _conversation_containers; // For focus group management
std::vector<RNS::Bytes> _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

View File

@@ -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<PropagationNodesScreen*>(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<PropagationNodesScreen*>(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<PropagationNodesScreen*>(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<PropagationNodesScreen*>(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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <vector>
#include <functional>
#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<void(const RNS::Bytes& node_hash)>;
using BackCallback = std::function<void()>;
using SyncCallback = std::function<void()>;
using AutoSelectChangedCallback = std::function<void(bool enabled)>;
/**
* 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<NodeItem> _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

View File

@@ -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://<dest_hash>:<public_key>
// 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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <functional>
#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<void()>;
/**
* 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

File diff suppressed because it is too large Load Diff

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <Preferences.h>
#include <functional>
#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<void()>;
using SaveCallback = std::function<void(const AppSettings&)>;
using WifiReconnectCallback = std::function<void(const String&, const String&)>;
using BrightnessChangeCallback = std::function<void(uint8_t)>;
using PropagationNodesCallback = std::function<void()>;
/**
* 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

View File

@@ -0,0 +1,365 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "StatusScreen.h"
#include "Theme.h"
#ifdef ARDUINO
#include <WiFi.h>
#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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <functional>
#include <vector>
#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<void()>;
using ShareCallback = std::function<void()>;
/**
* 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

View File

@@ -0,0 +1,69 @@
#pragma once
#include <lvgl.h>
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

View File

@@ -0,0 +1,651 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "UIManager.h"
#ifdef ARDUINO
#include <lvgl.h>
#include <Preferences.h>
#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

View File

@@ -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 <Arduino.h>
#include <lvgl.h>
#include <functional>
#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

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#pragma once
#ifdef ARDUINO
#include <Arduino.h>
#include <lvgl.h>
#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

25
lib/tdeck_ui/library.json Normal file
View File

@@ -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": [
"+<Hardware/TDeck/*.cpp>",
"+<UI/*.cpp>",
"+<UI/LVGL/*.cpp>",
"+<UI/LXMF/*.cpp>"
],
"includeDir": "."
}
}

133
lib/tone/Tone.cpp Normal file
View File

@@ -0,0 +1,133 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "Tone.h"
#ifdef ARDUINO
#include <Arduino.h>
#include <driver/i2s.h>
#include <Hardware/TDeck/Config.h>
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

42
lib/tone/Tone.h Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
/**
* 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

5
lib/tone/library.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "tone",
"version": "1.0.0",
"description": "Simple I2S tone generator for T-Deck Plus speaker"
}

View File

@@ -0,0 +1,529 @@
#include "UniversalFileSystem.h"
#include <Utilities/OS.h>
#include <Log.h>
#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<std::string> UniversalFileSystem::list_directory(const char* directory_path) {
TRACE("list_directory: listing directory " + std::string(directory_path));
std::list<std::string> 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

View File

@@ -0,0 +1,187 @@
#pragma once
#include <FileSystem.h>
#include <FileStream.h>
#include <Bytes.h>
#ifdef ARDUINO
#ifdef BOARD_ESP32
//#include <FS.h>
#include <SPIFFS.h>
#elif BOARD_NRF52
//#include <Adafruit_LittleFS.h>
#include <InternalFileSystem.h>
using namespace Adafruit_LittleFS_Namespace;
#endif
#else
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <unistd.h>
#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<std::string> 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> _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
};

View File

@@ -0,0 +1,7 @@
{
"name": "universal_filesystem",
"version": "0.1.0",
"description": "Universal filesystem abstraction for SPIFFS/LittleFS",
"frameworks": ["arduino"],
"platforms": ["espressif32"]
}

7
partitions.csv Normal file
View File

@@ -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,
1 # ESP-IDF Partition Table for T-Deck Plus (8MB Flash)
2 # Name, Type, SubType, Offset, Size, Flags
3 nvs, data, nvs, 0x9000, 0x5000,
4 otadata, data, ota, 0xe000, 0x2000,
5 app0, app, ota_0, 0x10000, 0x300000,
6 app1, app, ota_1, 0x310000, 0x300000,
7 spiffs, data, spiffs, 0x610000, 0x1F0000,

147
platformio.ini Normal file
View File

@@ -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

78
sdkconfig.defaults Normal file
View File

@@ -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

105
src/HDLC.h Normal file
View File

@@ -0,0 +1,105 @@
#pragma once
#include "Bytes.h"
#include <stdint.h>
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<uint8_t>(ESC ^ ESC_MASK));
} else if (byte == FLAG) {
// Escape FLAG byte: 0x7E -> 0x7D 0x5E
result.append(ESC);
result.append(static_cast<uint8_t>(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<uint8_t>(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

496
src/TCPClientInterface.cpp Normal file
View File

@@ -0,0 +1,496 @@
#include "TCPClientInterface.h"
#include "HDLC.h"
#include <Transport.h>
#include <Log.h>
#include <memory>
#ifdef ARDUINO
// ESP32 lwIP socket headers
#include <lwip/sockets.h>
#include <lwip/netdb.h>
#else
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#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<uint32_t>(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<int>(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<int>(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<size_t>(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();
}
}

124
src/TCPClientInterface.h Normal file
View File

@@ -0,0 +1,124 @@
#pragma once
#include "Interface.h"
#include "Bytes.h"
#include "Type.h"
#ifdef ARDUINO
#include <WiFi.h>
#include <WiFiClient.h>
#else
#include <netinet/in.h>
#endif
#include <stdint.h>
#include <string>
#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
};

1437
src/main.cpp Normal file

File diff suppressed because it is too large Load Diff