mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-03-30 13:45:38 +00:00
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:
108
.github/workflows/release-firmware.yml
vendored
Normal file
108
.github/workflows/release-firmware.yml
vendored
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
*.pyc
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal 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
661
LICENSE
Normal 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
1
deps/microReticulum
vendored
Submodule
Submodule deps/microReticulum added at ad1776dae6
563
docs/flasher/index.html
Normal file
563
docs/flasher/index.html
Normal 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>
|
||||
|
|
||||
<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>
|
||||
16
docs/flasher/manifest-install.json
Normal file
16
docs/flasher/manifest-install.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
13
docs/flasher/manifest-update.json
Normal file
13
docs/flasher/manifest-update.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1537
lib/auto_interface/AutoInterface.cpp
Normal file
1537
lib/auto_interface/AutoInterface.cpp
Normal file
File diff suppressed because it is too large
Load Diff
175
lib/auto_interface/AutoInterface.h
Normal file
175
lib/auto_interface/AutoInterface.h
Normal 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;
|
||||
};
|
||||
74
lib/auto_interface/AutoInterfacePeer.h
Normal file
74
lib/auto_interface/AutoInterfacePeer.h
Normal 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
|
||||
};
|
||||
19
lib/auto_interface/auto_config.h.example
Normal file
19
lib/auto_interface/auto_config.h.example
Normal 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
|
||||
15
lib/auto_interface/library.json
Normal file
15
lib/auto_interface/library.json
Normal 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"
|
||||
}
|
||||
}
|
||||
177
lib/ble_interface/BLEFragmenter.cpp
Normal file
177
lib/ble_interface/BLEFragmenter.cpp
Normal 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
|
||||
125
lib/ble_interface/BLEFragmenter.h
Normal file
125
lib/ble_interface/BLEFragmenter.h
Normal 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
|
||||
493
lib/ble_interface/BLEIdentityManager.cpp
Normal file
493
lib/ble_interface/BLEIdentityManager.cpp
Normal 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
|
||||
352
lib/ble_interface/BLEIdentityManager.h
Normal file
352
lib/ble_interface/BLEIdentityManager.h
Normal 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
|
||||
946
lib/ble_interface/BLEInterface.cpp
Normal file
946
lib/ble_interface/BLEInterface.cpp
Normal 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
|
||||
280
lib/ble_interface/BLEInterface.h
Normal file
280
lib/ble_interface/BLEInterface.h
Normal 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);
|
||||
};
|
||||
227
lib/ble_interface/BLEOperationQueue.cpp
Normal file
227
lib/ble_interface/BLEOperationQueue.cpp
Normal 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
|
||||
144
lib/ble_interface/BLEOperationQueue.h
Normal file
144
lib/ble_interface/BLEOperationQueue.h
Normal 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
|
||||
870
lib/ble_interface/BLEPeerManager.cpp
Normal file
870
lib/ble_interface/BLEPeerManager.cpp
Normal 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
|
||||
483
lib/ble_interface/BLEPeerManager.h
Normal file
483
lib/ble_interface/BLEPeerManager.h
Normal 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
|
||||
69
lib/ble_interface/BLEPlatform.cpp
Normal file
69
lib/ble_interface/BLEPlatform.cpp
Normal 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
|
||||
367
lib/ble_interface/BLEPlatform.h
Normal file
367
lib/ble_interface/BLEPlatform.h
Normal 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
|
||||
326
lib/ble_interface/BLEReassembler.cpp
Normal file
326
lib/ble_interface/BLEReassembler.cpp
Normal 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
|
||||
199
lib/ble_interface/BLEReassembler.h
Normal file
199
lib/ble_interface/BLEReassembler.h
Normal 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
|
||||
502
lib/ble_interface/BLETypes.h
Normal file
502
lib/ble_interface/BLETypes.h
Normal 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
|
||||
15
lib/ble_interface/library.json
Normal file
15
lib/ble_interface/library.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1963
lib/ble_interface/platforms/BluedroidPlatform.cpp
Normal file
1963
lib/ble_interface/platforms/BluedroidPlatform.cpp
Normal file
File diff suppressed because it is too large
Load Diff
330
lib/ble_interface/platforms/BluedroidPlatform.h
Normal file
330
lib/ble_interface/platforms/BluedroidPlatform.h
Normal 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
|
||||
2120
lib/ble_interface/platforms/NimBLEPlatform.cpp
Normal file
2120
lib/ble_interface/platforms/NimBLEPlatform.cpp
Normal file
File diff suppressed because it is too large
Load Diff
380
lib/ble_interface/platforms/NimBLEPlatform.h
Normal file
380
lib/ble_interface/platforms/NimBLEPlatform.h
Normal 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
188
lib/lv_conf.h
Normal 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
93
lib/lv_mem_hybrid.h
Normal 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
|
||||
288
lib/sx1262_interface/SX1262Interface.cpp
Normal file
288
lib/sx1262_interface/SX1262Interface.cpp
Normal 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);
|
||||
}
|
||||
105
lib/sx1262_interface/SX1262Interface.h
Normal file
105
lib/sx1262_interface/SX1262Interface.h
Normal 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
411
lib/tdeck_ui/Display.cpp
Normal 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
139
lib/tdeck_ui/Display.h
Normal 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
|
||||
226
lib/tdeck_ui/DisplayGraphics.h
Normal file
226
lib/tdeck_ui/DisplayGraphics.h
Normal 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
|
||||
171
lib/tdeck_ui/Hardware/TDeck/Config.h
Normal file
171
lib/tdeck_ui/Hardware/TDeck/Config.h
Normal 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
|
||||
269
lib/tdeck_ui/Hardware/TDeck/Display.cpp
Normal file
269
lib/tdeck_ui/Hardware/TDeck/Display.cpp
Normal 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
|
||||
153
lib/tdeck_ui/Hardware/TDeck/Display.h
Normal file
153
lib/tdeck_ui/Hardware/TDeck/Display.h
Normal 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
|
||||
293
lib/tdeck_ui/Hardware/TDeck/Keyboard.cpp
Normal file
293
lib/tdeck_ui/Hardware/TDeck/Keyboard.cpp
Normal 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
|
||||
166
lib/tdeck_ui/Hardware/TDeck/Keyboard.h
Normal file
166
lib/tdeck_ui/Hardware/TDeck/Keyboard.h
Normal 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
|
||||
193
lib/tdeck_ui/Hardware/TDeck/SDLogger.cpp
Normal file
193
lib/tdeck_ui/Hardware/TDeck/SDLogger.cpp
Normal 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
|
||||
89
lib/tdeck_ui/Hardware/TDeck/SDLogger.h
Normal file
89
lib/tdeck_ui/Hardware/TDeck/SDLogger.h
Normal 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
|
||||
323
lib/tdeck_ui/Hardware/TDeck/Touch.cpp
Normal file
323
lib/tdeck_ui/Hardware/TDeck/Touch.cpp
Normal 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
|
||||
120
lib/tdeck_ui/Hardware/TDeck/Touch.h
Normal file
120
lib/tdeck_ui/Hardware/TDeck/Touch.h
Normal 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
|
||||
348
lib/tdeck_ui/Hardware/TDeck/Trackball.cpp
Normal file
348
lib/tdeck_ui/Hardware/TDeck/Trackball.cpp
Normal 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
|
||||
132
lib/tdeck_ui/Hardware/TDeck/Trackball.h
Normal file
132
lib/tdeck_ui/Hardware/TDeck/Trackball.h
Normal 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
|
||||
8
lib/tdeck_ui/UI/Clipboard.cpp
Normal file
8
lib/tdeck_ui/UI/Clipboard.cpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#include "Clipboard.h"
|
||||
|
||||
namespace UI {
|
||||
|
||||
String Clipboard::_content = "";
|
||||
bool Clipboard::_has_content = false;
|
||||
|
||||
} // namespace UI
|
||||
53
lib/tdeck_ui/UI/Clipboard.h
Normal file
53
lib/tdeck_ui/UI/Clipboard.h
Normal 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
|
||||
330
lib/tdeck_ui/UI/LVGL/LVGLInit.cpp
Normal file
330
lib/tdeck_ui/UI/LVGL/LVGLInit.cpp
Normal 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
|
||||
153
lib/tdeck_ui/UI/LVGL/LVGLInit.h
Normal file
153
lib/tdeck_ui/UI/LVGL/LVGLInit.h
Normal 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
|
||||
80
lib/tdeck_ui/UI/LVGL/LVGLLock.h
Normal file
80
lib/tdeck_ui/UI/LVGL/LVGLLock.h
Normal 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
|
||||
456
lib/tdeck_ui/UI/LXMF/AnnounceListScreen.cpp
Normal file
456
lib/tdeck_ui/UI/LXMF/AnnounceListScreen.cpp
Normal 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
|
||||
154
lib/tdeck_ui/UI/LXMF/AnnounceListScreen.h
Normal file
154
lib/tdeck_ui/UI/LXMF/AnnounceListScreen.h
Normal 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
|
||||
686
lib/tdeck_ui/UI/LXMF/ChatScreen.cpp
Normal file
686
lib/tdeck_ui/UI/LXMF/ChatScreen.cpp
Normal 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
|
||||
189
lib/tdeck_ui/UI/LXMF/ChatScreen.h
Normal file
189
lib/tdeck_ui/UI/LXMF/ChatScreen.h
Normal 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
|
||||
320
lib/tdeck_ui/UI/LXMF/ComposeScreen.cpp
Normal file
320
lib/tdeck_ui/UI/LXMF/ComposeScreen.cpp
Normal 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
|
||||
129
lib/tdeck_ui/UI/LXMF/ComposeScreen.h
Normal file
129
lib/tdeck_ui/UI/LXMF/ComposeScreen.h
Normal 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
|
||||
723
lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp
Normal file
723
lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp
Normal 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
|
||||
233
lib/tdeck_ui/UI/LXMF/ConversationListScreen.h
Normal file
233
lib/tdeck_ui/UI/LXMF/ConversationListScreen.h
Normal 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
|
||||
418
lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.cpp
Normal file
418
lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.cpp
Normal 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
|
||||
174
lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.h
Normal file
174
lib/tdeck_ui/UI/LXMF/PropagationNodesScreen.h
Normal 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
|
||||
180
lib/tdeck_ui/UI/LXMF/QRScreen.cpp
Normal file
180
lib/tdeck_ui/UI/LXMF/QRScreen.cpp
Normal 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
|
||||
109
lib/tdeck_ui/UI/LXMF/QRScreen.h
Normal file
109
lib/tdeck_ui/UI/LXMF/QRScreen.h
Normal 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
|
||||
1439
lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp
Normal file
1439
lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp
Normal file
File diff suppressed because it is too large
Load Diff
347
lib/tdeck_ui/UI/LXMF/SettingsScreen.h
Normal file
347
lib/tdeck_ui/UI/LXMF/SettingsScreen.h
Normal 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
|
||||
365
lib/tdeck_ui/UI/LXMF/StatusScreen.cpp
Normal file
365
lib/tdeck_ui/UI/LXMF/StatusScreen.cpp
Normal 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
|
||||
171
lib/tdeck_ui/UI/LXMF/StatusScreen.h
Normal file
171
lib/tdeck_ui/UI/LXMF/StatusScreen.h
Normal 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
|
||||
69
lib/tdeck_ui/UI/LXMF/Theme.h
Normal file
69
lib/tdeck_ui/UI/LXMF/Theme.h
Normal 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
|
||||
651
lib/tdeck_ui/UI/LXMF/UIManager.cpp
Normal file
651
lib/tdeck_ui/UI/LXMF/UIManager.cpp
Normal 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
|
||||
227
lib/tdeck_ui/UI/LXMF/UIManager.h
Normal file
227
lib/tdeck_ui/UI/LXMF/UIManager.h
Normal 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
|
||||
70
lib/tdeck_ui/UI/TextAreaHelper.h
Normal file
70
lib/tdeck_ui/UI/TextAreaHelper.h
Normal 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
25
lib/tdeck_ui/library.json
Normal 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
133
lib/tone/Tone.cpp
Normal 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
42
lib/tone/Tone.h
Normal 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
5
lib/tone/library.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "tone",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple I2S tone generator for T-Deck Plus speaker"
|
||||
}
|
||||
529
lib/universal_filesystem/UniversalFileSystem.cpp
Normal file
529
lib/universal_filesystem/UniversalFileSystem.cpp
Normal 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
|
||||
187
lib/universal_filesystem/UniversalFileSystem.h
Normal file
187
lib/universal_filesystem/UniversalFileSystem.h
Normal 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
|
||||
|
||||
};
|
||||
7
lib/universal_filesystem/library.json
Normal file
7
lib/universal_filesystem/library.json
Normal 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
7
partitions.csv
Normal 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,
|
||||
|
147
platformio.ini
Normal file
147
platformio.ini
Normal 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
78
sdkconfig.defaults
Normal 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
105
src/HDLC.h
Normal 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
496
src/TCPClientInterface.cpp
Normal 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
124
src/TCPClientInterface.h
Normal 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
1437
src/main.cpp
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user