Compare commits

..
83 changed files with 1670 additions and 13155 deletions

View file

@ -1,20 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build firmware
run: pio run

View file

@ -1,77 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build firmware
run: pio run
- name: Copy firmware to Android assets
run: bash tools/copy_firmware.sh
- name: Decode keystore
run: |
mkdir -p android/keystore
echo "${{ secrets.KEYSTORE_B64 }}" | base64 -d > android/keystore/sylda.jks
- name: Write signing.properties
run: |
cat > android/signing.properties << 'PROPS'
KEYSTORE_PATH=keystore/sylda.jks
KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASS }}
KEY_ALIAS=${{ secrets.KEY_ALIAS }}
KEY_PASSWORD=${{ secrets.KEY_PASS }}
PROPS
- name: Build release APK + AAB
working-directory: android
run: ./gradlew assembleRelease bundleRelease
env:
ANDROID_HOME: ${{ vars.ANDROID_HOME || '/home/linuxbrew/.linuxbrew/share/android-commandlinetools' }}
- name: Verify version consistency
run: bash tools/check_versions.sh
- name: Gather release artifacts
run: |
mkdir -p release/artifacts
cp android/app/build/outputs/apk/release/app-release.apk release/artifacts/
cp android/app/build/outputs/bundle/release/app-release.aab release/artifacts/
cp android/app/src/main/assets/firmware/server/firmware.bin release/artifacts/server-firmware.bin
cp android/app/src/main/assets/firmware/client/firmware.bin release/artifacts/client-firmware.bin
cd release/artifacts
sha256sum * > checksums.txt
- name: Create Forgejo release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
API="https://git.eddydevink.nl/api/v1/repos/eddy/Project_SylDa/releases"
RELEASE=$(curl -s -X POST \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\"}" \
"$API")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Created release $TAG (id=$RELEASE_ID)"
for file in release/artifacts/*; do
name=$(basename "$file")
echo "Uploading $name..."
curl -s -X POST \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file" \
"$API/$RELEASE_ID/assets?name=$name" > /dev/null
done
echo "Release $TAG published with $(ls release/artifacts | wc -l) assets"

33
.gitignore vendored
View file

@ -1,7 +1,3 @@
# Generated from version.txt
src/version.h
android/app/src/main/java/nl/eddydevink/sylda/SylDaVersions.kt
# PlatformIO
.pio/
.pioenvs/
@ -23,10 +19,6 @@ local.properties
*.ap_
*.dex
# Android signing (contains secrets)
android/keystore/
android/signing.properties
# OS
.DS_Store
Thumbs.db
@ -37,28 +29,3 @@ Thumbs.db
*.swp
*.swo
*~
# Git worktrees
.worktrees/
# Research/documentation (not part of the project code)
appstore.md
codereview.md
DC3-24V*.md
DC3-24V*.pdf
esplibalternatief.md
docs/handleiding.md
release/
releases/
# Test/debug firmware files
src/blink.cpp
src/c1.cpp
src/get_mac.cpp
src/min_cli.cpp
src/min_srv.cpp
src/s1.cpp
src/test_*.cpp
# Python cache
scripts/__pycache__/

View file

@ -1,50 +0,0 @@
# Project_SylDa
M5Stack Atom S3 Lite (ESP32-S3), 2 relais, 2 touch sensors (HS-S43A), BLE, Android app USB OTG.
## Hardware
- **Board:** M5Stack Atom S3 Lite (ESP32-S3)
- **Pinnen:** G1=RELAY1, G2=RELAY2, G8=TOUCH1, G6=TOUCH2, G41=BUTTON (active low), G46=LED
- **Touch:** HS-S43A Digital Capacitive Switch Module (openelab.io). **Actief LOW** — pin GND bij aanraking, HIGH bij geen aanraking. Pin mode: `INPUT_PULLUP` (zonder sensor: pin HIGH → `t1Active=true` → `!t1Active=false` → relais UIT). Logica: `relayState = !tActive || btnActive`. **NOOIT de `!` weghalen.**
- **BLE:** "SylDa_Server", service `4fafc201-1fb5-459e-8fcc-c5c9c331914b`
- **BLE cmds:** 0x00=relay1 OFF, 0x01=ON, 0x02=relay2 ON, 0x03=OFF, HMAC_PKT_SIZE=37 (1 cmd + 4 seq + 32 hmac)
- **NVS:** namespace `hmac`, key `key` — HMAC key 32 bytes, NVS op 0x9000-0xDFFF (partitie tabel)
## Workspace
`/home/eddy/src/Project_SylDa/` — Atom S3 Lite firmware (server + client) + Android app
## Firmware — `src/main.cpp`
- PlatformIO, `platform = espressif32@6.7.0`, board `esp32-s3-devkitc-1`
- CDC USB serial (`ARDUINO_USB_CDC_ON_BOOT=1`), QIO QSPI flash
- **Upload:** `pio run -t upload` (via `--no-stub`). Poort: **NOOIT `/dev/ttyACM0`** (dat is het Atreus toetsenbord). ESP32 herkennen via `ls /dev/serial/by-id/ | grep -i espressif` en die poort gebruiken. PlatformIO: pas `upload_port` in `platformio.ini` aan naar de gevonden poort.
- **Direct flashen (handmatig):** `~/.platformio/packages/tool-esptoolpy/esptool.py --no-stub --chip esp32s3 --port /dev/ttyACM1 --baud 115200 --before default_reset --after hard_reset write_flash --flash_mode keep --flash_size keep 0x0 .pio/build/s3lite-server/bootloader.bin 0x8000 .pio/build/s3lite-server/partitions.bin 0xE000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin 0x10000 .pio/build/s3lite-server/firmware.bin`
- NVS: namespace "hmac", key "key" (32 bytes HMAC)
- BLE advertising "SylDa_Server", HMAC-verified relay commands
## Android app — `android/`
- CDC key exchange (geen FTDI), 2 relay knoppen (touch-to-hold), byte commands
- Key exchange: SettingsActivity via USB CDC (`UsbSerialTransport`)
- Flash: EspFlasher via USB CDC, `eraseFlash()` VERWIJDERD (NVS blijft behouden)
- Partitie layout flash: bootloader 0x0, partitions 0x8000, boot_app0 0xE000, firmware 0x10000
- BLE scan: 30s timeout, auto-retry 3x, reconnect na disconnect
- `deviceId` = UUID van ESP, `hmacKey` = HMAC key uit key exchange
- Bestanden: `MainActivity.kt`, `SettingsActivity.kt`, `EspFlasher.kt`, `UsbSerialTransport.kt`, `PrefsHelper.kt`, `UsbUtils.kt`
- Assets: `firmware/bootloader.bin`, `partitions.bin`, `boot_app0.bin`, `firmware.bin`
## Versiebeheer
- **Single source of truth:** `version.txt` (regels `server=N` en `client=N`)
- `src/version.h` wordt **automatisch gegenereerd** uit `version.txt` door PlatformIO pre-build script
- Nooit `version.h` handmatig bewerken — altijd `version.txt` aanpassen en `pio run` laten regenereren
- Firmwareversie ophogen: bump het nummer in `version.txt`, commit, rebuild firmware, kopieer `.bin` naar Android assets
## ADB — telefoon
```
adb connect 192.168.1.43:5555
adb -s 192.168.1.43:5555 install -r app/build/outputs/apk/debug/app-debug.apk
```

674
LICENSE
View file

@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 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 General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is 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. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
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.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
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 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. Use with the GNU Affero General Public License.
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 Affero 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 special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU 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 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 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 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 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 General Public License for more details.
You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
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 GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

596
README.md
View file

@ -1,504 +1,370 @@
*Dit project is vibecoded met DeepSeek v4 Pro en OpenCode 1.18.5*
# Sylvia Touch Relay
# Project SylDa
BLE-gestuurde relais-switch voor de M5Stack Atom VoiceS3R (ESP32-S3) met een bijbehorende Android-app. Het relais kan worden bediend via een capacitieve touch-sensor (open-drain) óf via Bluetooth Low Energy vanaf een Android-telefoon.
BLE-gestuurde relais-switch voor de M5Stack Atom S3 Lite (ESP32-S3) met een bijbehorende Android-app. De relais kunnen worden bediend via capacitieve touch-sensoren (HS-S43A) óf via Bluetooth Low Energy vanaf een Android-telefoon.
De BLE-verbinding is beveiligd met **ECDH (secp256r1) sleuteluitwisseling** en **AES-256-GCM encryptie** (authenticiteit + confidentiality). Een **PSK** (pre-shared key, via USB provisioning) wordt gebruikt voor ECDH pubkey HMAC-verificatie, niet direct voor command authenticatie. Sequence counters bieden replay-protectie. Eerdere versies gebruikten HMAC-SHA256 zonder encryptie — ECDH+AES-GCM is vanaf firmware server v16 / client v3.
De BLE-verbinding is beveiligd met **twee lagen**: BLE Just Works pairing (link-layer encryptie) en **HMAC-SHA256 authenticatie** (application-level handtekening met replay-protectie).
## Hardware
| Component | Details |
|---|---|
| Relais 1 | Op **G1** |
| Relais 2 | Op **G2** |
| Touch sensor 1 | HS-S43A Digital Capacitive Switch Module op **G8** (actief LOW) |
| Touch sensor 2 | HS-S43A Digital Capacitive Switch Module op **G6** (actief LOW) |
| Knop | Op **G41** (active low, interne Atom S3 Lite knop) |
| Status LED | Op **G35** (WS2812 RGB extern, rood = geen verbinding, geel = verbinding < 60 s, uit = stabiel) |
| Board | M5Stack Atom VoiceS3R (SKU: C126-ECHO) |
| SoC | ESP32-S3-PICO-1-N8R8 (8MB Flash, 8MB PSRAM) |
| Touch sensor | Open-drain sensor op **G38** (3.3V, interne pullup) |
| Relais / LED | Op **G1** (3.3V GPIO output) |
| Status LED | Op **G46** (knippert 3x bij opstarten) |
### Pinout
### Pinout (Atom VoiceS3R onderste header)
```
G1 = RELAY1
G2 = RELAY2
G6 = TOUCH2 (INPUT_PULLUP, actief LOW)
G8 = TOUCH1 (INPUT_PULLUP, actief LOW)
G41 = BUTTON (INPUT, active low)
G35 = LED (WS2812 RGB, OUTPUT)
G38 G39 3.3V GND 5V G5 G6 G7 G8
```
- **G38** — Touch sensor input (active LOW, interne pullup ingeschakeld)
- **G1** — Relais output (HIGH = aan, LOW = uit)
- **G46** — Status LED (3x knipperen bij opstarten, daarna uit)
### Aansluiting
```
Touch sensor GND → GND
Touch sensor OUT → G8 / G6
Touch sensor VCC → 3.3V
Sensor GND → GND
Sensor OUT → G38
Sensor VCC → 3.3V
Relais + → G1 / G2
Relais - → GND
Relais + → G1
Relais - → GND
```
> **HS-S43A touch sensor:** Digitale output (actief LOW — pin naar GND bij aanraking). Gebruik `INPUT_PULLUP` modus. De sensor trekt de pin actief laag bij detectie.
## LED-signalering
De LED fungeert als debug-indicator en schakelt uit na 60 seconden stabiele verbinding:
| State | LED | Prioriteit |
|-------|-----|-----------|
| OTA in progress | Groen knipperend (100 ms) | 1 (hoogst) |
| OTA completed, wacht op confirm | Geel vast | 1 |
| Geen verbinding | Rood | 2 |
| Verbinding actief (< 60 s) | Geel | 3 |
| Verbinding stabiel (>= 60 s) | Uit | 4 (laagst) |
| Relais-flash overlay | Groen 150 ms | overlay |
De relais-flash toont kort groen bij een verandering in relais-status, waarna de LED terugkeert naar de verbindingskleur.
> **Let op:** De ESP32-S3 interne pullup is ~45kΩ. Bij lange draden of elektrische ruis een externe 4.7kΩ–10kΩ pullup naar 3.3V plaatsen.
## Beveiliging
De relay is beveiligd tegen ongeautoriseerde toegang via ECDH (Elliptic Curve Diffie-Hellman over secp256r1) met AES-256-GCM encryptie op applicatieniveau.
De relay is beveiligd tegen ongeautoriseerde toegang via twee onafhankelijke lagen:
### ECDH + AES-256-GCM
### 1. BLE Just Works Pairing (link-layer encryptie)
- Zowel de firmware als de Android app delen een **32-byte PSK** (pre-shared key), die via USB provisioning op beide apparaten terechtkomt
- Bij BLE-connectie wordt een **ECDH sleuteluitwisseling** uitgevoerd:
1. Client en server genereren elk een secp256r1 sleutelpaar
2. Ze sturen hun public key (65 bytes) + HMAC-SHA256(PSK, public key) (32 bytes) naar elkaar (**ECDH packet: 97 bytes**)
3. De ontvanger verifieert de HMAC met de PSK — ongeautoriseerde apparaten worden hier al geweerd
4. Uit de eigen private key + peer public key wordt een shared secret berekend
5. `SHA-256(shared secret)` levert de **AES-256 sleutel** op
- Elk relay-commando wordt daarna versleuteld met **AES-256-GCM** (authenticity + confidentiality)
- Een **sequence counter** (uint32, big-endian) voorkomt replay-aanvallen: elk packet moet een strikt oplopend volgnummer hebben
- GCM biedt zowel encryptie als integriteitsverificatie (AEAD)
- De ESP32 vereist **bonding** voordat de characteristic geschreven kan worden
- IO capability: `ESP_IO_CAP_NONE` (Just Works — geen passcode nodig)
- Authentication mode: `ESP_LE_AUTH_REQ_SC_BOND` (LE Secure Connections met bonding)
- De characteristic heeft `ESP_GATT_PERM_WRITE_ENCRYPTED` — writes worden alleen geaccepteerd over een versleutelde link
- Na de eerste keer paren onthoudt de telefoon de bonding; bij herverbinding wordt de link automatisch versleuteld
#### AES-GCM packet formaat (29 bytes)
### 2. HMAC-SHA256 Authenticatie (application-level)
Omdat BLE Just Works geen MITM-bescherming biedt, is er een tweede laag toegevoegd:
- Zowel de ESP firmware als de Android app delen een **32-byte shared secret** (HMAC key), die via USB provisioning op beide apparaten terechtkomt
- Elk commando wordt ondertekend met `HMAC-SHA256(key, command || sequence)`
- De ESP verifieert de handtekening voordat het relais schakelt
- Een **sequence counter** voorkomt replay-aanvallen: elk packet moet een strikt oplopend volgnummer hebben
#### Packet formaat (37 bytes)
```
Offset Grootte Veld Beschrijving
0 4 bytes sequence Big-endian uint32, strikt oplopend (start bij 1)
4 8 bytes nonce Random nonce voor AES-GCM
12 1 byte ciphertext 1 byte plaintext (command), versleuteld
13 16 bytes tag GCM authenticatie-tag
Offset Grootte Veld Beschrijving
0 1 byte command 0x01 = relais aan, 0x00 = relais uit
1 4 bytes sequence Big-endian uint32, strikt oplopend (start bij 1)
5 32 bytes hmac HMAC-SHA256(key, command || sequence)
```
#### ECDH packet formaat (97 bytes)
De sequence counter reset naar 0 bij elke (re)connectie op de ESP. De app start bij 1 om te zorgen dat het eerste packet altijd geaccepteerd wordt (`1 > 0`). Replay van een eerder packet wordt geweerd omdat `seq <= lastSeq`.
#### Key management
- De HMAC key wordt **niet meegecompileerd** — de ESP start zonder key en wordt geprovisioneerd via USB
- De key wordt opgeslagen in **NVS** (Non-Volatile Storage) op de ESP via de Arduino `Preferences` library (survives reboots en power-cycles)
- De app slaat de key op in **SharedPreferences** (overleeft app-herstarts)
- **USB auto-provisioning:** Sluit de ESP via USB-OTG aan op de telefoon. De app stuurt `KEY?\n`:
- Als de ESP al een key heeft → ESP antwoordt `HMAC_KEY:<hex>` → app slaat op lokaal op
- Als de ESP nog geen key heeft → ESP antwoordt `NO_KEY` → app genereert willekeurige 32-byte key via `SecureRandom` → stuurt `SET_KEY:<hex>\n` → ESP slaat op in NVS en antwoordt `OK` → app slaat op lokaal op
- Beide paden tonen dezelfde toast: **"Beveiligingssleutel gelezen"**
- Handmatige invoer: via het instellingenscherm in de Android app (64-teken hex string)
- Zo kan dezelfde app gebruikt worden voor meerdere ESP modules — each met een eigen key
#### USB Serial Protocol
```
Offset Grootte Veld Beschrijving
0 65 bytes public key Uncompressed secp256r1 public key
65 32 bytes hmac HMAC-SHA256(PSK, public key)
App → ESP: KEY?\n
ESP → App: HMAC_KEY:C3F17B...AF\n (als key bekend)
ESP → App: NO_KEY\n (als ESP nog geen key heeft)
App → ESP: SET_KEY:C3F17B...AF\n (nieuwe key schrijven)
ESP → App: OK\n (bevestiging)
```
#### Commando codes
De ESP luistert in `loop()` voor USB serial data. Bij `KEY?\n` antwoordt hij met de hex key (`HMAC_KEY:<hex>`) of `NO_KEY`. Bij `SET_KEY:<64-hex>\n` slaat hij de key op in NVS en antwoordt `OK`. De Android `UsbKeyReceiver` (manifest-geregistreerde `BroadcastReceiver`) vangt `USB_DEVICE_ATTACHED` op, filtert op Espressif (vendor 0x303a), vraagt USB-permission, opent een CDC-ACM serial connectie, en handelt het hele provisioning-proces automatisch af.
#### Provisioning flow (auto)
```
0x00 = relay1 OFF
0x01 = relay1 ON
0x02 = relay2 ON
0x03 = relay2 OFF
Android app ESP32 (USB CDC)
─────────── ───────────────
USB device attached ──────────►
Request USB permission ◄────── Android dialog
Open CDC-ACM connection
Send KEY?\n ──────────────────►
┌─ if key in NVS ──► HMAC_KEY:<hex>\n
│ App saves to prefs → toast
│
└─ if no key ──────► NO_KEY\n
Generate 32 random bytes │
Send SET_KEY:<hex>\n ─────────► (store in NVS)
◄──────────────────────────── OK\n
App saves to prefs → toast
```
De sequence counter reset naar 0 bij elke (re)connectie op de module. De app start bij 1 om te zorgen dat het eerste packet altijd geaccepteerd wordt (`1 > 0`). De sequence counter wordt gepersisteerd in SharedPreferences en overleeft app-herstarts.
### Key management
- De PSK wordt **niet meegecompileerd** — de module start zonder key en wordt geprovisioneerd via USB
- De PSK en device UUID worden opgeslagen in **NVS** (namespace `syl`, keys `psk` en `uuid`, partitie op 0x9000-0xDFFF)
- De app slaat PSK, device UUID, server UUID en client UUID op in **SharedPreferences**
- **USB provisioning:** Open Instellingen → Module bijwerken. De app checkt eerst de firmware versie (`VERSION?`) en flasht indien nodig. Na het flashen (of als versie al actueel is) volgt automatisch de key exchange:
- Als de module al keys heeft → module antwoordt `DEVICE_ID:<hex>` en `PSK:<hex>` → app slaat lokaal op
- Als de module nog geen keys heeft → module antwoordt `NO_KEY` → app genereert willekeurige 32-byte PSK via `SecureRandom` → stuurt `SET_KEY:<hex>\n`, `SET_ID:<hex>\n`, en voor client devices `SET_SERVER:<hex>\n` → module slaat op in NVS → app slaat lokaal op
### USB Serial Protocol
```
App → module: VERSION?\n
module → App: BUILD_NR:<version>\n (firmware versie check)
App → module: ID?\n
module → App: SylDa_Server\n of SylDa_Client\n (device type)
App → module: KEY?\n
module → App: DEVICE_ID:<hex>\n (module UUID, 16 bytes = 32 hex)
module → App: PSK:<hex>\n (PSK, 32 bytes = 64 hex)
module → App: SERVER_UUID:<hex>\n (alleen client: opgeslagen server UUID, 32 hex)
module → App: NO_KEY\n (als module nog geen key heeft)
App → module: SET_KEY:<hex>\n (nieuwe PSK schrijven, 64 hex)
module → App: OK\n
App → module: SET_ID:<hex>\n (UUID schrijven, 32 hex)
module → App: OK\n
App → module: SET_SERVER:<hex>\n (server UUID voor client, 32 hex)
module → App: OK\n
```
### Multi-client ondersteuning
De server ondersteunt tot **2 gelijktijdige BLE-clients** (MAX_CLIENTS 2). Elke client krijgt een onafhankelijke ECDH-handshake, AES-sleutel en sequence counter. Relais-commando's van alle clients worden gecombineerd: een relais gaat aan als minimaal één client het aan-commando stuurt (OR-logica).
De `UsbKeyReceiver` doet dit op een achtergrondthread met max 5 retries (1s pauze) om andere USB-apps te overleven die mogelijk het apparaat tijdelijk claimen.
### Beveiligingsanalyse
| Aanval | Hoe geweerd |
|---|---|
| **Replay** | Sequence counter — zelfde packet werkt niet 2× (`seq <= lastSeq` geweerd) |
| **Ongeautoriseerd device** | ECDH pubkey HMAC vereist PSK — verkeerde PSK = afgewezen public key |
| **BLE spoofing (client)** | Client gebruikt service UUID + manufacturer data voor scanning, full 16-byte UUID + ECDH PSK-HMAC voor verificatie na connect. 4-byte manufacturer data scan-filter (2^32 combinaties) kan tot onnodige connectiepogingen leiden — deze falen alsnog bij 16-byte UUID check of ECDH handshake. Voorheen (issue #10) werd alleen op naam "SylDa_Server" gescant zonder verdere verificatie. |
| **Commando vervalsing** | AES-GCM versleutelt en authenticeert command byte + nonce — wijzigen maakt tag ongeldig |
| **Afluisteren** | AES-256-GCM biedt confidentiality — commando's zijn onleesbaar zonder AES-sleutel |
| **Brute-force PSK** | 32-byte PSK = 2^256 mogelijkheden — onhaalbaar |
| **Reverse engineering van PSK** | PSK staat niet in de APK — wordt pas bij USB provisioning gegenereerd en in SharedPreferences opgeslagen. |
| **PSK extractie via USB** | `KEY?\n` geeft de huidige PSK terug. Fysieke USB-toegang is nodig — bescherm het board. |
| **NVS wiping** | `pio run -t erase` wist alle flash waaronder NVS — module start daarna zonder keys. |
### Firmware (OTA via BLE)
De firmware kan **over-the-air** worden geüpdatet via BLE (zonder USB):
1. De app scant en verbindt zoals normaal
2. Instellingen → Module bijwerken (Server mode + BLE transport)
3. De app leest de `VERSION` characteristic — als de versie actueel is, wordt overgeslagen
4. Firmware wordt in **500-byte chunks** verzonden via de `OTA` characteristic (WRITE_NR + NOTIFY)
5. Elke chunk krijgt een ACK/NAK response
6. Na alle chunks wordt de SHA-256 hash van de ontvangen firmware geverifieerd
7. Bij match: `0xFF` confirmatie → `esp_ota_set_boot_partition` → module reboot met nieuwe firmware
8. 10-seconden timeout op 0xFF confirm — geen confirm = update wordt weggegooid
Tijdens OTA zijn relais geforceerd UIT en knippert de LED groen. Na afloop (wachtend op confirm) is de LED geel.
| **Sniffen** | BLE link encrypted (AES-CCM via Just Works) |
| **Replay** | Sequence counter — zelfde packet werkt niet 2x |
| **Ongeautoriseerd device** | HMAC vereist geheime sleutel |
| **MITM** | Just Works is kwetsbaar voor MITM; HMAC maakt actieve MITM wel moeilijk (aanvaller moet de key kennen) |
| **Reverse engineering van key** | Sleutel staat niet in de APK — wordt pas bij USB provisioning gegenereerd en in SharedPreferences opgeslagen. Extractie vereist fysieke toegang tot de telefoon én root. |
| **Key extractie via USB** | Fysieke USB-toegang tot de ESP geeft `KEY?\n` de huidige key terug. Bescherming: fysieke toegang tot het board beperken. |
| **NVS wiping** | `pio run -t erase` wist alle flash waaronder NVS — ESP is daarna leeg en kan opnieuw geprovisioneerd worden. |
## Firmware
De firmware is geschreven in C++ met PlatformIO en de Arduino-framework. BLE: ESP32 NimBLE library, crypto: mbedtls (ingebouwd in ESP-IDF).
| Environment | Bronbestand | Rol |
|---|---|---|
| `s3lite-server` | `src/main.cpp` | Ontvangt BLE-verbindingen, bedient relais |
| `s3lite-client` | `src/client.cpp` | Verbindt als BLE-central met server, stuurt relais-commando's |
Firmwareversies: `version.txt` (single source of truth) → `src/version.h` (gegenereerd door `tools/pio_version_pre.py`) + `SylDaVersions.kt` (Android). Versiebeheer via `tools/check_versions.sh`.
De firmware is geschreven in C++ met behulp van PlatformIO en de Arduino-framework. BLE wordt verzorgd door de ESP32 BLE library, HMAC door mbedtls (ingebouwd in ESP-IDF).
### Logica
Elk relais gaat aan als **één van beide** bronnen actief is:
Het relais gaat aan als **één van beide** bronnen actief is:
| Relais | Bron | Voorwaarde | Resultaat |
|---|---|---|---|
| Relais 1 | Touch sensor 1 | G8 = LOW (aanraken, actief LOW) | Relais 1 aan |
| Relais 1 | BLE/AES command | App stuurt AES-GCM `0x01` | Relais 1 aan |
| Relais 1 | BLE/AES command (client) | Client stuurt AES-GCM `0x01` | Relais 1 aan |
| Relais 1 | Interne knop (G41) | Knop ingedrukt (LOW) | Relais 1 aan |
| Relais 2 | Touch sensor 2 | G6 = LOW (aanraken, actief LOW) | Relais 2 aan |
| Relais 2 | BLE/AES command | App stuurt AES-GCM `0x02` | Relais 2 aan |
| Relais 2 | BLE/AES command (client) | Client stuurt AES-GCM `0x02` | Relais 2 aan |
| Beide | Geen van beide | Geen touch + geen BLE command | Relais uit |
| Bron | Voorwaarde | Resultaat |
|---|---|---|
| Touch sensor | G38 = LOW (aanraken) | Relais aan |
| BLE command | App stuurt HMAC-ondertekend `0x01` | Relais aan |
| Geen van beide | G38 = HIGH + geen BLE command | Relais uit |
De touch sensoren, BLE, en interne knop werken onafhankelijk (OR-logica). De interne knop (G41) activeert alleen relais 1. Bij meerdere BLE-clients worden hun commando's samengevoegd.
De touch sensor en BLE werken onafzankelijk. Als de sensor losgelaten wordt en de BLE-knop niet ingedrukt is, gaat het relais uit.
### BLE GATT
| Eigenschap | UUID | Properties | Beschrijving |
|---|---|---|---|
| Service | `4fafc201-1fb5-459e-8fcc-c5c9c331914b` | — | SylDa service |
| Relay | `beb5483e-36e1-4688-b7f5-ea07361f26d8` | WRITE_NR | AES-GCM relay commands (29 bytes) |
| UUID | `beb5483e-36e1-4688-b7f5-ea07361f26d9` | READ | Device UUID (16 bytes) |
| VERSION | `beb5483e-36e1-4688-b7f5-ea07361f26db` | READ | Firmware versie string |
| OTA | `beb5483e-36e1-4688-b7f5-ea07361f26da` | WRITE_NR, NOTIFY | BLE OTA firmware update |
| ECDH | `beb5483e-36e1-4688-b7f5-ea07361f26dc` | WRITE_NR, NOTIFY | ECDH sleuteluitwisseling |
| Eigenschap | UUID |
|---|---|
| Service | `4fafc201-1fb5-459e-8fcc-c5c9c331914b` |
| Characteristic | `beb5483e-36e1-4688-b7f5-ea07361f26d8` |
De characteristic ondersteunt `WRITE` (encrypted) en `NOTIFY`. Writes vereisen een versleutelde link (bonding) plus HMAC-authenticatie.
### Key provisioning
De firmware wordt zonder PSK gecompileerd. Bij de eerste opstart print de module:
De firmware wordt zonder HMAC key gecompileerd. Bij de eerste opstart print de ESP:
```
No PSK — connect USB and run SET_KEY
No HMAC key provisioned — connect USB and run SET_KEY
```
Sluit de module via USB aan op de telefoon met de app geïnstalleerd. De app genereert automatisch een willekeurige PSK en stuurt deze naar de module. Na provisioning print de module bij opstarten:
Sluit de ESP via USB aan op de telefoon met de app geinstalleerd. De app genereert automatisch een willekeurige key en stuurt deze naar de ESP. Na provisioning print de ESP bij opstarten:
```
PSK loaded from NVS
HMAC key loaded from NVS
HMAC Key: C3F17B...AF
```
**Key wissen (factory reset):** `pio run -t erase` wist alle flash inclusief NVS.
**Key wissen (factory reset):** Om de ESP terug te zetten naar ongeprovisioneerd:
```bash
pio run -t erase
```
Dit wist alle flash inclusief NVS. Na opnieuw flashen start de ESP weer zonder key.
### Serial monitor
Baudrate: **115200** (USB CDC). De firmware stuurt heartbeats elke 60 seconden, logt statuswijzigingen, en print de PSK-status bij opstarten.
Baudrate: **115200** (USB CDC). De firmware stuurt heartbeat-statussen elke 5 seconden, logt statuswijzigingen, en print de HMAC key (of de afwezigheid ervan) bij opstarten.
**Met geprovisioneerde PSK (server):**
**Met geprovisioneerde key:**
```
=== AtomS3 Lite BLE Relay v20 ===
PSK loaded from NVS
BLE: advertising started
=== Atom VoiceS3R BLE Relay ===
HMAC key loaded from NVS
LED blink done, starting BLE...
BLE: advertising started as 'AtomS3R-Relay'
HMAC Key: C3F17B612B93E82C56B055FA9B04E84664ACE1EC4E889F2763F271B65A5B54AF
Setup done.
Relays: R1=ON R2=OFF (t1=YES t2=no btn=no ble1=OFF ble2=OFF)
[heartbeat] R1=ON R2=OFF t1=YES t2=no btn=no ble1=OFF ble2=OFF clients=1
[heartbeat] relay=OFF touch=no ble=disconnected
BLE: device connected
Relay ON (touch=YES, ble=connected)
BLE: relay ON (seq=1)
BLE: relay OFF (seq=2)
```
**Zonder PSK (nieuwe/gewiste module):**
**Zonder key (nieuwe/gewiste ESP):**
```
=== AtomS3 Lite BLE Relay v20 ===
No PSK — connect USB and run SET_KEY
BLE: advertising started
No PSK — use USB SET_KEY to provision
=== Atom VoiceS3R BLE Relay ===
No HMAC key provisioned — connect USB and run SET_KEY
LED blink done, starting BLE...
BLE: advertising started as 'AtomS3R-Relay'
No HMAC key — use USB SET_KEY to provision
Setup done.
```
**Client firmware log (geprovisioneerd):**
```
=== SylDa Client v5 ===
PSK loaded from NVS
UUID loaded from NVS
BLE: scanning...
BLE: connected to xx:xx:xx:xx:xx:xx
[hb] t1=no t2=no btn=no r1=OFF r2=OFF conn=yes ecdh=yes
```
In deze staat accepteert de ESP wel BLE-verbindingen, maar elk HMAC-packet faalt verificatie omdat er geen key is om mee te vergelijken.
### Uploaden
> **Download-mode:** Houd de reset-knop ~2 seconden ingedrukt tot de groene LED aangaat, laat dan los.
> **Download-mode:** Houd de reset-knop ~2 seconden ingedrukt tot de groene LED aangaat, laat dan los. Het board is nu in download-mode.
```bash
# Flash server firmware (behoudt NVS/PSK)
pio run -e s3lite-server --target upload
# Flash firmware (behoudt NVS/key)
pio run --target upload
# Flash client firmware (behoudt NVS/PSK)
pio run -e s3lite-client --target upload
# Volledig wissen (incl. NVS/PSK) + opnieuw flashen
pio run -t erase && pio run -e s3lite-server --target upload
# Volledig wissen (incl. NVS/key) + opnieuw flashen
pio run -t erase && pio run -t upload
```
De upload port wordt automatisch gedetecteerd door `tools/pio_upload_port.py` (post-build script). Deze script zoekt de ESP32 via `ID?` over de seriële poort en past `upload_port` aan in `platformio.ini`.
**Direct flashen (handmatig):**
```bash
~/.platformio/packages/tool-esptoolpy/esptool.py --no-stub --chip esp32s3 --port /dev/ttyACM1 --baud 115200 --before default_reset --after hard_reset write_flash --flash_mode keep --flash_size keep 0x0 .pio/build/s3lite-server/bootloader.bin 0x8000 .pio/build/s3lite-server/partitions.bin 0xE000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin 0x10000 .pio/build/s3lite-server/firmware.bin
```
### Firmware flashen via de app
De firmware kan rechtstreeks via de Android-app worden geflashed (USB) of geüpdatet (BLE OTA):
1. Sluit de module via USB-OTG aan op de telefoon (USB) of zorg dat deze verbonden is via BLE (OTA)
2. Open de app → Instellingen
3. Kies **Device Mode** (Server/Client) en **Transport Mode** (BLE/USB)
4. Tik op **Module bijwerken**
5. **USB transport:** App checkt versie, flasht bootloader/partitions/firmware indien verouderd, wisselt daarna PSK uit
6. **BLE transport (alleen server mode):** App checkt VERSION characteristic, voert OTA update uit zonder USB
Firmware binaries: `tools/copy_firmware.sh` kopieert `.bin` bestanden naar `assets/firmware/server/` en `assets/firmware/client/`.
Na het uploaden treedt er een harde reset op. Als het board in download-mode blijft, USB eruit/erin of kort op reset tikken om in run-mode te komen.
## Android App
### Features
- **Auto-connect:** Scant bij opstarten naar BLE service UUID `4fafc201-1fb5-459e-8fcc-c5c9c331914b` ("SylDa_Server")
- **Manufacturer data matching:** Scant op manufacturer ID 0xFFFF die de eerste 4 bytes van de device UUID bevat — directe UUID-match bij scan
- **Device UUID verificatie:** Leest `UUID_CHAR_UUID` en vergelijkt met opgeslagen UUID — alleen correcte apparaten worden geaccepteerd
- **Firmware versie check:** Bij connect wordt VERSION characteristic gelezen — toont "Update beschikbaar" als app-versie nieuwer is
- **Auto-reconnect:** Bij wegvallen verbinding na 2 seconden opnieuw scannen
- **Scan retry:** Bij timeout 3× automatisch herhalen, daarna "Opnieuw zoeken" knop
- **Hold-to-activate:** Knop ingedrukt = relais aan, loslaten = relais uit
- **Twee onafhankelijke relais:** Twee knoppen, elk met eigen AES-GCM commando
- **Write Without Response:** `WRITE_TYPE_NO_RESPONSE` voor minimale latency
- **MTU negotiatie:** Vraagt MTU 512 aan na service discovery
- **ECDH + AES-256-GCM:** ECDH sleuteluitwisseling bij connectie, daarna AES-GCM encryptie voor elk commando
- **BLE OTA update:** Firmware updaten via BLE zonder USB (server mode + BLE transport)
- **Key exchange geïntegreerd:** Module bijwerken = versie check → flashen (indien nodig) → PSK-uitwisseling — volledig automatisch
- **Thema-selector:** Instellingen → Weergave met Systeem, Licht of Donker
- **Permissies:** Vraagt BLUETOOTH_SCAN en BLUETOOTH_CONNECT. Bij weigeren wordt verwezen naar Instellingen
- **Auto-connect:** De app scant automatisch bij opstarten naar het BLE-apparaat "AtomS3R-Relay"
- **Auto-reconnect:** Bij wegvallen van de verbinding wordt na 2 seconden opnieuw gescant
- **Hold-to-activate:** Houd de knop ingedrukt voor relais aan, loslaten voor relais uit (zelfde gedrag als de sensor)
- **BLE Pairing:** De app initieert bonding (`createBond()`) na connectie; Just Works pairing verloopt zonder gebruikersinteractie
- **MTU negotiatie:** Vraagt MTU 256 aan na service discovery (voor 37-byte HMAC packets)
- **HMAC authenticatie:** Elk commando wordt ondertekend met HMAC-SHA256 + sequence counter
- **USB auto-provisioning:** Bij aansluiten van de ESP via USB-OTG leest de app de bestaande key uit, of genereert een nieuwe key als de ESP er nog geen heeft. Beide scenario's tonen de toast _"Beveiligingssleutel gelezen"_ (BroadcastReceiver, werkt ook als app niet actief is)
- **Instellingenscherm:** Losse Activity voor het invoeren van de HMAC key (met oog-icoon om de key te tonen/verbergen), "Scan & Verbind" knop, en hint-tekst
- **Permissies:** Vraagt BLUETOOTH_SCAN, BLUETOOTH_CONNECT en ACCESS_FINE_LOCATION aan (Android 12+)
### Instellingenscherm
1. Open de app → tik op **instellingen** (tandwiel-icoon rechtsboven)
2. **Device Mode:** Kies Server of Client (bepaalt welke firmware wordt geflashed / provisioning flow)
3. **Transport Mode:** Kies BLE of USB (bepaalt hoe firmware wordt geüpdatet: OTA via BLE of USB flash)
4. **Module bijwerken:** Versie check → flash/OTA → PSK-uitwisseling — één knop voor de hele flow
5. **Weergave:** Kies Systeem (volgt telefoon), Licht of Donker
6. **Relais labels:** Stel eigen namen in voor de twee relais knoppen
1. Open de app
2. Tik op het **tandwiel-icoon** rechtsboven
3. De app toont een hint: _"Voer de HMAC sleutel in of sluit de relaismodule aan op de telefoon."_
4. **Optie A — handmatig:** Voer de 64-teken hex key in. Tik op het **oog-icoon** om de key te tonen/verbergen. Tik **Opslaan**.
5. **Optie B — automatisch (aanbevolen):** Sluit de ESP via USB-OTG aan op de telefoon. Android vraagt USB-toestemming (1x). De app leest de key uit (als de ESP er al een heeft) of genereert een nieuwe key en provisioneert de ESP. Toast: _"Beveiligingssleutel gelezen"_.
6. Tik **Scan & Verbind** om op te slaan en direct BLE scannen te starten.
De PSK en UUIDs worden opgeslagen in SharedPreferences en overleven app-herstarts. Bij geen PSK toont de hoofdapp "Geen sleutel — open Instellingen".
De key wordt opgeslagen in SharedPreferences en overleeft app-herstarts. Bij geen key toont de hoofdapp "Geen HMAC sleutel - open Instellingen" en wordt er niet gescant.
### Hoofdscherm UI
- **instellingen** tekst + tandwiel-icoon rechtsboven → opent instellingenscherm
- **Twee knoppen** in het midden → hold-to-activate voor relais 1 en 2 (labels aanpasbaar)
- **Tandwiel-icoon** rechtsboven → opent instellingenscherm
- **HOLD-knop** centrum → houd ingedrukt voor relais aan
- **Status tekst** linksonder (14sp) → toont verbindingsstatus
### Bouwen
Vereisten:
- Android SDK (API 34+)
- Java 17 target (JDK 21 voor Gradle — Java 26 werkt niet)
- Gradle 8.14 (via wrapper)
- AGP 8.5.2, Kotlin 2.0.21 (K2 compiler)
- USB host support (raw Android USB API, geen externe serial library)
- PlatformIO (voor firmware compilatie)
- Java 17+ (Java 26 werkt niet met Gradle 8.5 — gebruik Java 21)
- Gradle 8.2.2 (via wrapper)
- USB host support (raw Android USB API, geen externe library)
```bash
# Firmware compileren en naar app assets kopiëren
pio run
bash tools/copy_firmware.sh
# App bouwen en installeren
cd android
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew installDebug
JAVA_HOME=/pad/naar/jdk21 ./gradlew assembleDebug
```
APK: `android/app/build/outputs/apk/debug/app-debug.apk`.
De APK staat na build in `android/app/build/outputs/apk/debug/app-debug.apk`.
### Installeren
```bash
adb -s <ip>:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
```
Of via draadloze foutopsporing:
```bash
adb connect <ip>:<poort>
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
```
## Eerste keer setup
1. **App op de telefoon installeren:**
1. **Flash de ESP:**
```bash
cd android
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew installDebug
pio run -t upload
```
2. **Firmware flashen & sleutel uitwisselen (één flow):**
- Sluit de module via USB-OTG aan op de telefoon
- Open de app → Instellingen → kies Device Mode (Server/Client) → kies Transport (USB) → Module bijwerken
- De app checkt versie, flasht indien nodig, en wisselt PSK uit — volledig automatisch
2. **Installeer de app op de telefoon:**
```bash
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
```
3. **Gebruiken:** De app scant automatisch, verbindt, en toont "Verbonden". Houd een knop ingedrukt om het relais te schakelen.
3. **Provision de HMAC key (kies één):**
### Nieuwe module, bestaande telefoon
**Optie A — USB auto-provisioning (aanbevolen):**
- Sluit de ESP via USB-OTG aan op de telefoon
- Android vraagt USB-toestemming (1x) — tik "Toestaan"
- De app detecteert dat de ESP geen key heeft, genereert er één, en stuurt deze naar de ESP
- Toast: _"Beveiligingssleutel gelezen"_
- Ontkoppel de ESP en zet hem op stroom
- Sluit nieuwe module via USB aan → Instellingen → Module bijwerken → app flasht en provisioneert
**Optie B — handmatig:**
- Open de app → tandwiel-icoon → voer een 64-teken hex key in → Opslaan
- Let op: de ESP moet dan dezelfde key hebben (via USB provisioning of handmatig via serial `SET_KEY:<hex>\n`)
### Bestaande module, nieuwe telefoon
**Optie C — bestand ESP, nieuwe telefoon:**
- Sluit de ESP via USB-OTG aan op de nieuwe telefoon
- De app leest de bestaande key uit de ESP en slaat lokaal op
- Toast: _"Beveiligingssleutel gelezen"_
- Sluit module via USB-OTG aan op nieuwe telefoon → Instellingen → Module bijwerken → app leest bestaande PSK uit (antwoordt `PSK:<hex>`)
**Nieuwe ESP, bestaande telefoon:**
- Wis de app data (instellingen → apps → Atom Relay → storage → clear) of de-installeer en herinstalleer
- Sluit de nieuwe ESP via USB aan → app genereert nieuwe key
- Let op: de oude key in de app wordt overschreven
4. **Scan & Verbind:** Tik in instellingen op "Scan & Verbind" (of de app scant automatisch bij terugkeren naar het hoofdscherm)
5. De app scant, paart (Just Works), en verbindt. Houd de HOLD-knop ingedrukt om het relais te schakelen.
## Projectstructuur
```
Project_SylDa/
├── platformio.ini # PlatformIO configuratie (ESP32-S3, server + client)
├── version.txt # Single source of truth voor firmwareversies
├── icon.png / icon.svg # Project icon
sylvia-touch-relay/
├── platformio.ini # PlatformIO configuratie (ESP32-S3)
├── src/
│ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA)
│ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais)
│ ├── ecdh_helper.h # ECDH crypto helper (secp256r1, AES-sleutel afleiding, pubkey HMAC)
│ └── version.h # Auto-gegenereerd uit version.txt — niet handmatig bewerken
├── tools/
│ ├── copy_firmware.sh # Kopieert firmware binaries naar Android app assets
│ ├── pio_version_pre.py # PlatformIO pre-build script (genereert version.h)
│ ├── pio_upload_port.py # PlatformIO post-build script (auto-detecteert upload port)
│ ├── check_versions.sh # Valideert version.txt vs version.h vs SylDaVersions.kt
│ └── test_ota_notify.py # Test BLE OTA notificatie flow
├── scripts/
│ └── release.sh # Release automatisering: firmware + APK/AAB + Forgejo release
│ └── main.cpp # Firmware (BLE + HMAC + NVS key storage + USB serial + sensor + relais)
├── android/
│ ├── build.gradle # Root Gradle config
│ ├── settings.gradle # Gradle settings
│ ├── build.gradle # Root Gradle config
│ ├── settings.gradle # Gradle settings
│ ├── gradle.properties
│ ├── gradlew # Gradle wrapper
│ ├── gradlew # Gradle wrapper
│ ├── gradle/wrapper/
│ │ └── gradle-wrapper.properties
│ └── app/
│ ├── build.gradle # App module (nl.eddydevink.sylda, minSdk 26)
│ ├── build.gradle # App module config
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── assets/firmware/
│ │ ├── server/
│ │ │ ├── bootloader.bin
│ │ │ ├── partitions.bin
│ │ │ ├── firmware.bin
│ │ │ └── boot_app0.bin
│ │ └── client/
│ │ ├── bootloader.bin
│ │ ├── partitions.bin
│ │ ├── firmware.bin
│ │ └── boot_app0.bin
│ ├── java/nl/eddydevink/sylda/
│ │ ├── MainActivity.kt
│ │ ├── SettingsActivity.kt
│ │ ├── BleRelayManager.kt
│ │ ├── BleStatus.kt
│ │ ├── EspFlasher.kt
│ │ ├── OtaManager.kt
│ │ ├── SylDaVersions.kt
│ │ ├── UsbSerialTransport.kt
│ │ ├── UsbUtils.kt
│ │ └── PrefsHelper.kt
│ ├── res/
│ │ ├── drawable/
│ │ │ └── ic_settings.xml
│ │ ├── layout/
│ │ │ ├── activity_main.xml
│ │ │ ├── activity_settings.xml
│ │ │ ├── dialog_waiting.xml
│ │ │ └── dialog_progress.xml
│ │ ├── values/strings.xml
│ │ └── xml/
│ │ └── device_filter.xml
├── docs/ # Documentatie (handleiding, ontwerpplannen)
├── release/ # Release artifacts
│ ├── java/com/eddy/atomrelay/
│ │ ├── MainActivity.kt # BLE client + HMAC signing
│ │ ├── SettingsActivity.kt # HMAC key invoer + scan knop
│ │ └── UsbKeyReceiver.kt # USB key provisioning (raw USB API)
│ └── res/
│ ├── drawable/
│ │ ├── ic_eye_open.xml # Oog open icoon
│ │ ├── ic_eye_closed.xml # Oog dicht icoon
│ │ └── ic_settings.xml # Tandwiel icoon
│ ├── layout/
│ │ ├── activity_main.xml # Hoofd UI (FrameLayout)
│ │ └── activity_settings.xml # Key invoer + scan UI
│ └── values/strings.xml
├── .gitignore
├── LICENSE
└── README.md
```
## Ontwerpbeslissingen
### WRITE_TYPE_NO_RESPONSE (geen BLE write ACK)
De app gebruikt `WRITE_TYPE_NO_RESPONSE` in plaats van `WRITE_TYPE_DEFAULT`. Eerdere versies gebruikten ACK's, maar de BLE write acknowledgement duurde lang genoeg dat de gebruiker de knop kon loslaten vóór de ACK arriveerde. Als `ACTION_UP` plaatsvond tijdens een pending write, werd het loslaten genegeerd en bleven relais en app hangen in ON-status. `WRITE_TYPE_NO_RESPONSE` verwijdert die latency en maakt directe ON→OFF sequenties mogelijk.
### compileSdk/targetSdk 34 (niet 35 / Android 15)
De doelgroep (lichamelijk gehandicapte gebruikers) is terughoudend met telefoonupdates. API 34 (Android 14) maximaliseert compatibiliteit. Heroverweeg bij voldoende migratie.
### Thema-selector en DayNight
`Theme.AppCompat.DayNight` met 3 opties: Systeem, Licht, Donker. Android's `sp`-eenheden en hoog-contrast modus (Android 13+) volstaan voor visueel beperkte gebruikers — eigen implementatie zou onnodige complexiteit toevoegen.
### ECDH + AES-256-GCM i.p.v. HMAC-only
HMAC-SHA256 (vroegere versies) bood authenticatie maar geen confidentiality. ECDH+AES-GCM voegt encryptie toe zonder de complexiteit van BLE-link bonding (die Android sliding bugs veroorzaakt: write queues, `GATT_INSUFFICIENT_AUTHENTICATION`). De PSK wordt alleen gebruikt voor ECDH pubkey HMAC-verificatie — daarna is alle communicatie versleuteld met afgeleide AES-256-GCM sleutels. GCM is AEAD: één operatie voor zowel encryptie als authenticatie.
## Technische notities
- De Atom S3 Lite heeft GPIO G1, G2, G5-G8, G38, G39 beschikbaar op de headers. G41 is de interne knop (active low). G35 wordt gebruikt voor een externe WS2812 LED (niet de interne G46 SK6812).
- G1 en G2 zitten op de HY2.0-4P Grove poort. G5-G8, G38, G39 op de onderste expansie-header.
- De firmware gebruikt geen M5Unified library — alleen Arduino core + ESP32 NimBLE + mbedtls + FastLED.
- De Atom VoiceS3R gebruikt de ES8311 audio codec op G45/G0/G48/G4/G3/G17/G11, IR TX op G47, en de knop op G41. Deze pinnen zijn niet beschikbaar voor eigen gebruik.
- G1 en G2 zitten op de HY2.0-4P Grove poort. G5-G8, G38, G39 zitten op de onderste expantie-header.
- De firmware gebruikt geen M5Unified library — alleen Arduino core + ESP32 BLE + mbedtls. Dit maakt de serial output betrouwbaarder via USB CDC.
- `ARDUINO_USB_CDC_ON_BOOT=1` is ingeschakeld voor virtuele serial via USB.
- NVS: namespace `syl`, PSK in key `psk`, UUID in key `uuid`, server UUID in key `server`, BLE-adres in key `addr`. Partitie op 0x9000-0xDFFF.
- `pio run -t erase` wist alle NVS. `tools/pio_upload_port.py` auto-detecteert de upload port via `ID?` query.
- Firmwareversies: `version.txt` → `src/version.h` (gegenereerd) + `SylDaVersions.kt` (Android). `tools/check_versions.sh` valideert consistentie.
- De app gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) — geen externe serial library.
- `minSdk` is 26 (Android 8.0). `compileSdk`/`targetSdk` blijft op 34.
- Sequence counters starten bij 1 (niet 0), wrappen bij 1.000.000.
- BLE MTU: server stelt 248 in (ESP32 NimBLE limiet), app vraagt 512. OTA/ECDH chunks passen zich aan aan `negotiatedMtu - 7`.
- Server krijgt maximaal 2 gelijktijdige BLE-clients. Client slaat server BLE-adres op in NVS voor snelle reconnect (valt terug op scan als adres niet werkt).
- Server advertising bevat manufacturer data (0xFFFF) met eerste 4 bytes van device UUID voor snelle identificatie.
- OTA: 500-byte chunks (aangepast aan MTU), 3 retries per chunk, SHA-256 hash verificatie, 10s 0xFF confirm timeout.
## Release
```bash
# Volledige release (firmware + APK + AAB + Forgejo)
bash scripts/release.sh
```
Deze script:
1. Compileert server + client firmware (`pio run`)
2. Kopieert firmware naar Android assets (`copy_firmware.sh`)
3. Bouwt release APK + AAB (`gradlew assembleRelease assembleAndroidTest bundleRelease`)
4. Publiceert de release op Forgejo (met APK/AAB als attachments)
## Licentie
**GNU General Public License v3.0 or later** (GPL-3.0-or-later).
Vereist omdat de app [`esp32-flash-lib`](https://github.com/dkaukov/esp32-flash) (GPL-3.0) gebruikt voor ESP32 firmware flashing. Zie het `LICENSE` bestand.
- De HMAC key wordt opgeslagen in NVS (Non-Volatile Storage) via de Arduino `Preferences` library. De key wordt niet meegecompileerd maar via USB provisioning door de Android app op de ESP gezet. Sleuteloverdracht werkt in beide richtingen: de app kan een key naar de ESP schrijven (nieuwe ESP) of een bestaande key uitlezen (bestaande ESP op nieuwe telefoon).
- De app gebruikt `WRITE_TYPE_NO_RESPONSE` voor minimale latency — de encryptie en HMAC verificatie gebeuren op de link layer resp. application layer en zijn onafhankelijk van het write type.
- De sequence counter start bij 1 (niet 0) om te voorkomen dat het eerste packet na connectie als replay wordt geweerd (`0 <= 0` is true in de `seq <= lastSeq` check).
- Für meerdere ESP modules met verschillende keys: sluit elke ESP één voor één aan op de telefoon. De app genereert per ESP een eigen key en slaat deze op. De app ondersteunt momenteel slechts één key tegelijk (opgeslagen in SharedPreferences). Bij wisselen van ESP: wis app data of sluit de nieuwe ESP aan (overschrijft vorige key).
- De `UsbKeyReceiver` is een manifest-geregistreerde `BroadcastReceiver` voor `ACTION_USB_DEVICE_ATTACHED`. Hij filtert op Espressif (vendor 0x303a), vraagt USB-permission via `PendingIntent` (FLAG_MUTABLE voor Android 12+), opent een CDC-ACM serial connectie met raw USB API (geen externe library), configureert CDC control interface (line coding + DTR/RTS), en handelt het volledige provisioning-proces af. Werkt ook als de app niet actief is.
- De `UsbKeyReceiver` gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) in plaats van een externe serial library. Dit vermijdt compatibiliteitsproblemen met de ESP32-S3 USB-JTAG/serial controller die geen CDC `get_status` requests ondersteunt.
- CDC control interface setup: line coding (115200 8N1) via SET_LINE_CODING request + DTR/RTS via SET_CONTROL_LINE_STATE. Dit activeert de data-flow op de ESP32-S3 CDC-ACM interface voordat commando's worden gestuurd.
- De `UsbKeyReceiver` doet serial I/O op een achtergrondthread (max 5s timeout per poging, 5 retries met 1s pauze) om ANR's te voorkomen en andere USB-apps te overleven die mogelijk het apparaat tijdelijk claimen.
- Bij elke retry wordt het ESP-apparaat opnieuw opgezocht via `UsbManager.deviceList` om apparaat-pad-wijzigingen op te vangen.

View file

@ -3,45 +3,20 @@ plugins {
id 'org.jetbrains.kotlin.android'
}
def signingPropsFile = rootProject.file('signing.properties')
def hasSigningConfig = signingPropsFile.exists()
android {
namespace 'nl.eddydevink.sylda'
namespace 'com.eddy.atomrelay'
compileSdk 34
if (hasSigningConfig) {
def props = new Properties()
props.load(new FileInputStream(signingPropsFile))
signingConfigs {
release {
storeFile rootProject.file(props['KEYSTORE_PATH'])
storePassword props['KEYSTORE_PASSWORD']
keyAlias props['KEY_ALIAS']
keyPassword props['KEY_PASSWORD']
}
}
}
defaultConfig {
applicationId "nl.eddydevink.sylda"
minSdk 26
applicationId "com.eddy.atomrelay"
minSdk 24
targetSdk 34
versionCode 2
versionName "1.1"
buildConfigField "String", "BUILD_NUMBER", "\"${new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date())}\""
}
buildFeatures {
buildConfig true
versionCode 1
versionName "1.0"
}
buildTypes {
release {
if (hasSigningConfig) {
signingConfig signingConfigs.release
}
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
@ -56,37 +31,7 @@ android {
}
}
task generateSylDaVersions {
def versionFile = rootProject.file('../version.txt')
def outputFile = file('src/main/java/nl/eddydevink/sylda/SylDaVersions.kt')
inputs.file(versionFile)
outputs.file(outputFile)
doLast {
def versions = [:]
versionFile.eachLine { line ->
def parts = line.trim().split('=')
if (parts.length == 2) versions[parts[0].trim()] = parts[1].trim()
}
outputFile.text = """// Auto-generated from version.txt — DO NOT EDIT
package nl.eddydevink.sylda
object SylDaVersions {
const val SERVER = "${versions.server}"
const val CLIENT = "${versions.client}"
}
"""
}
}
preBuild.dependsOn generateSylDaVersions
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'io.github.dkaukov:esp32-flash-lib:1.1.17'
implementation 'com.eclipsesource.minimal-json:minimal-json:0.9.5'
implementation 'org.slf4j:slf4j-nop:2.0.16'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
}

View file

@ -1,2 +0,0 @@
-keep class org.dkaukov.esp32.** { *; }
-dontwarn org.dkaukov.esp32.**

View file

@ -3,20 +3,18 @@
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:label="Atom Relay"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/Theme.AppCompat.Light">
<activity
android:name=".MainActivity"
@ -31,15 +29,6 @@
<activity
android:name=".SettingsActivity"
android:label="Instellingen"
android:parentActivityName=".MainActivity"
android:launchMode="singleTop"
android:exported="true">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
android:parentActivityName=".MainActivity"/>
</application>
</manifest>

View file

@ -0,0 +1,134 @@
package com.eddy.atomrelay
import android.content.Context
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import org.dkaukov.esp32.core.EspFlasherApi
import org.dkaukov.esp32.io.ProgressCallback
import java.io.IOException
class EspFlasher(private val context: Context) {
companion object {
private const val TAG = "EspFlasher"
}
interface FlashCallback {
fun onProgress(percent: Int, message: String)
fun onComplete()
fun onError(message: String)
}
private val handler = Handler(Looper.getMainLooper())
fun flashDevice(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
Thread {
try {
doFlash(usbManager, device, callback)
} catch (e: Exception) {
Log.e(TAG, "Flash failed", e)
handler.post { callback.onError(e.message ?: "Onbekende fout") }
}
}.start()
}
private class FlashStep(
val name: String,
val address: Int,
val data: ByteArray,
val baseProgress: Int,
val range: Int,
val compress: Boolean
)
private fun doFlash(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
Thread.sleep(500)
val connection = usbManager.openDevice(device)
?: throw IOException("Kan USB-apparaat niet openen")
try {
val intf = UsbUtils.findCdcDataInterface(device)
?: throw IOException("Geen CDC data interface gevonden")
if (!connection.claimInterface(intf, true)) {
throw IOException("Kan USB interface niet claimen")
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: throw IOException("Geen USB OUT endpoint gevonden")
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: throw IOException("Geen USB IN endpoint gevonden")
postProgress(callback, 0, "Verbinden met ESP bootloader...")
val transport = UsbSerialTransport(connection, epOut, epIn)
val assets = context.assets
val bootloader = assets.open("firmware/bootloader.bin").readBytes()
val partitions = assets.open("firmware/partitions.bin").readBytes()
val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes()
val firmware = assets.open("firmware/firmware.bin").readBytes()
val totalSize = bootloader.size + partitions.size + bootApp0.size + firmware.size
val steps = listOf(
FlashStep("Bootloader", 0x0, bootloader, 30, 10, false),
FlashStep("Partitie tabel", 0x8000, partitions, 40, 10, false),
FlashStep("Boot app0", 0xe000, bootApp0, 50, 10, false),
FlashStep("Firmware", 0x10000, firmware, 60, 35, true)
)
postProgress(callback, 5, "Sync met bootloader...")
val api = EspFlasherApi.connect(transport)
.withCallBack(object : ProgressCallback {
override fun onProgress(pct: Float) {
Log.d(TAG, "Lib progress: $pct%")
}
override fun onInfo(value: String) {
Log.d(TAG, "Info: $value")
}
})
postProgress(callback, 10, "Chip detecteren...")
val detected = api.chipDetect()
val chipId = detected.getChipId()
Log.i(TAG, "Detected chip: $chipId")
postProgress(callback, 15, "Chip: ${chipId.readableName}")
postProgress(callback, 20, "Stub laden...")
val stub = detected.loadStub()
postProgress(callback, 25, "Flash wissen...")
stub.eraseFlash()
for (step in steps) {
val sizeStr = if (step.data.size > 1024) "${step.data.size / 1024}KB" else "${step.data.size} bytes"
postProgress(callback, step.baseProgress, "${step.name} schrijven ($sizeStr)...")
val writeStub = if (step.compress) stub.withCompression(true) else stub.withCompression(false)
writeStub.writeFlash(step.address, step.data)
val endProgress = step.baseProgress + step.range
postProgress(callback, endProgress.coerceAtMost(95), "${step.name} geschreven")
}
postProgress(callback, 95, "ESP resetten...")
stub.reset()
postProgress(callback, 100, "Firmware geflashed!")
handler.post { callback.onComplete() }
} finally {
connection.close()
}
}
private fun postProgress(callback: FlashCallback, percent: Int, message: String) {
Log.d(TAG, "Progress $percent%: $message")
handler.post { callback.onProgress(percent, message) }
}
}

View file

@ -0,0 +1,353 @@
package com.eddy.atomrelay
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.MotionEvent
import android.widget.Button
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.UUID
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "AtomRelay"
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val DEVICE_NAME = "AtomS3R-Relay"
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
private const val REQUEST_SETTINGS = 1
private const val SCAN_TIMEOUT_MS = 10000L
}
private var bluetoothGatt: BluetoothGatt? = null
private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false
private var scanTimedOut = false
private val handler = Handler(Looper.getMainLooper())
private var seq = 1
private var hmacKey: ByteArray? = null
private var pendingDevice: BluetoothDevice? = null
private lateinit var statusText: TextView
private lateinit var relayButton: Button
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val name = device?.name ?: ""
Log.d(TAG, "Found device: $name (${device?.address})")
if (name == DEVICE_NAME) {
stopScan()
connectToDevice(device)
}
}
}
private val bondReceiver = object : BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
val bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)
Log.d(TAG, "Bond state changed: ${device?.address} -> $bondState")
if (bondState == BluetoothDevice.BOND_BONDED && device == pendingDevice) {
Log.d(TAG, "Bonded, discovering services...")
runOnUiThread { statusText.text = "Bonded, connecting..." }
bluetoothGatt?.discoverServices()
}
}
}
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.d(TAG, "GATT connected")
bluetoothGatt = gatt
val device = gatt.device
pendingDevice = device
if (device.bondState == BluetoothDevice.BOND_BONDED) {
Log.d(TAG, "Already bonded, discovering services...")
runOnUiThread { statusText.text = "Connected, discovering..." }
gatt.discoverServices()
} else {
Log.d(TAG, "Not bonded, creating bond...")
runOnUiThread { statusText.text = "Pairing..." }
device.createBond()
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected")
runOnUiThread {
statusText.text = "Reconnecting..."
relayButton.isEnabled = false
}
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
seq = 1
handler.postDelayed({ startScan() }, 2000)
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
Log.d(TAG, "Services discovered, requesting MTU...")
gatt.requestMtu(256)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
if (relayCharacteristic != null) {
gatt.setCharacteristicNotification(relayCharacteristic, true)
val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
Log.d(TAG, "Ready, relay characteristic found")
runOnUiThread {
statusText.text = "Connected"
relayButton.isEnabled = true
}
}
} else {
Log.e(TAG, "Service not found")
runOnUiThread { statusText.text = "Service not found" }
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
Log.d(TAG, "Write status: $status")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
statusText = findViewById(R.id.statusText)
relayButton = findViewById(R.id.relayButton)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
settingsButton.setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
}
setupRelayButton()
registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_NOT_EXPORTED)
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == Activity.RESULT_OK) {
handler.postDelayed({ startScan() }, 300)
}
}
override fun onResume() {
super.onResume()
loadHmacKey()
if (hmacKey == null) {
statusText.text = "Geen HMAC sleutel - open Instellingen"
} else if (!isScanning && bluetoothGatt == null) {
handler.postDelayed({ startScan() }, 300)
}
}
override fun onPause() {
super.onPause()
stopScan()
}
private fun loadHmacKey() {
val prefs = getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE)
val hexKey = prefs.getString(SettingsActivity.PREF_KEY, null)
hmacKey = hexKey?.let { hexStringToBytes(it) }
Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}")
}
private fun hexStringToBytes(hex: String): ByteArray? {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
if (cleaned.length != 64) return null
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray {
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
val hmacData = ByteArray(5)
hmacData[0] = cmd
System.arraycopy(seqBytes, 0, hmacData, 1, 4)
val hmac = hmacSha256(hmacData, key)
val packet = ByteArray(HMAC_PKT_SIZE)
packet[0] = cmd
System.arraycopy(seqBytes, 0, packet, 1, 4)
System.arraycopy(hmac, 0, packet, 5, HMAC_LEN)
return packet
}
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1 && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startScan()
}
}
private fun setupRelayButton() {
relayButton.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
sendRelayCommand(true)
relayButton.text = "ON"
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(false)
relayButton.text = "HOLD"
true
}
else -> false
}
}
}
@SuppressLint("MissingPermission")
private fun startScan() {
if (isScanning) return
if (!hasPermissions()) {
requestPermissions()
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || !adapter.isEnabled) {
statusText.text = "Bluetooth not enabled"
return
}
if (hmacKey == null) {
statusText.text = "Geen HMAC sleutel - open Instellingen"
return
}
isScanning = true
scanTimedOut = false
statusText.text = "Scanning..."
Log.d(TAG, "Starting BLE scan for $DEVICE_NAME")
adapter.bluetoothLeScanner.startScan(scanCallback)
handler.postDelayed({
if (isScanning) {
scanTimedOut = true
stopScan()
}
}, SCAN_TIMEOUT_MS)
}
@SuppressLint("MissingPermission")
private fun stopScan() {
if (!isScanning) return
isScanning = false
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (scanTimedOut) {
statusText.text = "Device not found"
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: BluetoothDevice) {
runOnUiThread { statusText.text = "Connecting..." }
Log.d(TAG, "Connecting to ${device.address}")
bluetoothGatt = device.connectGatt(this, false, gattCallback)
}
@SuppressLint("MissingPermission")
private fun sendRelayCommand(on: Boolean) {
val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return
val cmd: Byte = if (on) 0x01 else 0x00
val packet = buildPacket(cmd, key)
char.value = packet
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
seq++
Log.d(TAG, "Sent relay ${if (on) "ON" else "OFF"} (seq=${seq - 1})")
}
private fun getRequiredPermissions(): Array<String> {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
private fun hasPermissions(): Boolean {
return getRequiredPermissions().all {
ActivityCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestPermissions() {
ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1)
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
stopScan()
bluetoothGatt?.close()
try { unregisterReceiver(bondReceiver) } catch (e: Exception) {}
}
}

View file

@ -0,0 +1,431 @@
package com.eddy.atomrelay
import android.app.PendingIntent
import android.app.ProgressDialog
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
class SettingsActivity : AppCompatActivity() {
companion object {
const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key"
const val RESULT_SCAN = 100
private const val TAG = "SettingsActivity"
private const val ESP_VENDOR_ID = 0x303a
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
private const val KEY_TIMEOUT_MS = 5000L
private const val SET_KEY_TIMEOUT_MS = 5000L
private const val ACTION_USB_PERMISSION = "com.eddy.atomrelay.USB_PERMISSION"
}
private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
private var currentAction = UsbAction.NONE
private var dialog: ProgressDialog? = null
private val handler = Handler(Looper.getMainLooper())
private val usbAttachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && currentAction != UsbAction.NONE) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
if (usbManager.hasPermission(device)) {
onDeviceReady(usbManager, device)
} else {
requestUsbPermission(usbManager, device)
}
}
}
}
}
private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_USB_PERMISSION && currentAction != UsbAction.NONE) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null) {
onDeviceReady(usbManager, device)
}
} else {
Toast.makeText(this@SettingsActivity, "USB-toegang geweigerd", Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
private val usbDetachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID && dialog != null && currentAction == UsbAction.NONE) {
dialog?.dismiss()
dialog = null
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Instellingen"
findViewById<Button>(R.id.exchangeKeyButton).setOnClickListener {
startKeyExchange()
}
findViewById<Button>(R.id.flashFirmwareButton).setOnClickListener {
startFlashing()
}
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_NOT_EXPORTED)
registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_EXPORTED)
registerReceiver(usbDetachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED), Context.RECEIVER_NOT_EXPORTED)
}
override fun onDestroy() {
super.onDestroy()
currentAction = UsbAction.NONE
try { unregisterReceiver(usbAttachedReceiver) } catch (e: Exception) {}
try { unregisterReceiver(usbPermissionReceiver) } catch (e: Exception) {}
try { unregisterReceiver(usbDetachedReceiver) } catch (e: Exception) {}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
// === Key Exchange ===
private fun startKeyExchange() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.EXCHANGE_KEY
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doKeyExchange(usbManager, device)
} else {
showWaitingDialog("Sleutel Uitwisselen", "Sluit de ESP aan via USB.")
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog("Sleutel Uitwisselen", "Sleutel uitlezen...", ProgressDialog.STYLE_SPINNER)
Thread {
val result = readKeyFromEsp(usbManager, device)
if (isFinishing || isDestroyed) return@Thread
handler.post {
when (result) {
is KeyResult.Success -> {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(PREF_KEY, result.hexKey).apply()
Toast.makeText(this, "Beveiligingssleutel gelezen", Toast.LENGTH_LONG).show()
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Error -> {
Toast.makeText(this, result.message, Toast.LENGTH_LONG).show()
cleanupAction()
}
null -> {
Toast.makeText(this, "Sleutel niet gevonden", Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}.start()
}
// === Firmware Flashing ===
private fun startFlashing() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.FLASH
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doFlashing(usbManager, device)
} else {
showWaitingDialog(
"Firmware Flashen",
"Zet de ESP in download-modus (houd reset 2s in tot groene LED) en sluit aan via USB."
)
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doFlashing(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog("Firmware Flashen", "Initialiseren...", ProgressDialog.STYLE_HORIZONTAL)
val flasher = EspFlasher(this)
flasher.flashDevice(usbManager, device, object : EspFlasher.FlashCallback {
override fun onProgress(percent: Int, message: String) {
runOnUiThread {
dialog?.progress = percent
dialog?.setMessage(message)
}
}
override fun onComplete() {
runOnUiThread {
dialog?.setMessage("Firmware geflashed!\n\nOntkoppel de ESP en sluit opnieuw aan om de sleutel in te stellen.")
dialog?.setCancelable(true)
dialog?.setButton(ProgressDialog.BUTTON_NEUTRAL, "Sluiten") { _, _ ->
dialog?.dismiss()
dialog = null
cleanupAction()
}
}
}
override fun onError(message: String) {
runOnUiThread {
dialog?.dismiss()
dialog = null
Toast.makeText(this@SettingsActivity, "Flashen mislukt: $message", Toast.LENGTH_LONG).show()
cleanupAction()
}
}
})
}
// === USB dispatch ===
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
when (currentAction) {
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
UsbAction.FLASH -> doFlashing(usbManager, device)
UsbAction.NONE -> {}
}
}
// === Dialog helpers ===
private fun showWaitingDialog(title: String, message: String) {
dialog?.dismiss()
dialog = ProgressDialog(this).apply {
setTitle(title)
setMessage(message)
setProgressStyle(ProgressDialog.STYLE_SPINNER)
setCancelable(true)
setButton(ProgressDialog.BUTTON_NEGATIVE, "Annuleren") { _, _ ->
cleanupAction()
}
show()
}
}
private fun showProgressDialog(title: String, message: String, style: Int) {
dialog?.dismiss()
dialog = ProgressDialog(this).apply {
setTitle(title)
setMessage(message)
setProgressStyle(style)
if (style == ProgressDialog.STYLE_HORIZONTAL) {
max = 100
progress = 0
}
setCancelable(false)
show()
}
}
private fun cleanupAction() {
currentAction = UsbAction.NONE
dialog?.dismiss()
dialog = null
}
private fun requestUsbPermission(usbManager: UsbManager, device: UsbDevice) {
val flags = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val permIntent = PendingIntent.getBroadcast(
this, 0,
Intent(ACTION_USB_PERMISSION).setPackage(packageName),
flags
)
usbManager.requestPermission(device, permIntent)
}
// === ESP Key Exchange via USB ===
sealed class KeyResult {
data class Success(val hexKey: String) : KeyResult()
data class Error(val message: String) : KeyResult()
}
private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error("Geen CDC interface gevonden")
val connection = usbManager.openDevice(device)
?: return KeyResult.Error("Kan USB apparaat niet openen")
if (!connection.claimInterface(intf, true)) {
connection.close()
return KeyResult.Error("Kan USB interface niet claimen")
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return KeyResult.Error("Geen USB OUT endpoint gevonden") }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return KeyResult.Error("Geen USB IN endpoint gevonden") }
try {
setupCdcControl(connection, device)
for (attempt in 1..3) {
Thread.sleep(500)
flushReadBuffer(connection, epIn)
val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
val written = connection.bulkTransfer(epOut, cmd, cmd.size, 1000)
Log.d(TAG, "Attempt $attempt: Sent KEY? ($written bytes)")
if (written < 0) {
Log.w(TAG, "Write failed on attempt $attempt")
continue
}
Thread.sleep(500)
val response = readResponse(connection, epIn, KEY_TIMEOUT_MS)
Log.d(TAG, "Attempt $attempt response: ${response.take(300)}")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
val noKey = response.lines().any { it.trim() == "NO_KEY" }
if (keyLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) {
return KeyResult.Success(hexKey.uppercase())
} else {
return KeyResult.Error("Ongeldig sleutel formaat (lengte=${hexKey.length})")
}
} else if (noKey) {
Log.d(TAG, "ESP has no key, provisioning new one")
val newKey = generateRandomHexKey()
val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, 1000)
Log.d(TAG, "Sent SET_KEY ($w bytes)")
if (w < 0) {
return KeyResult.Error("SET_KEY schrijven mislukt")
}
Thread.sleep(500)
val okResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_KEY response: ${okResponse.take(200)}")
if (okResponse.contains("OK")) {
return KeyResult.Success(newKey)
} else {
return KeyResult.Error("ESP bevestigde sleutel niet")
}
}
Log.w(TAG, "Attempt $attempt: no KEY response, retrying")
}
return KeyResult.Error("Geen sleutel gevonden na 3 pogingen")
} catch (e: Exception) {
Log.e(TAG, "Key exchange error", e)
return KeyResult.Error("Fout: ${e.message}")
} finally {
try { connection.close() } catch (e: Exception) {}
}
}
private fun flushReadBuffer(connection: UsbDeviceConnection, epIn: UsbEndpoint) {
val buf = ByteArray(512)
while (true) {
val len = connection.bulkTransfer(epIn, buf, buf.size, 100)
if (len <= 0) break
Log.d(TAG, "Flushed $len bytes from read buffer")
}
}
private fun readResponse(connection: UsbDeviceConnection, epIn: UsbEndpoint, timeoutMs: Long): String {
val buf = ByteArray(512)
val sb = StringBuilder()
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeoutMs) {
val len = connection.bulkTransfer(epIn, buf, buf.size, 500)
if (len > 0) {
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY") || sb.contains("OK")) && sb.contains("\n")) {
break
}
}
}
return sb.toString()
}
private fun setupCdcControl(connection: UsbDeviceConnection, device: UsbDevice) {
val ctrlIntf = (0 until device.interfaceCount)
.map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == 0x02 }
if (ctrlIntf != null && ctrlIntf.endpointCount > 0) {
val lineCoding = byteArrayOf(
0x00.toByte(), 0xC2.toByte(), 0x01.toByte(), 0x00.toByte(),
0x00.toByte(), 0x08.toByte(), 0x08.toByte()
)
connection.controlTransfer(0x21, 0x20, 0, ctrlIntf.id, lineCoding, lineCoding.size, 500)
connection.controlTransfer(0x21, 0x22, 3, ctrlIntf.id, null, 0, 500)
Log.d(TAG, "CDC control interface configured")
} else {
Log.w(TAG, "No CDC control interface found, trying without")
}
}
private fun generateRandomHexKey(): String {
val random = SecureRandom()
val bytes = ByteArray(32)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
}

View file

@ -0,0 +1,318 @@
package com.eddy.atomrelay
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
class UsbKeyReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "UsbKeyReceiver"
private const val ACTION_USB_PERMISSION = "com.eddy.atomrelay.USB_PERMISSION"
private const val ESP_VENDOR_ID = 0x303a
private const val KEY_TIMEOUT_MS = 5000
private const val MAX_RETRIES = 5
private const val RETRY_DELAY_MS = 1000L
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
private const val SET_KEY_TIMEOUT_MS = 3000
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: return
Log.d(TAG, "USB device attached: vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
if (device.vendorId != ESP_VENDOR_ID) return
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val currentDevice = findEspDevice(usbManager) ?: device
if (usbManager.hasPermission(currentDevice)) {
Thread { readKeyRaw(context, usbManager, currentDevice) }.start()
} else {
requestPermission(context, usbManager, currentDevice)
}
}
ACTION_USB_PERMISSION -> {
if (!intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
Log.w(TAG, "USB permission denied")
Handler(Looper.getMainLooper()).post {
Toast.makeText(context, "USB-toegang geweigerd", Toast.LENGTH_SHORT).show()
}
return
}
Log.d(TAG, "USB permission granted")
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val device = findEspDevice(usbManager)
if (device != null) {
Thread { readKeyRaw(context, usbManager, device) }.start()
} else {
Log.e(TAG, "ESP device not found after permission grant")
Handler(Looper.getMainLooper()).post {
Toast.makeText(context, "ESP niet (meer) gevonden", Toast.LENGTH_SHORT).show()
}
}
}
}
}
private fun findEspDevice(usbManager: UsbManager): UsbDevice? {
return usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
}
private fun generateRandomHexKey(): String {
val random = SecureRandom()
val bytes = ByteArray(32)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
private fun requestPermission(context: Context, usbManager: UsbManager, device: UsbDevice) {
val flags = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val permIntent = PendingIntent.getBroadcast(
context, 0,
Intent(context, UsbKeyReceiver::class.java)
.setAction(ACTION_USB_PERMISSION),
flags
)
usbManager.requestPermission(device, permIntent)
Log.d(TAG, "Requesting USB permission for ${device.deviceName}")
}
private fun readKeyRaw(context: Context, usbManager: UsbManager, device: UsbDevice) {
val handler = Handler(Looper.getMainLooper())
for (attempt in 1..MAX_RETRIES) {
Log.d(TAG, "Read attempt $attempt/$MAX_RETRIES")
var connection: UsbDeviceConnection? = null
try {
val currentDevice = findEspDevice(usbManager) ?: device
connection = usbManager.openDevice(currentDevice)
if (connection == null) {
Log.w(TAG, "Attempt $attempt: openDevice returned null")
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
continue
}
handler.post { Toast.makeText(context, "Kan USB-apparaat niet openen", Toast.LENGTH_SHORT).show() }
return
}
val dataInterface = findCdcDataInterface(currentDevice)
if (dataInterface == null) {
Log.w(TAG, "Attempt $attempt: no CDC data interface")
connection.close()
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
continue
}
handler.post { Toast.makeText(context, "Geen CDC interface gevonden op ESP", Toast.LENGTH_SHORT).show() }
return
}
if (!connection.claimInterface(dataInterface, true)) {
Log.w(TAG, "Attempt $attempt: failed to claim interface")
connection.close()
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
continue
}
handler.post { Toast.makeText(context, "Kan USB interface niet claimen", Toast.LENGTH_SHORT).show() }
return
}
val epOut = findBulkEndpoint(dataInterface, UsbConstants.USB_DIR_OUT)
val epIn = findBulkEndpoint(dataInterface, UsbConstants.USB_DIR_IN)
if (epOut == null || epIn == null) {
Log.w(TAG, "Attempt $attempt: missing endpoints")
connection.close()
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
continue
}
handler.post { Toast.makeText(context, "USB endpoints niet gevonden", Toast.LENGTH_SHORT).show() }
return
}
Log.d(TAG, "CDC interface claimed, endpoints ready (out max=${epOut.maxPacketSize}, in max=${epIn.maxPacketSize})")
val controlIntf = findCdcControlInterface(currentDevice)
if (controlIntf != null) {
connection.claimInterface(controlIntf, true)
connection.controlTransfer(
UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or 0x01,
0x20,
0,
controlIntf.id,
byteArrayOf(0x00, 0xC2.toByte(), 0x01, 0x00, 0x00, 0x00, 0x08),
7,
500
)
connection.controlTransfer(
UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or 0x01,
0x22,
3,
controlIntf.id,
null,
0,
500
)
Log.d(TAG, "CDC control interface configured (line coding + DTR/RTS)")
} else {
Log.w(TAG, "No CDC control interface found, trying without")
}
Thread.sleep(300)
val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
val written = connection.bulkTransfer(epOut, cmd, cmd.size, 1000)
Log.d(TAG, "Sent KEY? (${written} bytes)")
Thread.sleep(300)
val buf = ByteArray(512)
val sb = StringBuilder()
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < KEY_TIMEOUT_MS) {
val len = connection.bulkTransfer(epIn, buf, buf.size, 500)
if (len > 0) {
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY")) && sb.contains("\n")) {
break
}
}
}
val response = sb.toString()
Log.d(TAG, "Serial response: ${response.take(200)}")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
val noKey = response.lines().any { it.trim() == "NO_KEY" }
if (keyLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) {
val upperKey = hexKey.uppercase()
val prefs = context.getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(SettingsActivity.PREF_KEY, upperKey).apply()
Log.d(TAG, "HMAC key saved: $upperKey")
handler.post {
Toast.makeText(context, "Beveiligingssleutel gelezen", Toast.LENGTH_LONG).show()
}
return
} else {
Log.e(TAG, "Invalid key format: length=${hexKey.length}")
handler.post { Toast.makeText(context, "Ongeldig sleutel formaat", Toast.LENGTH_SHORT).show() }
return
}
} else if (noKey) {
Log.d(TAG, "ESP has no key — generating and provisioning")
val newKey = generateRandomHexKey()
Log.d(TAG, "Generated key: $newKey")
val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII)
val written = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, 1000)
Log.d(TAG, "Sent SET_KEY (${written} bytes)")
Thread.sleep(300)
val okBuf = ByteArray(256)
val okSb = StringBuilder()
val okStart = System.currentTimeMillis()
while (System.currentTimeMillis() - okStart < SET_KEY_TIMEOUT_MS) {
val len = connection.bulkTransfer(epIn, okBuf, okBuf.size, 500)
if (len > 0) {
okSb.append(String(okBuf, 0, len, StandardCharsets.US_ASCII))
if (okSb.contains("OK") && okSb.contains("\n")) break
}
}
Log.d(TAG, "SET_KEY response: ${okSb.toString().take(100)}")
if (okSb.contains("OK")) {
Log.d(TAG, "Key provisioned successfully, saving to prefs")
val prefs = context.getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(SettingsActivity.PREF_KEY, newKey).apply()
handler.post {
Toast.makeText(context, "Beveiligingssleutel gelezen", Toast.LENGTH_LONG).show()
}
return
} else {
Log.w(TAG, "SET_KEY failed, no OK response")
connection.close()
connection = null
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
}
}
} else {
Log.w(TAG, "Attempt $attempt: no HMAC_KEY in response, will retry")
connection.close()
connection = null
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
}
}
} catch (e: Exception) {
Log.e(TAG, "Attempt $attempt error: ${e.message}", e)
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
} else {
handler.post { Toast.makeText(context, "Fout bij uitlezen: ${e.message}", Toast.LENGTH_SHORT).show() }
}
} finally {
try { connection?.close() } catch (e: Exception) {}
}
}
Log.e(TAG, "All $MAX_RETRIES attempts failed")
handler.post { Toast.makeText(context, "Sleutel niet gevonden na $MAX_RETRIES pogingen", Toast.LENGTH_SHORT).show() }
}
private fun findCdcDataInterface(device: UsbDevice): UsbInterface? {
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)
if (intf.interfaceClass == CDC_DATA_INTERFACE_CLASS) {
return intf
}
}
return null
}
private fun findCdcControlInterface(device: UsbDevice): UsbInterface? {
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)
if (intf.interfaceClass == 0x02) {
return intf
}
}
return null
}
private fun findBulkEndpoint(intf: UsbInterface, direction: Int): UsbEndpoint? {
for (i in 0 until intf.endpointCount) {
val ep = intf.getEndpoint(i)
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK && ep.direction == direction) {
return ep
}
}
return null
}
}

View file

@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
package com.eddy.atomrelay
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDeviceConnection
@ -13,36 +12,30 @@ class UsbSerialTransport(
private val epIn: UsbEndpoint
) : SerialTransport {
companion object {
private const val USB_TIMEOUT_MS = 5000
private const val CONTROL_LINE_TIMEOUT_MS = 1000
private const val MIN_USB_PACKET = 64
}
override fun read(buffer: ByteArray, length: Int): Int {
val n = connection.bulkTransfer(epIn, buffer, length, USB_TIMEOUT_MS)
val n = connection.bulkTransfer(epIn, buffer, length, 5000)
if (n < 0) return 0
return n
}
override fun write(buffer: ByteArray, length: Int) {
val n = connection.bulkTransfer(epOut, buffer, length, USB_TIMEOUT_MS)
val n = connection.bulkTransfer(epOut, buffer, length, 5000)
if (n < 0) throw IOException("USB write failed")
}
override fun setControlLines(dtr: Boolean, rts: Boolean) {
connection.controlTransfer(
UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or 0x01,
UsbUtils.CDC_SET_CONTROL_LINE_STATE,
(if (dtr) UsbUtils.CDC_DTR else 0x00) or (if (rts) UsbUtils.CDC_RTS else 0x00),
0x22,
(if (dtr) 0x01 else 0x00) or (if (rts) 0x02 else 0x00),
0,
null,
0,
CONTROL_LINE_TIMEOUT_MS
1000
)
}
override fun readBufferSize(): Int {
return epIn.maxPacketSize.coerceAtLeast(MIN_USB_PACKET)
return epIn.maxPacketSize.coerceAtLeast(64)
}
}

View file

@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
package com.eddy.atomrelay
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
@ -9,13 +8,6 @@ import android.hardware.usb.UsbInterface
object UsbUtils {
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
// CDC-ACM class-specific control request constants (USB CDC 1.20 spec)
const val CDC_REQUEST_TYPE = 0x21
const val CDC_SET_LINE_CODING = 0x20
const val CDC_SET_CONTROL_LINE_STATE = 0x22
const val CDC_DTR = 0x01
const val CDC_RTS = 0x02
fun findCdcDataInterface(device: UsbDevice): UsbInterface? {
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)

View file

@ -1,647 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyPairGenerator
import java.security.SecureRandom
import java.security.spec.ECGenParameterSpec
import java.security.interfaces.ECPublicKey
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.text.Charsets
import kotlinx.coroutines.CompletableDeferred
class BleRelayManager(
private val context: Context,
private val callbacks: BleCallbacks
) {
companion object {
var instance: BleRelayManager? = null
private const val TAG = "BleRelayManager"
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9")
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db")
private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
private val ECDH_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dc")
private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L
private const val MAX_SCAN_RETRIES = 3
private const val AES_PKT_SIZE = 29
private const val GCM_TAG_LEN = 16
private const val GCM_NONCE_LEN = 12
private const val AES_KEY_LEN = 32
private const val PSK_LEN = 32
private const val ECDH_PUBKEY_LEN = 65
private const val ECDH_PKT_SIZE = 97
private const val BLE_MTU = 512
private const val ECDH_TIMEOUT_MS = 5000L
}
private var bluetoothGatt: BluetoothGatt? = null
private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var ecdhCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false
private var scanTimedOut = false
private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper())
private var seq = 1
private var psk: ByteArray? = null
@Volatile private var aesKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
private var otaCharacteristic: BluetoothGattCharacteristic? = null
private var espVersion: String? = null
private var appVersion: String = ""
private var negotiatedMtu: Int = 23
private var ecdhDone = false
@Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null
private var ecdhNotifyBuf: ByteArray = ByteArray(0)
private var ecdhCccdPending = false
interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int)
}
private var otaNotifyCallback: OtaNotifyCallback? = null
fun setOtaNotifyCallback(callback: OtaNotifyCallback?) {
otaNotifyCallback = callback
}
init {
instance = this
}
private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
@SuppressLint("MissingPermission")
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device ?: return
val address = device.address ?: return
Log.d(TAG, "Found device: ${device.name ?: ""} ($address)")
if (triedAddresses.contains(address)) {
Log.d(TAG, "Skipping already-tried device $address")
return
}
val scanRecord = result.scanRecord
val storedUuid = deviceUuid
if (scanRecord != null && storedUuid != null) {
val mfgData = scanRecord.getManufacturerSpecificData(0xFFFF)
if (mfgData != null && mfgData.size >= 4) {
val advPrefix = mfgData.copyOf(4)
val storedPrefix = storedUuid.copyOf(4)
if (!advPrefix.contentEquals(storedPrefix)) {
Log.d(TAG, "Skipping non-matching UUID prefix ($address)")
return
}
}
}
stopScan()
connectToDevice(device)
}
}
@SuppressLint("MissingPermission")
fun startScan() {
if (isScanning) return
if (!hasPermissions()) {
handler.post { callbacks.onStatusChanged(BleStatus.PermissionsDenied) }
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || !adapter.isEnabled) {
handler.post { callbacks.onStatusChanged(BleStatus.BluetoothDisabled) }
return
}
loadState()
if (psk == null || deviceUuid == null) {
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
return
}
isScanning = true
scanTimedOut = false
scanTimeoutRetries = 0
handler.post { callbacks.onStatusChanged(BleStatus.Scanning) }
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
adapter.bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
scanTimeoutRunnable = Runnable {
if (isScanning) {
scanTimedOut = true
stopScan()
}
}
handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS)
}
@SuppressLint("MissingPermission")
fun stopScan() {
handler.removeCallbacks(scanRunnable)
if (!isScanning) return
isScanning = false
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (scanTimedOut) {
if (scanTimeoutRetries < MAX_SCAN_RETRIES) {
scanTimeoutRetries++
handler.postDelayed(scanRunnable, 2000L)
return
}
handler.post { callbacks.onStatusChanged(BleStatus.DeviceNotFound) }
}
}
fun hasPermissions(): Boolean {
return getRequiredPermissions().all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
fun getRequiredPermissions(): Array<String> {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
@SuppressLint("MissingPermission")
fun destroy() {
handler.removeCallbacksAndMessages(null)
stopScan()
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
}
@SuppressLint("MissingPermission")
fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return
val key = aesKey ?: return
val packet = buildPacket(cmd, key)
char.value = packet
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
seq++
if (seq > PrefsHelper.SEQ_MAX) seq = 1
PrefsHelper.getPrefs(context).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply()
Log.d(TAG, "Sent relay cmd 0x${cmd.toString(16)} (seq=${seq - 1})")
}
@SuppressLint("MissingPermission")
fun disconnect() {
handler.removeCallbacks(scanRunnable)
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
stopScan()
bluetoothGatt?.disconnect()
}
@SuppressLint("MissingPermission")
fun sendOtaData(data: ByteArray): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return gatt.writeCharacteristic(char)
}
@SuppressLint("MissingPermission")
fun enableOtaNotifications(): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
if (!gatt.setCharacteristicNotification(char, true)) {
Log.e(TAG, "setCharacteristicNotification returned false")
return false
}
val desc = char.getDescriptor(OTA_CCCD_UUID)
if (desc != null) {
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (!gatt.writeDescriptor(desc)) {
Log.w(TAG, "writeDescriptor for CCCD returned false")
}
}
return true
}
fun hasOtaCharacteristic(): Boolean {
return otaCharacteristic != null
}
fun updateKeys() {
loadState()
}
fun resetTriedAddresses() {
triedAddresses.clear()
}
fun isConnected(): Boolean {
return bluetoothGatt != null
}
fun setAppVersion(version: String) {
appVersion = version
}
fun getEspVersion(): String? = espVersion
fun getNegotiatedMtu(): Int = negotiatedMtu
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
Log.d(TAG, "Connecting to ${device.address}")
bluetoothGatt?.close()
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}
private val gattCallback = object : android.bluetooth.BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
if (psk == null) {
Log.w(TAG, "GATT connected but no PSK -- disconnecting")
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
gatt.disconnect()
return
}
Log.d(TAG, "GATT connected")
bluetoothGatt = gatt
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected")
handler.post { callbacks.onStatusChanged(BleStatus.Disconnected) }
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
ecdhCharacteristic = null
aesKey = null
ecdhDone = false
ecdhCccdPending = false
seq = 1
triedAddresses.clear()
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: android.bluetooth.BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "Service discovery failed (status=$status)")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceError) }
return
}
Log.d(TAG, "Services discovered, requesting MTU...")
gatt.requestMtu(BLE_MTU)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
negotiatedMtu = mtu
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
otaCharacteristic = service.getCharacteristic(OTA_UUID)
ecdhCharacteristic = service.getCharacteristic(ECDH_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
val versionChar = service.getCharacteristic(VERSION_UUID)
if (relayCharacteristic != null && uuidChar != null) {
Log.d(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e(TAG, "Service or characteristics not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
} else {
Log.e(TAG, "Service not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicRead(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val storedUuid = deviceUuid
if (storedUuid != null && value.contentEquals(storedUuid)) {
Log.d(TAG, "UUID verified, starting ECDH handshake...")
startEcdhHandshake(gatt)
} else {
Log.w(TAG, "UUID mismatch -- disconnecting")
triedAddresses.add(gatt.device.address)
handler.post { callbacks.onStatusChanged(BleStatus.WrongDevice) }
gatt.disconnect()
}
} else if (characteristic.uuid == VERSION_UUID) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val version = String(value, Charsets.UTF_8).trim()
espVersion = version
Log.d(TAG, "ESP firmware version: $version, app bundled: $appVersion")
handler.post { callbacks.onFirmwareVersion(version, appVersion) }
} else {
Log.w(TAG, "Version read failed (status=$status), treating as no version")
handler.post { callbacks.onFirmwareVersion("", appVersion) }
}
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicWrite(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "Write failed (status=$status)")
handler.post { callbacks.onStatusChanged(BleStatus.CommError) }
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
if (characteristic.uuid == OTA_UUID && value.size >= 1) {
val statusVal = value[0].toInt() and 0xFF
val seqVal = if (value.size >= 5) {
((value[1].toInt() and 0xFF) shl 24) or
((value[2].toInt() and 0xFF) shl 16) or
((value[3].toInt() and 0xFF) shl 8) or
(value[4].toInt() and 0xFF)
} else 0
val statusName = when (statusVal) { 0x00 -> "ACK"; 0x01 -> "NAK"; 0x10 -> "COMPLETE"; 0x11 -> "ERROR"; else -> "UNKNOWN(0x${statusVal.toString(16)})" }
Log.d(TAG, "OTA notify: status=$statusName seq=$seqVal raw=${value.joinToString("") { "%02X".format(it) }}")
handler.post { otaNotifyCallback?.onOtaNotify(statusVal, seqVal) }
} else if (characteristic.uuid == ECDH_UUID) {
ecdhNotifyBuf = value.copyOf()
ecdhDeferred?.complete(true)
Log.d(TAG, "ECDH notify received (${value.size} bytes)")
}
}
@SuppressLint("MissingPermission")
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (ecdhCccdPending && descriptor.characteristic.uuid == ECDH_UUID) {
ecdhCccdPending = false
if (status == BluetoothGatt.GATT_SUCCESS) {
sendEcdhHandshake(gatt)
} else {
Log.w(TAG, "ECDH: CCCD write failed (status=$status)")
gatt.disconnect()
}
}
}
}
@SuppressLint("MissingPermission")
private fun startEcdhHandshake(gatt: BluetoothGatt) {
val char = ecdhCharacteristic
if (char == null) {
Log.w(TAG, "ECDH characteristic not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
gatt.disconnect()
return
}
val cccd = char.getDescriptor(OTA_CCCD_UUID)
if (cccd == null) {
Log.w(TAG, "ECDH: no CCCD descriptor")
gatt.disconnect()
return
}
if (!gatt.setCharacteristicNotification(char, true)) {
Log.w(TAG, "ECDH: setCharacteristicNotification failed")
gatt.disconnect()
return
}
ecdhCccdPending = true
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (!gatt.writeDescriptor(cccd)) {
ecdhCccdPending = false
Log.w(TAG, "ECDH: writeDescriptor for CCCD returned false")
gatt.disconnect()
}
}
@SuppressLint("MissingPermission")
private fun sendEcdhHandshake(gatt: BluetoothGatt) {
val myPsk = psk ?: run {
Log.w(TAG, "ECDH: no PSK")
gatt.disconnect()
return
}
try {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val keyPair = kpg.generateKeyPair()
val pubKey = keyPair.public as ECPublicKey
val pubBytes = encodeEcPubKey(pubKey)
if (pubBytes.size != ECDH_PUBKEY_LEN) {
Log.e(TAG, "ECDH: pubkey wrong size ${pubBytes.size}")
gatt.disconnect()
return
}
val handshake = ByteArray(ECDH_PKT_SIZE)
System.arraycopy(pubBytes, 0, handshake, 0, ECDH_PUBKEY_LEN)
val sig = hmacSha256(pubBytes, myPsk)
System.arraycopy(sig, 0, handshake, ECDH_PUBKEY_LEN, 32)
ecdhDeferred = CompletableDeferred()
ecdhCharacteristic!!.value = handshake
ecdhCharacteristic!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
if (!gatt.writeCharacteristic(ecdhCharacteristic!!)) {
Log.w(TAG, "ECDH: write pubkey failed")
gatt.disconnect()
return
}
Log.d(TAG, "ECDH: pubkey written, waiting for server...")
Thread {
val start = System.currentTimeMillis()
while (!ecdhDeferred!!.isCompleted && System.currentTimeMillis() - start < ECDH_TIMEOUT_MS) {
Thread.sleep(50)
}
if (!ecdhDeferred!!.isCompleted) {
Log.w(TAG, "ECDH: notify timeout")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val resp = ecdhNotifyBuf
if (resp.size != ECDH_PKT_SIZE) {
Log.w(TAG, "ECDH: bad response size ${resp.size}")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val serverPub = resp.copyOfRange(0, ECDH_PUBKEY_LEN)
val serverSig = resp.copyOfRange(ECDH_PUBKEY_LEN, ECDH_PKT_SIZE)
val expectedSig = hmacSha256(serverPub, myPsk)
if (!serverSig.contentEquals(expectedSig)) {
Log.w(TAG, "ECDH: server pubkey HMAC mismatch")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val ka = KeyAgreement.getInstance("ECDH")
ka.init(keyPair.private)
val spec = java.security.spec.ECPublicKeySpec(
java.security.spec.ECPoint(
java.math.BigInteger(1, serverPub.copyOfRange(1, 33)),
java.math.BigInteger(1, serverPub.copyOfRange(33, 65))
),
java.security.spec.ECParameterSpec(
java.security.spec.EllipticCurve(
java.security.spec.ECFieldFp(
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", 16)
),
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC", 16),
java.math.BigInteger("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B", 16)
),
java.security.spec.ECPoint(
java.math.BigInteger("6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296", 16),
java.math.BigInteger("4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5", 16)
),
java.math.BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16),
1
)
)
val serverKey = java.security.KeyFactory.getInstance("EC").generatePublic(spec)
ka.doPhase(serverKey, true)
val shared = ka.generateSecret()
val md = java.security.MessageDigest.getInstance("SHA-256")
aesKey = md.digest(shared)
ecdhDone = true
seq = 1
handler.post {
Log.d(TAG, "ECDH: handshake complete, reading version...")
val service = gatt.getService(SERVICE_UUID)
val versionChar = service?.getCharacteristic(VERSION_UUID)
if (versionChar != null) {
val ok = gatt.readCharacteristic(versionChar)
if (!ok) {
Log.w(TAG, "readCharacteristic(VERSION) returned false")
callbacks.onFirmwareVersion("", appVersion)
callbacks.onStatusChanged(BleStatus.Connected)
}
} else {
Log.d(TAG, "VERSION characteristic not found on ESP")
callbacks.onFirmwareVersion("", appVersion)
callbacks.onStatusChanged(BleStatus.Connected)
}
}
}.start()
} catch (e: Exception) {
Log.e(TAG, "ECDH: exception", e)
gatt.disconnect()
}
}
private fun encodeEcPubKey(pubKey: ECPublicKey): ByteArray {
val x = pubKey.w.affineX.toByteArray()
val y = pubKey.w.affineY.toByteArray()
val xPadded = ByteArray(32)
val yPadded = ByteArray(32)
System.arraycopy(x, maxOf(0, x.size - 32), xPadded, maxOf(0, 32 - x.size), minOf(x.size, 32))
System.arraycopy(y, maxOf(0, y.size - 32), yPadded, maxOf(0, 32 - y.size), minOf(y.size, 32))
val out = ByteArray(65)
out[0] = 0x04
System.arraycopy(xPadded, 0, out, 1, 32)
System.arraycopy(yPadded, 0, out, 33, 32)
return out
}
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
private fun loadState() {
val prefs = PrefsHelper.getPrefs(context)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
psk = hexKey?.let { hexStringToBytes(it) }
val hexUuid = PrefsHelper.getServerUuid(context)
?: prefs.getString(PrefsHelper.PREF_UUID, null)
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
Log.d(TAG, "PSK loaded: ${psk?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq")
}
private fun hexStringToBytes(hex: String): ByteArray? {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray {
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
val nonceRandom = ByteArray(8)
SecureRandom().nextBytes(nonceRandom)
val nonce = ByteArray(GCM_NONCE_LEN)
System.arraycopy(seqBytes, 0, nonce, 0, 4)
System.arraycopy(nonceRandom, 0, nonce, 4, 8)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE,
SecretKeySpec(key, "AES"),
GCMParameterSpec(GCM_TAG_LEN * 8, nonce))
val cipherOutput = cipher.doFinal(byteArrayOf(cmd))
val packet = ByteArray(AES_PKT_SIZE)
System.arraycopy(seqBytes, 0, packet, 0, 4)
System.arraycopy(nonceRandom, 0, packet, 4, 8)
System.arraycopy(cipherOutput, 0, packet, 12, cipherOutput.size)
return packet
}
}

View file

@ -1,22 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
enum class BleStatus {
Scanning,
Connecting,
Connected,
Disconnected,
NoKey,
WrongDevice,
ServiceNotFound,
ServiceError,
CommError,
DeviceNotFound,
BluetoothDisabled,
PermissionsDenied
}
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
fun onFirmwareVersion(espVersion: String, appVersion: String)
}

View file

@ -1,139 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Context
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import org.dkaukov.esp32.core.EspFlasherApi
import org.dkaukov.esp32.io.ProgressCallback
import java.io.IOException
import kotlin.coroutines.coroutineContext
class EspFlasher(private val context: Context) {
companion object {
private const val TAG = "EspFlasher"
private const val USB_SETTLE_MS = 500L
private const val KB = 1024
private const val FLASH_RESET_PCT = 95
private const val FLASH_DONE_PCT = 100
}
interface FlashCallback {
fun onProgress(percent: Int, message: String)
}
private fun assetPath(baseName: String): String {
val prefix = if (PrefsHelper.getDeviceMode(context) == "client") "client/" else "server/"
return "firmware/$prefix$baseName"
}
suspend fun flashDevice(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
try {
withContext(kotlinx.coroutines.Dispatchers.IO) {
doFlash(usbManager, device, callback)
}
} catch (e: CancellationException) {
Log.i(TAG, "Flash cancelled")
throw e
} catch (e: Exception) {
Log.e(TAG, "Flash failed", e)
throw e
}
}
private class FlashStep(
val nameResId: Int,
val address: Int,
val data: ByteArray,
val baseProgress: Int,
val range: Int,
val compress: Boolean
)
private suspend fun doFlash(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
kotlinx.coroutines.delay(USB_SETTLE_MS)
coroutineContext.ensureActive()
val connection = usbManager.openDevice(device)
?: throw IOException(context.getString(R.string.err_usb_open))
try {
val intf = UsbUtils.findCdcDataInterface(device)
?: throw IOException(context.getString(R.string.err_no_cdc))
if (!connection.claimInterface(intf, true)) {
throw IOException(context.getString(R.string.err_usb_claim))
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: throw IOException(context.getString(R.string.err_no_out))
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: throw IOException(context.getString(R.string.err_no_in))
callback.onProgress(0, context.getString(R.string.flash_connecting))
val transport = UsbSerialTransport(connection, epOut, epIn)
coroutineContext.ensureActive()
val assets = context.assets
val bootloader = assets.open(assetPath("bootloader.bin")).readBytes()
val partitions = assets.open(assetPath("partitions.bin")).readBytes()
val bootApp0 = assets.open(assetPath("boot_app0.bin")).readBytes()
val firmware = assets.open(assetPath("firmware.bin")).readBytes()
val steps = listOf(
FlashStep(R.string.flash_step_bootloader, 0x0, bootloader, 30, 10, false),
FlashStep(R.string.flash_step_partitions, 0x8000, partitions, 40, 10, false),
FlashStep(R.string.flash_step_boot_app0, 0xe000, bootApp0, 50, 10, false),
FlashStep(R.string.flash_step_firmware, 0x10000, firmware, 60, 35, true)
)
callback.onProgress(5, context.getString(R.string.flash_sync))
val api = EspFlasherApi.connect(transport)
.withCallBack(object : ProgressCallback {
override fun onProgress(pct: Float) {
Log.d(TAG, "Lib progress: $pct%")
}
override fun onInfo(value: String) {
Log.d(TAG, "Info: $value")
}
})
callback.onProgress(10, context.getString(R.string.flash_detect))
val detected = api.chipDetect()
val chipId = detected.getChipId()
Log.i(TAG, "Detected chip: $chipId")
callback.onProgress(15, context.getString(R.string.flash_chip, chipId.readableName))
callback.onProgress(20, context.getString(R.string.flash_stub))
val stub = detected.loadStub()
for (step in steps) {
coroutineContext.ensureActive()
val stepName = context.getString(step.nameResId)
val sizeStr = if (step.data.size > KB) "${step.data.size / KB}KB" else "${step.data.size} bytes"
callback.onProgress(step.baseProgress, context.getString(R.string.flash_writing, stepName, sizeStr))
val writeStub = if (step.compress) stub.withCompression(true) else stub.withCompression(false)
writeStub.writeFlash(step.address, step.data)
val endProgress = step.baseProgress + step.range
callback.onProgress(endProgress.coerceAtMost(FLASH_RESET_PCT), context.getString(R.string.flash_written, stepName))
}
callback.onProgress(FLASH_RESET_PCT, context.getString(R.string.flash_resetting))
stub.reset()
callback.onProgress(FLASH_DONE_PCT, context.getString(R.string.flash_done))
} finally {
connection.close()
}
}
}

View file

@ -1,229 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.widget.Button
import android.widget.ImageButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity(), BleCallbacks {
companion object {
private const val POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L
}
private lateinit var manager: BleRelayManager
private val handler = Handler(Looper.getMainLooper())
private lateinit var statusText: TextView
private lateinit var relay1Button: Button
private lateinit var relay2Button: Button
private var lastTheme: String? = null
private var triedPermissions = false
private val settingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == SettingsActivity.RESULT_SCAN) {
triedPermissions = false
manager.disconnect()
manager.resetTriedAddresses()
manager.updateKeys()
handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
}
}
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
if (grants.values.all { it }) {
manager.startScan()
} else {
triedPermissions = false
onStatusChanged(BleStatus.PermissionsDenied)
}
}
override fun onStatusChanged(status: BleStatus) {
when (status) {
BleStatus.Connected -> {
statusText.text = getString(R.string.status_connected)
setupRelayButton()
setupRelay2Button()
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
BleStatus.Disconnected -> {
statusText.text = getString(R.string.status_connecting)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning)
BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting)
BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key)
BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device)
BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found)
BleStatus.ServiceError -> statusText.text = getString(R.string.status_service_error)
BleStatus.CommError -> statusText.text = getString(R.string.status_comm_error)
BleStatus.BluetoothDisabled -> statusText.text = getString(R.string.status_bluetooth_disabled)
BleStatus.PermissionsDenied -> {
statusText.text = getString(R.string.status_permissions_denied)
statusText.setOnClickListener {
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", packageName, null)
startActivity(intent)
}
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
BleStatus.DeviceNotFound -> {
relay1Button.setOnTouchListener(null)
relay2Button.setOnTouchListener(null)
relay1Button.setOnClickListener(null)
relay2Button.setOnClickListener(null)
loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_retry_scan)
loadButtonLabel(relay2Button, PrefsHelper.PREF_LABEL2, R.string.button_hold)
relay2Button.isEnabled = false
relay1Button.isEnabled = true
relay1Button.setOnClickListener {
setupRelayButton()
setupRelay2Button()
manager.startScan()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
PrefsHelper.applyTheme(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
manager = BleRelayManager(this, this)
manager.setAppVersion(SylDaVersions.SERVER)
statusText = findViewById(R.id.statusText)
relay1Button = findViewById(R.id.relay1Button)
relay2Button = findViewById(R.id.relay2Button)
val settingsContainer = findViewById<android.view.View>(R.id.settingsContainer)
settingsContainer.setOnClickListener {
settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
}
findViewById<ImageButton>(R.id.settingsButton).setOnClickListener {
settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
}
setupRelayButton()
setupRelay2Button()
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
override fun onResume() {
super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
if (current != lastTheme) {
lastTheme = current
recreate()
return
}
loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_hold)
loadButtonLabel(relay2Button, PrefsHelper.PREF_LABEL2, R.string.button_hold)
if (!manager.hasPermissions()) {
if (!triedPermissions) {
triedPermissions = true
permissionLauncher.launch(manager.getRequiredPermissions())
}
return
}
if (!manager.isConnected()) {
manager.resetTriedAddresses()
handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS)
}
}
override fun onPause() {
super.onPause()
manager.stopScan()
}
private fun setupRelayButton() {
relay1Button.setOnClickListener(null)
loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_hold)
val a = Color.rgb(0xE0, 0xE0, 0xE0)
val b = ContextCompat.getColor(this, R.color.colorAccent)
relay1Button.backgroundTintList = null
relay1Button.setBackgroundColor(a)
relay1Button.setTextColor(Color.BLACK)
relay1Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
manager.sendRelayCommand(0x01.toByte())
relay1Button.setBackgroundColor(b)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
manager.sendRelayCommand(0x00.toByte())
relay1Button.setBackgroundColor(a)
true
}
else -> false
}
}
}
private fun setupRelay2Button() {
relay2Button.setOnClickListener(null)
loadButtonLabel(relay2Button, PrefsHelper.PREF_LABEL2, R.string.button_hold)
val a = Color.rgb(0xE0, 0xE0, 0xE0)
val b = ContextCompat.getColor(this, R.color.colorAccent)
relay2Button.backgroundTintList = null
relay2Button.setBackgroundColor(a)
relay2Button.setTextColor(Color.BLACK)
relay2Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
manager.sendRelayCommand(0x02.toByte())
relay2Button.setBackgroundColor(b)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
manager.sendRelayCommand(0x03.toByte())
relay2Button.setBackgroundColor(a)
true
}
else -> false
}
}
}
private fun loadButtonLabel(button: Button, prefKey: String, fallbackResId: Int) {
val prefs = PrefsHelper.getPrefs(this)
val label = prefs.getString(prefKey, "")?.trim()
button.text = if (label.isNullOrEmpty()) getString(fallbackResId) else label
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
manager.destroy()
}
override fun onFirmwareVersion(espVersion: String, appVersion: String) {
runOnUiThread {
if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread
if (espVersion.isNotEmpty() && espVersion != appVersion) {
Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_SHORT).show()
settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
}
}
}
}

View file

@ -1,203 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
class OtaManager(
private val context: Context,
private val relayManager: BleRelayManager
) {
companion object {
private const val TAG = "OtaManager"
private const val OTA_CMD_START: Byte = 0x10
private const val OTA_START_TIMEOUT_MS = 10000L
private const val OTA_CHUNK_TIMEOUT_MS = 2000L
private const val OTA_FINAL_TIMEOUT_MS = 10000L
private const val MAX_RETRIES = 3
private const val OTA_ESP_MAX_CHUNK = 500
}
interface OtaCallback {
fun onProgress(percent: Int, message: String)
fun onComplete()
fun onError(message: String)
}
private var firmwareData: ByteArray? = null
private var chunkDataSize: Int = 248
private fun calculateChunkSize(mtu: Int): Int {
return minOf(maxOf(mtu - 3 - 4, 20), OTA_ESP_MAX_CHUNK)
}
fun loadFirmware(assetPath: String = "firmware/firmware.bin"): Boolean {
return try {
firmwareData = context.assets.open(assetPath).readBytes()
chunkDataSize = calculateChunkSize(relayManager.getNegotiatedMtu())
Log.d(TAG, "Loaded firmware from $assetPath: ${firmwareData!!.size} bytes, chunkDataSize=$chunkDataSize")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to load firmware from $assetPath", e)
false
}
}
suspend fun performOta(callback: OtaCallback): Boolean = withContext(Dispatchers.IO) {
val fw = firmwareData ?: run {
withContext(Dispatchers.Main) { callback.onError("Firmware niet geladen") }
return@withContext false
}
val sha256 = MessageDigest.getInstance("SHA-256").digest(fw)
var chunkAckReceived = false
var chunkNakReceived = false
var finalStatusReceived = false
var finalStatus = -1
val notifyCallback = object : BleRelayManager.OtaNotifyCallback {
override fun onOtaNotify(status: Int, seq: Int) {
Log.d(TAG, "OTA notify received: status=0x${status.toString(16)} seq=$seq (0x00=ACK, 0x01=NAK, 0x10=COMPLETE, 0x11=ERROR)")
when (status) {
0x00 -> chunkAckReceived = true
0x01 -> chunkNakReceived = true
0x10 -> { finalStatusReceived = true; finalStatus = 0x10 }
0x11 -> { finalStatusReceived = true; finalStatus = 0x11 }
}
}
}
relayManager.setOtaNotifyCallback(notifyCallback)
try {
withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") }
val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN)
startPacket.put(OTA_CMD_START)
startPacket.putInt(fw.size)
var startSent = false
for (attempt in 1..MAX_RETRIES) {
if (relayManager.sendOtaData(startPacket.array())) {
startSent = true
Log.d(TAG, "OTA start packet sent on attempt $attempt")
break
}
Log.w(TAG, "OTA start packet failed attempt $attempt, retrying...")
delay(500)
}
if (!startSent) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA mislukt: start packet niet verzonden") }
return@withContext false
}
delay(500)
val totalChunks = (fw.size + chunkDataSize - 1) / chunkDataSize
var seq = 0
var offset = 0
while (offset < fw.size) {
val remaining = fw.size - offset
val chunkSize = minOf(remaining, chunkDataSize)
val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN)
packet.putInt(seq)
packet.put(fw, offset, chunkSize)
chunkAckReceived = false
chunkNakReceived = false
var retries = 0
while (retries < MAX_RETRIES) {
val writeOk = relayManager.sendOtaData(packet.array())
if (!writeOk) {
Log.w(TAG, "writeCharacteristic returned false for chunk $seq, retrying")
retries++
delay(100)
continue
}
Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}")
try {
withTimeout(OTA_CHUNK_TIMEOUT_MS) {
while (!chunkAckReceived && !chunkNakReceived) {
delay(50)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for ACK/NAK on chunk $seq")
}
if (chunkAckReceived) break
retries++
Log.w(TAG, "Chunk $seq NAK/timeout, retry $retries/$MAX_RETRIES")
}
if (!chunkAckReceived) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA mislukt: chunk $seq geen ACK na $MAX_RETRIES pogingen") }
return@withContext false
}
val pct = ((offset.toFloat() / fw.size.toFloat()) * 99).toInt().coerceAtMost(99)
withContext(Dispatchers.Main) {
callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks")
}
offset += chunkSize
seq++
}
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
finalStatusReceived = false
Log.d(TAG, "Sending SHA256 hash (${sha256.size} bytes) to ESP")
val shaSent = relayManager.sendOtaData(sha256)
Log.d(TAG, "SHA256 write result: $shaSent")
try {
withTimeout(OTA_FINAL_TIMEOUT_MS) {
while (!finalStatusReceived) {
delay(100)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for final OTA status after SHA256")
}
Log.d(TAG, "Final status: received=$finalStatusReceived status=$finalStatus (0x10=COMPLETE, 0x11=ERROR)")
relayManager.setOtaNotifyCallback(null)
if (finalStatus == 0x10) {
Log.d(TAG, "ESP confirmed SHA256 OK, sending 0xFF reboot confirmation")
val confirmSent = relayManager.sendOtaData(byteArrayOf(0xFF.toByte()))
Log.d(TAG, "0xFF write result: $confirmSent")
withContext(Dispatchers.Main) {
callback.onProgress(100, "Herstart...")
callback.onComplete()
}
true
} else {
withContext(Dispatchers.Main) {
callback.onError("OTA mislukt: ESP SHA256 verificatie fout")
}
false
}
} catch (e: Exception) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA fout: ${e.message}") }
false
}
}
}

View file

@ -1,45 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Context
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatDelegate
object PrefsHelper {
private const val TAG = "PrefsHelper"
const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "psk"
const val PREF_UUID = "device_uuid"
const val PREF_SEQ = "seq_counter"
const val PREF_THEME = "theme"
const val PREF_LABEL1 = "label_relay1"
const val PREF_LABEL2 = "label_relay2"
const val KEY_DEVICE_MODE = "deviceMode"
const val KEY_SERVER_UUID = "serverUuid"
const val KEY_CLIENT_UUID = "clientUuid"
const val SEQ_MAX = 1_000_000
fun getDeviceMode(context: Context): String = getPrefs(context).getString(KEY_DEVICE_MODE, "server") ?: "server"
fun setDeviceMode(context: Context, mode: String) = getPrefs(context).edit().putString(KEY_DEVICE_MODE, mode).apply()
fun getTransportMode(context: Context): String = getPrefs(context).getString("transportMode", "ble") ?: "ble"
fun setTransportMode(context: Context, mode: String) = getPrefs(context).edit().putString("transportMode", mode).apply()
fun getServerUuid(context: Context): String? = getPrefs(context).getString(KEY_SERVER_UUID, null)
fun setServerUuid(context: Context, uuid: String) = getPrefs(context).edit().putString(KEY_SERVER_UUID, uuid).apply()
fun getClientUuid(context: Context): String? = getPrefs(context).getString(KEY_CLIENT_UUID, null)
fun setClientUuid(context: Context, uuid: String) = getPrefs(context).edit().putString(KEY_CLIENT_UUID, uuid).apply()
fun applyTheme(context: Context) {
val prefs = getPrefs(context)
val mode = when (prefs.getString(PREF_THEME, "system")) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(mode)
}
fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}

View file

@ -1,967 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.app.AlertDialog
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
class SettingsActivity : AppCompatActivity() {
companion object {
const val RESULT_SCAN = 100
private const val TAG = "SettingsActivity"
private const val ESP_VENDOR_ID = 0x303a
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
private const val KEY_TIMEOUT_MS = 5000L
private const val SET_KEY_TIMEOUT_MS = 5000L
private const val MAX_KEY_RETRIES = 5
private const val KEY_SETTLE_MS = 1000L
private const val RESPONSE_WAIT_MS = 500L
private const val USB_BULK_TIMEOUT_MS = 1000
private const val FLUSH_TIMEOUT_MS = 100
private const val READ_CHUNK_TIMEOUT_MS = 500
private const val CDC_CONTROL_TIMEOUT_MS = 500
private const val ACTION_USB_PERMISSION = "nl.eddydevink.sylda.USB_PERMISSION"
private const val STYLE_SPINNER = 0
private const val STYLE_HORIZONTAL = 1
private const val USB_READ_BUF_SIZE = 512
private const val PSK_HEX_LEN = 64
private const val UUID_HEX_LEN = 32
private const val UUID_BYTE_COUNT = 16
private const val PSK_BYTE_COUNT = 32
private const val VERSION_TIMEOUT_MS = 3000L
private const val REBOOT_WAIT_MS = 5000L
private const val REBOOT_POLL_MS = 500L
private const val REBOOT_MAX_WAIT_MS = 15000L
}
private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
private var currentAction = UsbAction.NONE
private var expectingReboot = false
private var progressDialog: AlertDialog? = null
private var dialog: AlertDialog? = null
private var flasherJob: Job? = null
private var otaManager: OtaManager? = null
private var otaJob: Job? = null
private var lastTheme: String? = null
private val rebootTimeoutHandler = Handler(Looper.getMainLooper())
private var rebootTimeoutRunnable: Runnable? = null
private val usbAttachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && currentAction != UsbAction.NONE) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
if (usbManager.hasPermission(device)) {
onDeviceReady(usbManager, device)
} else {
requestUsbPermission(usbManager, device)
}
}
}
}
}
private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_USB_PERMISSION && currentAction != UsbAction.NONE) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null) {
onDeviceReady(usbManager, device)
}
} else {
Toast.makeText(this@SettingsActivity, getString(R.string.usb_access_denied), Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
private val usbDetachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID) {
if (expectingReboot) return
if (dialog != null && currentAction == UsbAction.NONE) {
dialog?.dismiss()
dialog = null
} else if (currentAction != UsbAction.NONE) {
cleanupAction()
}
}
}
}
}
private fun getExpectedFirmwareVersion(): String {
return if (PrefsHelper.getDeviceMode(this) == "client") SylDaVersions.CLIENT else SylDaVersions.SERVER
}
override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
PrefsHelper.applyTheme(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = getString(R.string.title_settings)
val radioDeviceMode = findViewById<RadioGroup>(R.id.radioDeviceMode)
val radioTransport = findViewById<RadioGroup>(R.id.radioTransport)
radioDeviceMode.check(
if (PrefsHelper.getDeviceMode(this) == "client") R.id.radioClient else R.id.radioServer
)
radioTransport.check(
if (PrefsHelper.getTransportMode(this) == "usb") R.id.radioUsb else R.id.radioBle
)
radioDeviceMode.setOnCheckedChangeListener { _, checkedId ->
val mode = if (checkedId == R.id.radioClient) "client" else "server"
PrefsHelper.setDeviceMode(this, mode)
}
radioTransport.setOnCheckedChangeListener { _, checkedId ->
val mode = if (checkedId == R.id.radioUsb) "usb" else "ble"
PrefsHelper.setTransportMode(this, mode)
}
findViewById<Button>(R.id.updateModuleButton).setOnClickListener {
if (PrefsHelper.getDeviceMode(this) == "client" ||
findViewById<RadioGroup>(R.id.radioTransport).checkedRadioButtonId == R.id.radioUsb) {
startFlashing()
} else {
startOtaUpdate()
}
}
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED)
registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_EXPORTED)
registerReceiver(usbDetachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED), Context.RECEIVER_EXPORTED)
setupThemeButtons()
setupLabelEditTexts()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && currentAction == UsbAction.NONE) {
finish()
}
}
override fun onResume() {
super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
if (current != lastTheme) {
lastTheme = current
recreate()
}
}
private fun setupThemeButtons() {
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
val group = findViewById<RadioGroup>(R.id.themeGroup)
group.check(when (current) {
"light" -> R.id.themeLight
"dark" -> R.id.themeDark
else -> R.id.themeSystem
})
data class ThemeOpt(val id: Int, val key: String, val label: String)
val options = listOf(
ThemeOpt(R.id.themeSystem, "system", getString(R.string.theme_system)),
ThemeOpt(R.id.themeLight, "light", getString(R.string.theme_light)),
ThemeOpt(R.id.themeDark, "dark", getString(R.string.theme_dark))
)
options.forEach { (id, key, label) ->
val rb = findViewById<android.widget.RadioButton>(id)
val isActive = key == current
rb.contentDescription = if (isActive)
getString(R.string.theme_applied, label)
else
label
rb.setOnClickListener {
selectTheme(key)
}
}
}
private fun selectTheme(theme: String) {
PrefsHelper.getPrefs(this).edit().putString(PrefsHelper.PREF_THEME, theme).apply()
Handler(Looper.getMainLooper()).post {
PrefsHelper.applyTheme(this)
}
}
private fun startOtaUpdate() {
val manager = BleRelayManager.instance
if (manager == null) {
Toast.makeText(this, "Niet verbonden met SylDa_Server", Toast.LENGTH_SHORT).show()
return
}
if (!manager.hasOtaCharacteristic()) {
Toast.makeText(this, getString(R.string.ota_needs_usb_first), Toast.LENGTH_LONG).show()
return
}
val espVersion = manager.getEspVersion()
if (espVersion != null && espVersion == getExpectedFirmwareVersion()) {
Toast.makeText(this, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
return
}
showOtaProgressDialog()
otaManager = OtaManager(this, manager)
val mode = PrefsHelper.getDeviceMode(this)
val fwPath = "firmware/${if (mode == "client") "client" else "server"}/firmware.bin"
if (otaManager?.loadFirmware(fwPath) != true) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this, getString(R.string.ota_load_failed), Toast.LENGTH_SHORT).show()
return
}
manager.enableOtaNotifications()
otaJob = lifecycleScope.launch {
delay(300)
val result = otaManager!!.performOta(object : OtaManager.OtaCallback {
override fun onProgress(percent: Int, message: String) {
lifecycleScope.launch(Dispatchers.Main) {
val d = progressDialog ?: return@launch
val pb = d.findViewById<ProgressBar>(R.id.progressBar)
val msg = d.findViewById<TextView>(R.id.message)
pb?.progress = percent
msg?.text = message
}
}
override fun onComplete() {
lifecycleScope.launch(Dispatchers.Main) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_complete), Toast.LENGTH_LONG).show()
}
}
override fun onError(message: String) {
lifecycleScope.launch(Dispatchers.Main) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_failed, message), Toast.LENGTH_LONG).show()
}
}
})
}
}
private fun showOtaProgressDialog() {
showProgressDialog(
getString(R.string.title_ota_update),
getString(R.string.msg_ota_starting),
STYLE_HORIZONTAL
)
}
override fun onDestroy() {
super.onDestroy()
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
currentAction = UsbAction.NONE
flasherJob?.cancel()
flasherJob = null
otaJob?.cancel()
otaJob = null
try { unregisterReceiver(usbAttachedReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbPermissionReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbDetachedReceiver) } catch (_: Exception) {}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
private fun setupLabelEditTexts() {
val prefs = PrefsHelper.getPrefs(this)
val label1 = findViewById<EditText>(R.id.labelRelay1)
val label2 = findViewById<EditText>(R.id.labelRelay2)
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
label1.setText(prefs.getString(PrefsHelper.PREF_LABEL1, ""))
label2.setText(prefs.getString(PrefsHelper.PREF_LABEL2, ""))
label1.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
prefs.edit().putString(PrefsHelper.PREF_LABEL1, label1.text.toString().trim()).apply()
imm.hideSoftInputFromWindow(label1.windowToken, 0)
label1.clearFocus()
}
false
}
label2.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
prefs.edit().putString(PrefsHelper.PREF_LABEL2, label2.text.toString().trim()).apply()
imm.hideSoftInputFromWindow(label2.windowToken, 0)
label2.clearFocus()
}
false
}
}
// === Key Exchange ===
private fun startKeyExchange() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.EXCHANGE_KEY
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doKeyExchange(usbManager, device)
} else {
showWaitingDialog(getString(R.string.title_key_exchange), getString(R.string.msg_connect_usb))
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER)
val prefs = PrefsHelper.getPrefs(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
val appKey = prefs.getString(PrefsHelper.PREF_KEY, null)
lifecycleScope.launch(Dispatchers.IO) {
val result = readKeyFromEsp(usbManager, device, appUuid, appKey)
withContext(Dispatchers.Main) {
if (isFinishing || isDestroyed) return@withContext
progressDialog?.dismiss()
progressDialog = null
when (result) {
is KeyResult.Success -> {
prefs.edit()
.putString(PrefsHelper.PREF_UUID, result.deviceUuid)
.putString(PrefsHelper.PREF_KEY, result.hexKey)
.apply()
if (PrefsHelper.getDeviceMode(this@SettingsActivity) == "server") {
PrefsHelper.setServerUuid(this@SettingsActivity, result.deviceUuid)
} else {
PrefsHelper.setClientUuid(this@SettingsActivity, result.deviceUuid)
}
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Conflict -> {
showConflictDialog(usbManager, device, result.espUuid, result.espKey, appKey ?: "")
}
is KeyResult.Error -> {
val msg = if (result.arg != null) getString(result.resId, result.arg!!) else getString(result.resId)
Toast.makeText(this@SettingsActivity, msg, Toast.LENGTH_LONG).show()
cleanupAction()
}
null -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
}
// === Firmware Flashing ===
private fun startFlashing() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.FLASH
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doFlashing(usbManager, device)
} else {
showWaitingDialog(
getString(R.string.title_firmware_flash),
getString(R.string.msg_flash_download_mode)
)
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doFlashing(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog(getString(R.string.title_firmware_flash), getString(R.string.msg_checking_version), STYLE_SPINNER)
lifecycleScope.launch {
val expected = getExpectedFirmwareVersion()
Log.d(TAG, "doFlashing: expected firmware version=$expected, expectingReboot=$expectingReboot")
val version = readEspVersion(usbManager, device)
if (version == getExpectedFirmwareVersion() && !expectingReboot) {
Log.d(TAG, "Firmware version $version matches, up to date")
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
currentAction = UsbAction.EXCHANGE_KEY
doKeyExchange(usbManager, device)
return@launch
}
Log.d(TAG, "Firmware version ${version ?: "none"}, flashing")
progressDialog?.dismiss()
progressDialog = null
doFlashInner(usbManager, device)
}
}
private fun doFlashInner(usbManager: UsbManager, device: UsbDevice) {
dialog?.dismiss()
dialog = null
showProgressDialog(getString(R.string.title_firmware_flash), getString(R.string.msg_initializing), STYLE_HORIZONTAL)
val flasher = EspFlasher(this)
val progressCallback = object : EspFlasher.FlashCallback {
override fun onProgress(percent: Int, message: String) {
lifecycleScope.launch(Dispatchers.Main) {
val d = progressDialog ?: return@launch
d.findViewById<ProgressBar>(R.id.progressBar)?.progress = percent
d.findViewById<TextView>(R.id.message)?.text = message
}
}
}
flasherJob = lifecycleScope.launch {
try {
flasher.flashDevice(usbManager, device, progressCallback)
if (isFinishing || isDestroyed) return@launch
expectingReboot = true
currentAction = UsbAction.EXCHANGE_KEY
progressDialog?.dismiss()
progressDialog = null
showWaitingDialog(
getString(R.string.title_firmware_flash),
getString(R.string.msg_env_waiting_reboot)
)
rebootTimeoutRunnable = Runnable { cleanupAction() }
rebootTimeoutHandler.postDelayed(rebootTimeoutRunnable!!, REBOOT_MAX_WAIT_MS)
} catch (e: kotlinx.coroutines.CancellationException) {
cleanupAction()
throw e
} catch (e: Exception) {
Log.e(TAG, "Flash inner error", e)
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.flash_failed, e.message), Toast.LENGTH_LONG).show()
cleanupAction()
}
}
}
// === USB dispatch ===
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
when (currentAction) {
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
UsbAction.FLASH -> doFlashing(usbManager, device)
UsbAction.NONE -> {}
}
}
// === Dialog helpers ===
private fun showWaitingDialog(title: String, message: String) {
dialog?.dismiss()
val view = layoutInflater.inflate(R.layout.dialog_waiting, null)
view.findViewById<TextView>(R.id.message).text = message
dialog = AlertDialog.Builder(this)
.setTitle(title)
.setView(view)
.setCancelable(true)
.setNegativeButton(getString(R.string.button_cancel)) { _, _ ->
cleanupAction()
}
.setOnCancelListener { cleanupAction() }
.show()
}
private fun showProgressDialog(title: String, message: String, style: Int) {
progressDialog?.dismiss()
val view = layoutInflater.inflate(R.layout.dialog_progress, null)
view.findViewById<TextView>(R.id.message).text = message
val progressBar = view.findViewById<ProgressBar>(R.id.progressBar)
if (style == STYLE_HORIZONTAL) {
progressBar.isIndeterminate = false
} else {
progressBar.isIndeterminate = true
}
progressDialog = AlertDialog.Builder(this)
.setTitle(title)
.setView(view)
.setNegativeButton(getString(R.string.button_cancel)) { _, _ ->
flasherJob?.cancel()
cleanupAction()
}
.setCancelable(false)
.show()
}
private fun cleanupAction() {
currentAction = UsbAction.NONE
expectingReboot = false
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
dialog?.dismiss()
dialog = null
progressDialog?.dismiss()
progressDialog = null
}
private fun requestUsbPermission(usbManager: UsbManager, device: UsbDevice) {
val flags = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val permIntent = PendingIntent.getBroadcast(
this, 0,
Intent(ACTION_USB_PERMISSION).setPackage(packageName),
flags
)
usbManager.requestPermission(device, permIntent)
}
// === ESP Key Exchange via USB ===
sealed class KeyResult {
data class Success(val deviceUuid: String, val hexKey: String) : KeyResult()
data class Conflict(val espUuid: String, val espKey: String) : KeyResult()
data class Error(val resId: Int, val arg: String? = null) : KeyResult()
}
private fun readEspVersion(usbManager: UsbManager, device: UsbDevice): String? {
Log.d(TAG, "readEspVersion: device=${device.deviceName}, id=${device.deviceId}, interfaces=${device.interfaceCount}")
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)
Log.d(TAG, "readEspVersion: intf[$i]=class:0x${intf.interfaceClass.toString(16)} subclass:0x${intf.interfaceSubclass.toString(16)} proto:0x${intf.interfaceProtocol.toString(16)} eps=${intf.endpointCount}")
}
val intf = UsbUtils.findCdcDataInterface(device)
if (intf == null) {
Log.w(TAG, "readEspVersion: no CDC data interface (class 0x0A) found")
return null
}
Log.d(TAG, "readEspVersion: found CDC data iface=${intf.id}")
val connection = usbManager.openDevice(device) ?: return null
if (!connection.claimInterface(intf, true)) { connection.close(); return null }
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return null }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return null }
return try {
setupCdcControl(connection, device)
Thread.sleep(KEY_SETTLE_MS)
flushReadBuffer(connection, epIn)
val cmd = "VERSION?\n".toByteArray(StandardCharsets.US_ASCII)
val writeResult = connection.bulkTransfer(epOut, cmd, cmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "readEspVersion: bulkTransfer OUT result=$writeResult (expected ${cmd.size})")
Thread.sleep(RESPONSE_WAIT_MS)
val response = readResponse(connection, epIn, VERSION_TIMEOUT_MS)
Log.d(TAG, "readEspVersion: raw response='${response.replace("\r", "\\r").replace("\n", "\\n")}' (len=${response.length})")
val match = Regex("BUILD_NR:(\\d+)").find(response)
Log.d(TAG, "readEspVersion: regex match=$match, group1=${match?.groupValues?.get(1)}")
match?.groupValues?.get(1)
} finally {
connection.close()
}
}
private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice, appUuid: String?, appKey: String?): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error(R.string.err_no_cdc)
val connection = usbManager.openDevice(device)
?: return KeyResult.Error(R.string.err_usb_open)
if (!connection.claimInterface(intf, true)) {
connection.close()
return KeyResult.Error(R.string.err_usb_claim)
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_out) }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_in) }
try {
setupCdcControl(connection, device)
for (attempt in 1..MAX_KEY_RETRIES) {
Thread.sleep(KEY_SETTLE_MS)
flushReadBuffer(connection, epIn)
val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
val written = connection.bulkTransfer(epOut, cmd, cmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "Attempt $attempt: Sent KEY? ($written bytes)")
if (written < 0) {
Log.w(TAG, "Write failed on attempt $attempt")
continue
}
Thread.sleep(RESPONSE_WAIT_MS)
val response = readResponse(connection, epIn, KEY_TIMEOUT_MS)
Log.d(TAG, "Attempt $attempt: got ${response.lines().size} line(s)")
val keyLine = response.lines().firstOrNull { it.startsWith("PSK:") }
val uuidLine = response.lines().firstOrNull { it.startsWith("DEVICE_ID:") }
val noKey = response.lines().any { it.trim() == "NO_KEY" }
if (keyLine != null && uuidLine != null) {
val hexKey = keyLine.removePrefix("PSK:").trim()
val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim()
if (hexKey.length == PSK_HEX_LEN && hexKey.all { it in "0123456789ABCDEFabcdef" } &&
hexUuid.length == UUID_HEX_LEN && hexUuid.all { it in "0123456789ABCDEFabcdef" }) {
val espKeyUpper = hexKey.uppercase()
val espUuidUpper = hexUuid.uppercase()
if (appUuid != espUuidUpper || appKey != espKeyUpper) {
return KeyResult.Conflict(espUuidUpper, espKeyUpper)
}
return KeyResult.Success(espUuidUpper, espKeyUpper)
} else {
return KeyResult.Error(R.string.err_invalid_key)
}
} else if (noKey) {
val newKey: String
val newUuid: String
if (appKey != null) {
Log.d(TAG, "ESP has no keys, using existing PSK")
newKey = appKey
newUuid = if (PrefsHelper.getDeviceMode(this) == "server") {
generateRandomHex(UUID_BYTE_COUNT)
} else {
generateRandomHex(UUID_BYTE_COUNT)
}
} else {
Log.d(TAG, "Both have no keys, generating new pair")
newKey = generateRandomHexKey()
newUuid = generateRandomHex(UUID_BYTE_COUNT)
}
val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "Sent SET_KEY ($w bytes)")
if (w < 0) {
return KeyResult.Error(R.string.err_set_key_write)
}
Thread.sleep(RESPONSE_WAIT_MS)
val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_KEY response: ${okKeyResponse.take(200)}")
if (!okKeyResponse.contains("OK")) {
return KeyResult.Error(R.string.err_key_not_ack)
}
val setIdCmd = "SET_ID:$newUuid\n".toByteArray(StandardCharsets.US_ASCII)
val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "Sent SET_ID ($w2 bytes)")
if (w2 < 0) {
return KeyResult.Error(R.string.err_set_id_write)
}
Thread.sleep(RESPONSE_WAIT_MS)
val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_ID response: ${okUuidResponse.take(200)}")
if (okUuidResponse.contains("OK")) {
if (PrefsHelper.getDeviceMode(this) == "client") {
val srvUuid = PrefsHelper.getServerUuid(this)
if (srvUuid != null) {
val setServerCmd = "SET_SERVER:$srvUuid\n".toByteArray(StandardCharsets.US_ASCII)
connection.bulkTransfer(epOut, setServerCmd, setServerCmd.size, USB_BULK_TIMEOUT_MS)
Thread.sleep(RESPONSE_WAIT_MS)
val okServerResp = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_SERVER response: ${okServerResp.take(200)}")
if (!okServerResp.contains("OK")) {
return KeyResult.Error(R.string.err_server_not_ack)
}
} else {
return KeyResult.Error(R.string.err_no_server_uuid)
}
}
return KeyResult.Success(newUuid, newKey)
} else {
return KeyResult.Error(R.string.err_uuid_not_ack)
}
}
Log.w(TAG, "Attempt $attempt: no KEY response, retrying")
}
return KeyResult.Error(R.string.err_key_retries)
} catch (e: Exception) {
Log.e(TAG, "Key exchange error", e)
return KeyResult.Error(R.string.err_generic, e.message)
} finally {
try { connection.close() } catch (_: Exception) {}
}
}
private fun flushReadBuffer(connection: UsbDeviceConnection, epIn: UsbEndpoint) {
val buf = ByteArray(USB_READ_BUF_SIZE)
while (true) {
val len = connection.bulkTransfer(epIn, buf, buf.size, FLUSH_TIMEOUT_MS)
if (len <= 0) break
Log.d(TAG, "Flushed $len bytes from read buffer")
}
}
private fun readResponse(connection: UsbDeviceConnection, epIn: UsbEndpoint, timeoutMs: Long): String {
val buf = ByteArray(USB_READ_BUF_SIZE)
val sb = StringBuilder()
val startTime = System.currentTimeMillis()
var reads = 0
while (System.currentTimeMillis() - startTime < timeoutMs) {
val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS)
if (len > 0) {
reads++
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
Log.d(TAG, "readResponse: chunk len=$len, sb now='${sb.toString().replace("\r","\\r").replace("\n","\\n")}'")
if ((sb.contains("PSK:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) {
break
}
}
}
Log.d(TAG, "readResponse: done after ${System.currentTimeMillis()-startTime}ms, ${reads} reads, response='${sb.toString().replace("\r","\\r").replace("\n","\\n")}'")
return sb.toString()
}
private fun setupCdcControl(connection: UsbDeviceConnection, device: UsbDevice) {
val ctrlIntf = (0 until device.interfaceCount)
.map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == 0x02 }
if (ctrlIntf != null && ctrlIntf.endpointCount > 0) {
val lineCoding = byteArrayOf(
0x00.toByte(), 0xC2.toByte(), 0x01.toByte(), 0x00.toByte(),
0x00.toByte(), 0x00.toByte(), 0x08.toByte()
)
connection.controlTransfer(
UsbUtils.CDC_REQUEST_TYPE,
UsbUtils.CDC_SET_LINE_CODING,
0,
ctrlIntf.id,
lineCoding,
lineCoding.size,
CDC_CONTROL_TIMEOUT_MS
)
connection.controlTransfer(
UsbUtils.CDC_REQUEST_TYPE,
UsbUtils.CDC_SET_CONTROL_LINE_STATE,
UsbUtils.CDC_DTR or UsbUtils.CDC_RTS,
ctrlIntf.id,
null,
0,
CDC_CONTROL_TIMEOUT_MS
)
Log.d(TAG, "CDC control interface configured")
} else {
Log.w(TAG, "No CDC control interface found, trying without")
}
}
private fun generateRandomHexKey(): String {
val random = SecureRandom()
val bytes = ByteArray(PSK_BYTE_COUNT)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
private fun generateRandomHex(byteCount: Int): String {
val random = SecureRandom()
val bytes = ByteArray(byteCount)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice,
espUuid: String, espKey: String, appKey: String) {
val prefs = PrefsHelper.getPrefs(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
val newAppKey = appKey.ifEmpty { generateRandomHexKey() }
val newAppUuid = appUuid ?: generateRandomHex(UUID_BYTE_COUNT)
AlertDialog.Builder(this)
.setTitle(getString(R.string.title_key_conflict))
.setMessage(getString(R.string.msg_pair_device))
.setPositiveButton(getString(R.string.button_pair)) { _, _ ->
doOverwriteEspKeys(usbManager, device, newAppKey, newAppUuid)
}
.setNegativeButton(getString(R.string.button_cancel)) { _, _ ->
cleanupAction()
}
.setCancelable(false)
.show()
}
private fun doOverwriteEspKeys(usbManager: UsbManager, device: UsbDevice,
appKey: String, appUuid: String) {
showProgressDialog(getString(R.string.title_overwriting_keys), getString(R.string.msg_writing_keys), STYLE_SPINNER)
lifecycleScope.launch(Dispatchers.IO) {
val result = writeKeysToEsp(usbManager, device, appKey, appUuid)
withContext(Dispatchers.Main) {
if (isFinishing || isDestroyed) return@withContext
progressDialog?.dismiss()
progressDialog = null
when (result) {
is KeyResult.Success -> {
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Error -> {
val msg = if (result.arg != null) getString(result.resId, result.arg!!) else getString(result.resId)
Toast.makeText(this@SettingsActivity, msg, Toast.LENGTH_LONG).show()
cleanupAction()
}
else -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
}
private fun writeKeysToEsp(usbManager: UsbManager, device: UsbDevice,
appKey: String, appUuid: String): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error(R.string.err_no_cdc)
val connection = usbManager.openDevice(device)
?: return KeyResult.Error(R.string.err_usb_open)
if (!connection.claimInterface(intf, true)) {
connection.close()
return KeyResult.Error(R.string.err_usb_claim)
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_out) }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_in) }
try {
setupCdcControl(connection, device)
Thread.sleep(KEY_SETTLE_MS)
flushReadBuffer(connection, epIn)
val newUuid = if (PrefsHelper.getDeviceMode(this) == "client") {
generateRandomHex(UUID_BYTE_COUNT)
} else {
appUuid
}
val setKeyCmd = "SET_KEY:$appKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS)
if (w < 0) return KeyResult.Error(R.string.err_set_key_write)
Log.d(TAG, "Sent SET_KEY ($w bytes)")
Thread.sleep(RESPONSE_WAIT_MS)
val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
if (!okKeyResponse.contains("OK")) return KeyResult.Error(R.string.err_key_not_ack)
val setIdCmd = "SET_ID:$newUuid\n".toByteArray(StandardCharsets.US_ASCII)
val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS)
if (w2 < 0) return KeyResult.Error(R.string.err_set_id_write)
Thread.sleep(RESPONSE_WAIT_MS)
val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
if (!okUuidResponse.contains("OK")) return KeyResult.Error(R.string.err_uuid_not_ack)
if (PrefsHelper.getDeviceMode(this) == "client") {
val srvUuid = PrefsHelper.getServerUuid(this)
if (srvUuid != null) {
val setServerCmd = "SET_SERVER:$srvUuid\n".toByteArray(StandardCharsets.US_ASCII)
connection.bulkTransfer(epOut, setServerCmd, setServerCmd.size, USB_BULK_TIMEOUT_MS)
Thread.sleep(RESPONSE_WAIT_MS)
val okServerResp = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_SERVER response: ${okServerResp.take(200)}")
if (!okServerResp.contains("OK")) return KeyResult.Error(R.string.err_server_not_ack)
} else {
return KeyResult.Error(R.string.err_no_server_uuid)
}
}
val prefs = PrefsHelper.getPrefs(this)
prefs.edit()
.putString(PrefsHelper.PREF_UUID, newUuid)
.putString(PrefsHelper.PREF_KEY, appKey)
.apply()
if (PrefsHelper.getDeviceMode(this) == "server") {
PrefsHelper.setServerUuid(this, newUuid)
} else {
PrefsHelper.setClientUuid(this, newUuid)
}
return KeyResult.Success(newUuid, appKey)
} catch (e: Exception) {
Log.e(TAG, "Write keys error", e)
return KeyResult.Error(R.string.err_generic, e.message)
} finally {
try { connection.close() } catch (_: Exception) {}
}
}
}

View file

@ -4,67 +4,30 @@
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:id="@+id/settingsContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageButton
android:id="@+id/settingsButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="end|top"
android:src="@drawable/ic_settings"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Instellingen" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_label"
android:textSize="14sp"
android:layout_marginEnd="4dp" />
<ImageButton
android:id="@+id/settingsButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_settings"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cd_settings" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<Button
android:id="@+id/relayButton"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center"
android:orientation="horizontal"
android:gravity="center">
<Button
android:id="@+id/relay1Button"
android:layout_width="150dp"
android:layout_height="200dp"
android:text="1"
android:textSize="24sp"
android:textAllCaps="false"
android:enabled="false" />
<Button
android:id="@+id/relay2Button"
android:layout_width="150dp"
android:layout_height="200dp"
android:layout_marginStart="16dp"
android:text="2"
android:textSize="24sp"
android:textAllCaps="false"
android:enabled="false" />
</LinearLayout>
android:text="HOLD"
android:textSize="24sp"
android:enabled="false" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:text="@string/status_default"
android:text="Disconnected"
android:textSize="14sp" />
</FrameLayout>

View file

@ -5,164 +5,37 @@
android:orientation="vertical"
android:padding="24dp">
<RadioGroup
android:id="@+id/radioDeviceMode"
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="12dp"
android:checkedButton="@+id/radioServer">
<RadioButton
android:id="@+id/radioServer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Server" />
<RadioButton
android:id="@+id/radioClient"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Client" />
</RadioGroup>
<RadioGroup
android:id="@+id/radioTransport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp"
android:checkedButton="@+id/radioBle">
<RadioButton
android:id="@+id/radioBle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="BLE" />
<RadioButton
android:id="@+id/radioUsb"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="USB" />
</RadioGroup>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
android:layout_marginTop="12dp"
android:layout_marginBottom="8dp" />
android:text="Sluit de ESP aan op USB en kies een actie."
android:textSize="16sp"
android:layout_marginBottom="24dp" />
<Button
android:id="@+id/updateModuleButton"
android:id="@+id/exchangeKeyButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/button_update_module" />
android:text="Sleutel Uitwisselen" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/section_labels"
android:text="Zet de ESP in download-modus (houd reset 2s in tot de groene LED aangaat) en tik op 'Firmware Flashen'."
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<EditText
android:id="@+id/labelRelay1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/hint_label_relay1"
android:maxLines="1"
android:inputType="text"
android:imeOptions="actionDone"
android:gravity="center" />
<EditText
android:id="@+id/labelRelay2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/hint_label_relay2"
android:maxLines="1"
android:inputType="text"
android:imeOptions="actionDone"
android:gravity="center" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
android:layout_marginBottom="16dp" />
<TextView
<Button
android:id="@+id/flashFirmwareButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/section_theme"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="3">
<RadioGroup
android:id="@+id/themeGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton
android:id="@+id/themeSystem"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/theme_system" />
<RadioButton
android:id="@+id/themeLight"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/theme_light" />
<RadioButton
android:id="@+id/themeDark"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/theme_dark" />
</RadioGroup>
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
android:text="Firmware Flashen" />
</LinearLayout>

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100"
android:progress="0" />
</LinearLayout>

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:padding="20dp"
android:gravity="center_vertical">
<ProgressBar
android:id="@+id/spinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp" />
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp" />
</LinearLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#4DB6AC</color>
<color name="colorPrimaryDark">#00897B</color>
<color name="colorAccent">#80CBC4</color>
</resources>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#00695C</color>
<color name="colorPrimaryDark">#004D40</color>
<color name="colorAccent">#009688</color>
</resources>

View file

@ -1,109 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Sylda</string>
<string name="app_name">Atom Relay</string>
<string name="settings">Instellingen</string>
<!-- MainActivity status -->
<string name="status_default">Niet verbonden</string>
<string name="status_discovering_services">Verbonden, services zoeken…</string>
<string name="status_connecting">Verbinden…</string>
<string name="status_connected">Verbonden</string>
<string name="status_service_error">Servicefout</string>
<string name="status_service_not_found">Service niet gevonden</string>
<string name="status_comm_error">Communicatiefout</string>
<string name="status_no_key">Geen sleutel — open Instellingen</string>
<string name="status_permissions_denied">Tik hier om Bluetooth-toegang in te stellen</string>
<string name="status_bluetooth_disabled">Bluetooth uitgeschakeld</string>
<string name="status_wrong_device">Verkeerd apparaat — opnieuw zoeken…</string>
<string name="status_scanning">Zoeken…</string>
<string name="status_device_not_found">Apparaat niet gevonden</string>
<string name="button_retry_scan">Opnieuw zoeken</string>
<!-- MainActivity UI -->
<string name="cd_settings">Instellingen</string>
<string name="settings_label">instellingen</string>
<string name="button_on">AAN</string>
<string name="button_hold">VAST</string>
<!-- SettingsActivity -->
<string name="title_settings">Instellingen</string>
<string name="button_update_module">Module bijwerken</string>
<string name="usb_access_denied">USB-toegang geweigerd</string>
<string name="key_read_success">Beveiligingssleutel gelezen</string>
<string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</string>
<string name="flash_failed">Flashen mislukt: %s</string>
<!-- Key conflict dialog -->
<string name="title_key_conflict">Onbekende module</string>
<string name="msg_pair_device">Deze module koppelen aan deze telefoon?</string>
<string name="button_pair">Koppelen</string>
<string name="title_overwriting_keys">Sleutels overschrijven</string>
<string name="msg_writing_keys">Sleutels schrijven naar ESP…</string>
<!-- SettingsActivity dialogs -->
<string name="title_key_exchange">Sleutel Uitwisselen</string>
<string name="title_firmware_flash">Firmware Flashen</string>
<string name="msg_connect_usb">Sluit de ESP aan via USB.</string>
<string name="msg_reading_key">Sleutel uitlezen…</string>
<string name="msg_flash_download_mode">Sluit de ESP aan via USB om te flashen.</string>
<string name="msg_initializing">Initialiseren…</string>
<string name="msg_checking_version">Firmware versie controleren…</string>
<string name="msg_env_waiting_reboot">Wachten op ESP herstart…</string>
<string name="msg_flash_complete">Firmware geflashed!\n\nSleutels worden nu uitgelezen…</string>
<string name="button_close">Sluiten</string>
<string name="button_cancel">Annuleren</string>
<!-- SettingsActivity error messages -->
<string name="err_no_cdc">Geen CDC interface gevonden</string>
<string name="err_usb_open">Kan USB apparaat niet openen</string>
<string name="err_usb_claim">Kan USB interface niet claimen</string>
<string name="err_no_out">Geen USB OUT endpoint gevonden</string>
<string name="err_no_in">Geen USB IN endpoint gevonden</string>
<string name="err_invalid_key">Ongeldig sleutel/UUID formaat</string>
<string name="err_key_retries">Geen sleutel gevonden na 5 pogingen</string>
<string name="err_generic">Fout: %s</string>
<string name="err_set_key_write">SET_KEY schrijven mislukt</string>
<string name="err_set_id_write">SET_ID schrijven mislukt</string>
<string name="err_key_not_ack">ESP bevestigde sleutel niet</string>
<string name="err_uuid_not_ack">ESP bevestigde UUID niet</string>
<string name="err_server_not_ack">ESP bevestigde server UUID niet</string>
<string name="err_no_server_uuid">Server UUID niet beschikbaar — provisioneer eerst de server</string>
<!-- EspFlasher -->
<string name="flash_connecting">Verbinden met ESP bootloader…</string>
<string name="flash_step_bootloader">Bootloader</string>
<string name="flash_step_partitions">Partitie tabel</string>
<string name="flash_step_boot_app0">Boot app0</string>
<string name="flash_step_firmware">Firmware</string>
<string name="flash_sync">Sync met bootloader…</string>
<string name="flash_detect">Chip detecteren…</string>
<string name="flash_chip">Chip: %s</string>
<string name="flash_stub">Stub laden…</string>
<string name="flash_done">Firmware geflashed!</string>
<string name="flash_writing">%s schrijven (%s)…</string>
<string name="flash_written">%s geschreven</string>
<string name="flash_resetting">ESP resetten…</string>
<!-- Label customization -->
<string name="section_labels">Knoptekst</string>
<string name="hint_label_relay1">Relay 1</string>
<string name="hint_label_relay2">Relay 2</string>
<!-- Theme -->
<string name="section_theme">Weergave</string>
<string name="theme_system">Systeem</string>
<string name="theme_light">Licht</string>
<string name="theme_dark">Donker</string>
<string name="theme_applied">Thema: %s</string>
<!-- OTA Updates -->
<string name="title_ota_update">Module bijwerken</string>
<string name="msg_ota_starting">OTA update starten...</string>
<string name="ota_update_available">Nieuwe firmware beschikbaar</string>
<string name="ota_up_to_date">Firmware is up‑to‑date</string>
<string name="ota_complete">Update voltooid! ESP herstart...</string>
<string name="ota_failed">OTA update mislukt: %s</string>
<string name="ota_load_failed">Kan firmware bestand niet laden</string>
<string name="ota_needs_usb_first">Module heeft nog geen OTA-ondersteuning — eerst flashen via USB</string>
<string name="hmac_key_hint">C3F17B61...</string>
<string name="save">Opslaan</string>
<string name="back">Terug</string>
</resources>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<usb-device vendor-id="12346" product-id="4097" />
</resources>

View file

@ -1,4 +1,4 @@
plugins {
id 'com.android.application' version '8.5.2' apply false
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
}

View file

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View file

@ -1,63 +0,0 @@
# Plan: Issue #2 — AES-256-GCM + ECDH + PSK voor BLE relay commands
## Beveiligingsmodel
### Provisioning (eenmalig, USB CDC)
Android genereert 32B PSK (pre-shared key) en provisioned beide ESP's:
- `SET_KEY` → Server ESP (NVS `syl/psk`)
- `SET_KEY` → Client ESP (NVS `syl/psk`)
### BLE ECDH handshake (elke sessie)
```
Client Server
|── pubkey(65B) || HMAC(PSK,pubkey)(32B) ─────>| verify HMAC met PSK
| | genereer eigen keypair
|<─ pubkey(65B) || HMAC(PSK,pubkey)(32B) ──────| verify HMAC met PSK
| |
AES_KEY = SHA-256(ECDH_shared_secret) AES_KEY = SHA-256(ECDH_shared_secret)
```
- PSK verlaat nooit het apparaat over BLE — alleen HMAC ervan
- Zonder PSK kan MITM geen geldige HMAC voor fake pubkey produceren
- Per-sessie AES keys → perfect forward secrecy
- PSK gelekt? → her-provisionen via USB CDC
## Packet structuren
### ECDH handshake packet (97 bytes)
```
pubkey[65] || HMAC-SHA256(PSK, pubkey)[32]
```
BLE characteristic: `beb5483e-...-f26dc`, properties `WRITE_NR | NOTIFY`
### Relay command (29 bytes, AES-256-GCM)
```
seq(4) || nonce(8) || ciphertext_cmd(1) || tag(16)
```
Nonce voor GCM = `seq(4) || random(8)` = 12 bytes.
Sequence > lastSeq voor replay protection per sessie.
## NVS / SharedPreferences
| Opslag | Sleutel | Waarde |
|---|---|---|
| ESP NVS `syl` | `psk` | 32B PSK |
| ESP NVS `syl` | `uuid` | 16B device UUID |
| Android Prefs | `psk` | 64-char hex PSK |
| Android Prefs | `device_uuid` | 32-char hex UUID |
## Bestandswijzigingen
### Nieuw
- `src/ecdh_helper.h` — ECDH keypair generatie + shared secret (mbedtls/ecdh.h)
### Gewijzigd
- `src/main.cpp` — HMAC → ECDH handshake + AES-GCM decrypt; PSK uit NVS
- `src/client.cpp` — HMAC → ECDH handshake + AES-GCM encrypt; PSK uit NVS
- `android/.../BleRelayManager.kt` — HMAC → ECDH (KeyAgreement) + AES-GCM (Cipher)
- `android/.../SettingsActivity.kt` — SET_KEY schrijft voortaan PSK
- `android/.../PrefsHelper.kt` — `hmac_key` → `psk`
- `version.txt` — bump server + client versies
## Werkt NIET met oude firmware/app
Geen backward compatibility: schone break. HMAC packets worden niet meer geaccepteerd.

View file

@ -1,192 +0,0 @@
# ESP32-PICO-D4 (M5Stack Atom) Port Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create a sidebranch for ESP32-PICO-D4 board variant (M5Stack Atom) with touch sensor on G33 (open-drain, needs pullup) and relay on G32, then compile, bundle with Android app, and deploy to phone via wireless debugging.
**Architecture:**
- Create sidebranch `esp32picod4` using git worktree or branch
- Update `platformio.ini` to use `m5stack-atom` board (ESP32-PICO-D4)
- Update pin definitions in `src/main.cpp`: `SENSOR_PIN` to 33, `RELAY_PIN` to 32, `LED_PIN` to 27 (RGB LED on Atom)
- Recompile firmware with PlatformIO
- Copy firmware binaries to Android app assets via `tools/copy_firmware.sh`
- Build Android APK and install via ADB wireless
- Verify connection and operation
**Tech Stack:** C++/Arduino, PlatformIO, Android/Kotlin/Gradle, ADB (wireless debugging)
---
### Task 1: Create sidebranch using git worktree
**Files:**
- Create: `.worktrees/esp32picod4` (git worktree directory)
- [ ] **Step 1: Create and switch to sidebranch in worktree**
```bash
git worktree add -b esp32picod4 .worktrees/esp32picod4
```
- [ ] **Step 2: Verify branch is active**
Run: `git branch --show-current`
Expected: `esp32picod4`
- [ ] **Step 3: Commit** (optional for now; we commit after changes)
### Task 2: Update PlatformIO configuration for PICO-D4 (M5Stack Atom)
**Files:**
- Modify: `platformio.ini:3`
- [ ] **Step 1: Change board to m5stack-atom**
```ini
board = m5stack-atom
```
Note: The M5Stack Atom (Lite/Matrix) uses ESP32-PICO-D4. The board ID in PlatformIO is `m5stack-atom`.
- [ ] **Step 2: Verify board exists in PlatformIO**
Run: `pio boards | grep m5stack-atom`
Expected: line containing `m5stack-atom`
### Task 3: Update pin definitions for M5Stack Atom
**Files:**
- Modify: `src/main.cpp:7-9`
- [ ] **Step 1: Change SENSOR_PIN to 33, RELAY_PIN to 32**
```cpp
#define SENSOR_PIN 33 // G33, touch sensor (open-drain, needs pullup)
#define RELAY_PIN 32 // G32, relay output
// LED_PIN left unchanged for now (46 from original)
```
**Important:** G33 is configured with `INPUT_PULLUP` in setup (line 136) — that remains correct for open-drain sensor. No change needed there.
### Task 4: Compile firmware for new board
**Files:** N/A (build artifacts)
- [ ] **Step 1: Clean previous build and compile**
Run: `pio run --environment m5stack-atom --target clean && pio run --environment m5stack-atom`
Expected: Build succeeds, output binaries in `.pio/build/m5stack-atom/`
- [ ] **Step 2: Verify build output files exist**
Run: `ls .pio/build/m5stack-atom/firmware*.bin .pio/build/m5stack-atom/bootloader*.bin .pio/build/m5stack-atom/partitions*.bin .pio/build/m5stack-atom/boot_app0*.bin`
Expected: 4 files: `firmware.bin`, `bootloader.bin`, `partitions.bin`, `boot_app0.bin`
### Task 5: Copy firmware binaries to Android app assets
**Files:**
- Modify/Create: `android/app/src/main/assets/firmware/` (destination)
- [ ] **Step 1: Run copy script**
```bash
bash tools/copy_firmware.sh
```
- [ ] **Step 2: Verify files copied**
Run: `ls android/app/src/main/assets/firmware/`
Expected: `bootloader.bin`, `partitions.bin`, `firmware.bin`, `boot_app0.bin`
- [ ] **Step 3: Commit changes**
```bash
git add platformio.ini src/main.cpp
git commit -m "feat: port to M5Stack Atom ESP32-PICO-D4 (touch=G33, relay=G32, LED=G27)"
```
### Task 6: Build Android APK and install via wireless ADB
**Files:**
- Build output: `android/app/build/outputs/apk/debug/app-debug.apk`
- [ ] **Step 1: Build the Android app**
```bash
cd android
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew assembleDebug
```
Expected: APK built at `app/build/outputs/apk/debug/app-debug.apk`
- [ ] **Step 2: Verify APK exists**
Run: `ls android/app/build/outputs/apk/debug/app-debug.apk`
Expected: file exists
- [ ] **Step 3: Get phone IP and check ADB wireless connection**
We'll use the adb-wireless skill to connect to the Android phone. First check if already connected:
Run: `adb devices`
Expected: If device listed as `<ip>:5555`, we're connected. If not, we need to connect.
- [ ] **Step 4: If not connected, connect via ADB wireless** (using adb-wireless skill)
- [ ] **Step 5: Install APK on phone**
Run: `adb -s <device_ip>:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk`
Expected: Success output
### Task 7: Deploy firmware to ESP32 via Android app
**Files:** N/A (device action)
- [ ] **Step 1: Launch app on phone** (manual or via ADB)
```bash
adb -s <device_ip>:5555 shell am start -n com.eddy.atomrelay/.MainActivity
```
- [ ] **Step 2: Put ESP32 in download mode** (manual: hold reset ~2s until green LED)
- [ ] **Step 3: Connect ESP32 via USB-OTG to phone** (manual)
- [ ] **Step 4: Use app to flash firmware** (manual: Settings → Firmware Flashen)
- [ ] **Step 5: Wait for flash completion and disconnect**
### Task 8: Provision HMAC key via app
**Files:** N/A (device action)
- [ ] **Step 1: With ESP32 connected via USB (not in download mode), open app**
- [ ] **Step 2: Go to Settings → Sleutel Uitwisselen**
- [ ] **Step 3: App reads or generates key and provisions ESP**
- [ ] **Step 4: Verify toast "Beveiligingssleutel gelezen"**
### Task 9: Test BLE connection and relay control
**Files:** N/A (manual verification)
- [ ] **Step 1: Ensure phone and ESP32 are powered and within range**
- [ ] **Step 2: Open app → auto-scan should find "SylDa_Server"**
- [ ] **Step 3: Verify status shows "Connected"**
- [ ] **Step 4: Hold HOLD button → relay should activate (verify with multimeter or relay click)**
- [ ] **Step 5: Release → relay deactivates**
- [ ] **Step 6: Touch sensor on G33 should also control relay** (manual test)
---
## Self-Review
**1. Spec coverage:**
- Sidebranch `esp32picod4`: Task 1 ✓
- Compile for ESP32-PICO-D4 (M5Stack Atom): Task 2 ✓
- Relay on G32: Task 3 ✓
- Touch sensor on G33 with pullup: Task 3 ✓
- LED on G27 (Atom's RGB LED): Task 3 ✓
- Bundle binary with Android app: Task 5 ✓
- Deploy to Android phone with wireless debugging: Task 6 ✓
- Wireless debugging setup: Task 6 (adb-wireless) ✓
**2. Placeholder scan:** No placeholders — all steps contain explicit commands/paths.
**3. Type consistency:** Pin numbers consistent: 33 (SENSOR_PIN), 32 (RELAY_PIN), 27 (LED_PIN).

File diff suppressed because it is too large Load diff

View file

@ -1,499 +0,0 @@
# ESP32-PICO-D4 (M5Stack Atom Lite) Port — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port the Sylvia Touch Relay project from ESP32-S3-Pico to ESP32-PICO-D4 (Atom Lite) with relay on G32, touch on G33 (pullup), SK6812 LED on G27, CDC-class USB detection in the Android app, and deploy via ADB wireless.
**Architecture:** Create side branch `esp32picod4` via git worktree. Replace platformio.ini S3 config with `m5stack-atom` board + FastLED library. Update pin definitions and LED code in main.cpp. Replace Android USB VID filter with CDC-ACM class filter. No DTR/RTS changes needed — the esp32-flash-lib handles it correctly.
**Tech Stack:** C++/Arduino (FastLED), PlatformIO (espressif32@6.7.0), Android/Kotlin (AGP 8.5.2, Gradle 8.14), ADB (wireless debugging)
---
### Task 1: Create sidebranch using git worktree
**Files:**
- Create: `.worktrees/esp32picod4/` (git worktree directory)
- [ ] **Step 1: Create and switch to sidebranch in worktree**
```bash
git worktree add -b esp32picod4 .worktrees/esp32picod4
```
- [ ] **Step 2: Verify branch is active**
```bash
git -C .worktrees/esp32picod4 branch --show-current
```
Expected: `esp32picod4`
---
### Task 2: Update PlatformIO configuration for PICO-D4
**Files:**
- Modify: `platformio.ini`
**Worktree path:** `.worktrees/esp32picod4/platformio.ini`
- [ ] **Step 1: Replace entire platformio.ini content**
```ini
[env:m5stack-atom]
platform = espressif32@6.7.0
board = m5stack-atom
framework = arduino
build_flags =
-DESP32
-DCORE_DEBUG_LEVEL=5
lib_deps =
fastled/FastLED@^3.7.0
upload_port = /dev/ttyUSB0
upload_speed = 115200
```
- [ ] **Step 2: Verify m5stack-atom board exists in PlatformIO**
```bash
pio boards --json-output 2>/dev/null | python3 -c "import json,sys; boards=[b['id'] for b in json.load(sys.stdin) if 'm5stack-atom' in b['id']]; print('\n'.join(boards) if boards else 'NOT FOUND')"
```
Expected: `m5stack-atom` (or similar variant)
- [ ] **Step 3: Commit platformio.ini**
```bash
git -C .worktrees/esp32picod4 add platformio.ini
git -C .worktrees/esp32picod4 commit -m "feat: switch platformio config to m5stack-atom (ESP32-PICO-D4)"
```
---
### Task 3: Update firmware for Atom Lite (pins, LED, BLE name)
**Files:**
- Modify: `src/main.cpp`
**Worktree path:** `.worktrees/esp32picod4/src/main.cpp`
- [ ] **Step 1: Replace pin definitions (lines 10-12)**
Replace:
```cpp
#define SENSOR_PIN 38
#define RELAY_PIN 1
#define LED_PIN 46
```
With:
```cpp
#include <FastLED.h>
#define SENSOR_PIN 33
#define RELAY_PIN 32
#define LED_PIN 27
#define NUM_LEDS 1
CRGB leds[NUM_LEDS];
```
- [ ] **Step 2: Replace setup() LED init and blink code (lines 139-149)**
Replace:
```cpp
pinMode(SENSOR_PIN, INPUT_PULLUP);
pinMode(RELAY_PIN, OUTPUT);
pinMode(LED_PIN, OUTPUT);
for (int i = 0; i < 3; i++) {
digitalWrite(LED_PIN, HIGH);
delay(200);
digitalWrite(LED_PIN, LOW);
delay(200);
}
Serial.println("LED blink done, starting BLE...");
```
With:
```cpp
pinMode(SENSOR_PIN, INPUT_PULLUP);
pinMode(RELAY_PIN, OUTPUT);
FastLED.addLeds<SK6812, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(32);
for (int i = 0; i < 3; i++) {
leds[0] = CRGB::Green;
FastLED.show();
delay(200);
leds[0] = CRGB::Black;
FastLED.show();
delay(200);
}
Serial.println("LED blink done, starting BLE...");
```
- [ ] **Step 3: Replace Serial banner and BLE device name**
Replace (line 125):
```cpp
Serial.println("=== SylDa BLE Relay ===");
```
With:
```cpp
Serial.println("=== Atom Lite BLE Relay ===");
```
Replace (line 151):
```cpp
BLEDevice::init("SylDa_Server");
```
With:
```cpp
BLEDevice::init("SyldaServer");
```
Replace (line 174):
```cpp
Serial.println("BLE: advertising started as 'SylDa_Server'");
```
With:
```cpp
Serial.println("BLE: advertising started as 'SyldaServer'");
```
- [ ] **Step 4: Verify main.cpp has no remaining S3-specific references**
```bash
grep -n "S3\|Atom Voice\|46\|G38\|G1\b" .worktrees/esp32picod4/src/main.cpp
```
Expected: no output (no matches)
- [ ] **Step 5: Commit main.cpp**
```bash
git -C .worktrees/esp32picod4 add src/main.cpp
git -C .worktrees/esp32picod4 commit -m "feat: port firmware to Atom Lite (G32 relais, G33 touch, G27 SK6812 LED, SyldaServer BLE)"
```
---
### Task 4: Update copy_firmware.sh for new environment name
**Files:**
- Modify: `tools/copy_firmware.sh`
**Worktree path:** `.worktrees/esp32picod4/tools/copy_firmware.sh`
- [ ] **Step 1: Change environment directory name (line 4)**
Replace:
```bash
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/s3lite-server"
```
With:
```bash
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/m5stack-atom"
```
- [ ] **Step 2: Commit**
```bash
git -C .worktrees/esp32picod4 add tools/copy_firmware.sh
git -C .worktrees/esp32picod4 commit -m "chore: update copy_firmware.sh for m5stack-atom environment"
```
---
### Task 5: Add CDC-class USB detection to UsbUtils.kt
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
**Worktree path:** `.worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
- [ ] **Step 1: Add hasCdcDataInterface function**
After the `findBulkEndpoint` function (before the closing `}` of `object UsbUtils`), add:
```kotlin
fun hasCdcDataInterface(device: UsbDevice): Boolean {
for (i in 0 until device.interfaceCount) {
if (device.getInterface(i).interfaceClass == CDC_DATA_INTERFACE_CLASS) {
return true
}
}
return false
}
```
- [ ] **Step 2: Commit**
```bash
git -C .worktrees/esp32picod4 add android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt
git -C .worktrees/esp32picod4 commit -m "feat: add CDC-class USB detection for universal bridge support"
```
---
### Task 6: Replace VID filter with CDC-class filter in SettingsActivity.kt
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
**Worktree path:** `.worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
- [ ] **Step 1: Remove ESP_VENDOR_ID constant (line 39)**
Delete:
```kotlin
private const val ESP_VENDOR_ID = 0x303a
```
- [ ] **Step 2: Replace VID check in usbAttachedReceiver (line 67)**
Replace:
```kotlin
if (device?.vendorId == ESP_VENDOR_ID) {
```
With:
```kotlin
if (device != null && UsbUtils.hasCdcDataInterface(device)) {
```
- [ ] **Step 3: Replace VID check in usbPermissionReceiver (line 85)**
Replace:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
```
With:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
```
- [ ] **Step 4: Replace VID check in usbDetachedReceiver (line 101)**
Replace:
```kotlin
if (device?.vendorId == ESP_VENDOR_ID) {
```
With:
```kotlin
if (device != null && UsbUtils.hasCdcDataInterface(device)) {
```
- [ ] **Step 5: Replace VID check in startKeyExchange (line 208)**
Replace:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
```
With:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
```
- [ ] **Step 6: Replace VID check in startFlashing (line 256)**
Replace:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
```
With:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
```
- [ ] **Step 7: Verify no remaining ESP_VENDOR_ID references**
```bash
grep -n "ESP_VENDOR_ID\|vendorId.*==" .worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
```
Expected: no output
- [ ] **Step 8: Commit**
```bash
git -C .worktrees/esp32picod4 add android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
git -C .worktrees/esp32picod4 commit -m "feat: replace USB VID filter with CDC-ACM class detection"
```
---
### Task 7: Compile firmware
**Files:** N/A (build artifacts in `.pio/build/m5stack-atom/`)
**Work tree path:** `.worktrees/esp32picod4/`
- [ ] **Step 1: Clean and compile**
```bash
pio run -d .worktrees/esp32picod4 -e m5stack-atom -t clean
pio run -d .worktrees/esp32picod4 -e m5stack-atom
```
Expected: `SUCCESS` at end of output
- [ ] **Step 2: Verify build output files**
```bash
ls -la .worktrees/esp32picod4/.pio/build/m5stack-atom/firmware.bin \
.worktrees/esp32picod4/.pio/build/m5stack-atom/bootloader.bin \
.worktrees/esp32picod4/.pio/build/m5stack-atom/partitions.bin
```
Expected: all 3 files exist with non-zero size
---
### Task 8: Copy firmware binaries to Android app assets
**Files:**
- Modify: `android/app/src/main/assets/firmware/` (destination)
**Worktree path:** `.worktrees/esp32picod4/`
- [ ] **Step 1: Run copy script**
```bash
bash .worktrees/esp32picod4/tools/copy_firmware.sh
```
Expected: lists 4 files (bootloader.bin, partitions.bin, firmware.bin, boot_app0.bin)
- [ ] **Step 2: Verify boot_app0.bin is also present**
```bash
ls -la .worktrees/esp32picod4/android/app/src/main/assets/firmware/boot_app0.bin
```
Expected: file exists
- [ ] **Step 3: Commit binary assets**
```bash
git -C .worktrees/esp32picod4 add android/app/src/main/assets/firmware/
git -C .worktrees/esp32picod4 commit -m "feat: add Atom Lite firmware binaries to Android assets"
```
---
### Task 9: Build Android APK
**Files:** N/A (build output `android/app/build/outputs/apk/debug/app-debug.apk`)
**Worktree path:** `.worktrees/esp32picod4/android/`
- [ ] **Step 1: Build debug APK**
```bash
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew assembleDebug
```
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 2: Verify APK exists**
```bash
ls -la app/build/outputs/apk/debug/app-debug.apk
```
Expected: file exists with size > 1MB
---
### Task 10: Connect Android phone via ADB wireless
**Files:** N/A (ADB system tool)
- [ ] **Step 1: Load adb-wireless skill**
Use the `adb-wireless` skill to establish a wireless ADB connection to the Android phone.
- [ ] **Step 2: Verify ADB connection**
```bash
adb devices
```
Expected: `<ip>:5555` listed as `device`
---
### Task 11: Install APK on phone
**Files:** N/A (app deployment)
- [ ] **Step 1: Install APK**
```bash
adb install -r .worktrees/esp32picod4/android/app/build/outputs/apk/debug/app-debug.apk
```
Expected: `Success`
- [ ] **Step 2: Launch the app**
```bash
adb shell am start -n com.eddy.atomrelay/.MainActivity
```
---
### Task 12: Flash firmware to Atom Lite via Android app
**Files:** N/A (manual device action)
- [ ] **Step 1: Connect Atom Lite via USB-OTG to the phone**
- [ ] **Step 2: Open the app, tap settings gear → "Firmware Flashen"**
- [ ] **Step 3: If app doesn't auto-detect, unplug/replug the Atom Lite while the dialog is open**
- [ ] **Step 4: Wait for flash to complete (progress bar reaches 100%)**
- [ ] **Step 5: After flash, disconnect USB — the Atom Lite should reboot with green LED blink 3x**
---
### Task 13: Provision HMAC key via app
**Files:** N/A (manual device action)
- [ ] **Step 1: Reconnect Atom Lite via USB-OTG to phone (NOT in download mode — just power on normally)**
- [ ] **Step 2: Open app → Settings → "Sleutel Uitwisselen"**
- [ ] **Step 3: Wait for toast "Beveiligingssleutel gelezen" or provisioning**
- [ ] **Step 4: If key was newly generated, verify Serial monitor shows "HMAC key stored in NVS"**
---
### Task 14: Verify BLE connection and relay control
**Files:** N/A (manual verification)
- [ ] **Step 1: Power on the Atom Lite (with relay and touch sensor connected)**
- [ ] **Step 2: Open the Android app — verify it auto-connects to "SyldaServer"**
- [ ] **Step 3: Verify status shows "Connected"**
- [ ] **Step 4: Hold HOLD button — verify relay activates (click or multimeter)**
- [ ] **Step 5: Release — verify relay deactivates**
- [ ] **Step 6: Touch sensor on G33 — verify relay also activates**
---
## Self-Review
**1. Spec coverage:**
- Sidebranch `esp32picod4`: Task 1 ✓
- Compile for ESP32-PICO-D4: Task 2 ✓
- Pins G32/G33/G27: Task 3 ✓
- SK6812 LED with FastLED: Task 3 ✓
- BLE name "SyldaServer": Task 3 ✓
- CDC-class USB detection: Tasks 5, 6 ✓
- DTR/RTS no changes: Confirmed in research, no task needed ✓
- Bundle binary with Android app: Task 8 ✓
- Build APK: Task 9 ✓
- ADB wireless deploy: Tasks 10, 11 ✓
- Flash + provision + test: Tasks 12-14 ✓
**2. Placeholder scan:** No TBD, TODO, or incomplete sections. All commands are concrete with expected output.
**3. Type consistency:** `hasCdcDataInterface(device: UsbDevice): Boolean` defined in Task 5, used consistently in Task 6 with `UsbUtils.hasCdcDataInterface(device/it)`.

View file

@ -1,776 +0,0 @@
# BLE Refactor: BleRelayManager Extractie
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extractie van alle BLE-logica uit MainActivity naar een aparte BleRelayManager klasse met callback interface.
**Architecture:** BleRelayManager beheert scan, GATT, UUID verificatie, HMAC relay commands, key/seq in PrefsHelper. MainActivity wordt puur UI + lifecycle en reageert op BleCallbacks.onStatusChanged(). Touch listeners sturen commands naar de manager.
**Tech Stack:** Kotlin, Android BLE API, PrefsHelper (SharedPreferences), javax.crypto.Mac
---
## File Structure
| Bestand | Actie | Verantwoordelijk |
|---|---|---|
| `BleStatus.kt` | Create | `BleStatus` enum + `BleCallbacks` interface |
| `BleRelayManager.kt` | Create | Alle BLE-logica: scan, GATT, UUID, HMAC, keys, seq, auto-retry |
| `MainActivity.kt` | Modify | UI + lifecycle, delegeert naar BleRelayManager |
---
### Task 1: BleStatus enum + BleCallbacks interface
**Files:**
- Create: `android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt`
- [ ] **Step 1: Schrijf BleStatus.kt**
```kotlin
// SPDX-License-Identifier: GPL-3.0-or-later
package com.eddy.atomrelay
enum class BleStatus {
Scanning,
Connecting,
Connected,
Disconnected,
NoKey,
WrongDevice,
ServiceNotFound,
ServiceError,
CommError,
DeviceNotFound,
BluetoothDisabled,
PermissionsDenied
}
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
}
```
- [ ] **Step 2: Build verificatie**
Run: `./gradlew assembleDebug`
Workdir: `android/`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt
git commit -m "feat: add BleStatus enum and BleCallbacks interface"
```
---
### Task 2: BleRelayManager — class skeleton + companion
**Files:**
- Create: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
- [ ] **Step 1: Schrijf class skeleton**
```kotlin
// SPDX-License-Identifier: GPL-3.0-or-later
package com.eddy.atomrelay
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.os.Handler
import android.os.Looper
import java.util.UUID
class BleRelayManager(
private val context: Context,
private val callbacks: BleCallbacks
) {
companion object {
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L
private const val MAX_SCAN_RETRIES = 3
}
private var bluetoothGatt: BluetoothGatt? = null
private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false
private var scanTimedOut = false
private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper())
private var seq = 1
private var hmacKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
fun startScan() {}
fun stopScan() {}
fun sendRelayCommand(cmd: Byte) {}
fun disconnect() {}
fun updateKeys() {}
fun resetTriedAddresses() {}
fun hasPermissions(): Boolean = false
fun getRequiredPermissions(): Array<String> = emptyArray()
fun destroy() {}
}
```
- [ ] **Step 2: Build verificatie**
Run: `./gradlew assembleDebug`
Workdir: `android/`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
git commit -m "feat: add BleRelayManager class skeleton"
```
---
### Task 3: BleRelayManager — private helpers (loadState, key/HMAC)
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
- [ ] **Step 1: Voeg imports en private helpers toe**
Nieuwe imports:
```kotlin
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
```
Verwijder `fun startScan() {}` etc stubs.
Nieuwe companion constants:
```kotlin
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
```
Nieuwe methodes:
```kotlin
fun updateKeys() {
loadState()
}
fun resetTriedAddresses() {
triedAddresses.clear()
}
fun destroy() {
handler.removeCallbacksAndMessages(null)
}
private fun loadState() {
val prefs = PrefsHelper.getPrefs(context)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
hmacKey = hexKey?.let { hexStringToBytes(it) }
val hexUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
}
private fun hexStringToBytes(hex: String): ByteArray? {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray {
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
val hmacData = ByteArray(5)
hmacData[0] = cmd
System.arraycopy(seqBytes, 0, hmacData, 1, 4)
val hmac = hmacSha256(hmacData, key)
val packet = ByteArray(HMAC_PKT_SIZE)
packet[0] = cmd
System.arraycopy(seqBytes, 0, packet, 1, 4)
System.arraycopy(hmac, 0, packet, 5, HMAC_LEN)
return packet
}
```
- [ ] **Step 2: Build verificatie**
Run: `./gradlew assembleDebug`
Workdir: `android/`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
git commit -m "feat: add BleRelayManager key/HMAC helpers and loadState"
```
---
### Task 4: BleRelayManager — scan callback + startScan/stopScan
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
- [ ] **Step 1: Voeg scan logic toe**
Nieuwe imports:
```kotlin
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.pm.PackageManager
import android.os.ParcelUuid
import androidx.core.app.ActivityCompat
```
Nieuwe velden + scanCallback:
```kotlin
private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device ?: return
val address = device.address ?: return
if (triedAddresses.contains(address)) return
stopScan()
connectToDevice(device)
}
}
```
Methodes startScan, stopScan, hasPermissions, getRequiredPermissions, connectToDevice:
```kotlin
@SuppressLint("MissingPermission")
fun startScan() {
if (isScanning) return
if (!hasPermissions()) {
callbacks.onStatusChanged(BleStatus.PermissionsDenied)
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || !adapter.isEnabled) {
callbacks.onStatusChanged(BleStatus.BluetoothDisabled)
return
}
loadState()
if (hmacKey == null || deviceUuid == null) {
callbacks.onStatusChanged(BleStatus.NoKey)
return
}
isScanning = true
scanTimedOut = false
scanTimeoutRetries = 0
callbacks.onStatusChanged(BleStatus.Scanning)
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
adapter.bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
scanTimeoutRunnable = Runnable {
if (isScanning) {
scanTimedOut = true
stopScan()
}
}
handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS)
}
@SuppressLint("MissingPermission")
fun stopScan() {
if (!isScanning) return
isScanning = false
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (scanTimedOut) {
if (scanTimeoutRetries < MAX_SCAN_RETRIES) {
scanTimeoutRetries++
handler.postDelayed(scanRunnable, 2000L)
return
}
callbacks.onStatusChanged(BleStatus.DeviceNotFound)
}
}
fun hasPermissions(): Boolean {
return getRequiredPermissions().all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
fun getRequiredPermissions(): Array<String> {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
callbacks.onStatusChanged(BleStatus.Connecting)
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}
```
- [ ] **Step 2: Build verificatie**
Run: `./gradlew assembleDebug`
Workdir: `android/`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
git commit -m "feat: add BleRelayManager scan logic with permissions"
```
---
### Task 5: BleRelayManager — GATT callback (connect, services, UUID verify, notifications)
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
- [ ] **Step 1: Voeg GATT callback toe**
Nieuwe imports:
```kotlin
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.util.Log
```
Voeg lege gattCallback stub toe (die was er nog niet) of vervang bestaande lege:
```kotlin
private val gattCallback = object : android.bluetooth.BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
if (hmacKey == null) {
Log.w("BleRelayManager", "GATT connected but no key -- disconnecting")
callbacks.onStatusChanged(BleStatus.NoKey)
gatt.disconnect()
return
}
Log.d("BleRelayManager", "GATT connected")
bluetoothGatt = gatt
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d("BleRelayManager", "GATT disconnected")
callbacks.onStatusChanged(BleStatus.Disconnected)
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
seq = 1
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: android.bluetooth.BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w("BleRelayManager", "Service discovery failed (status=$status)")
callbacks.onStatusChanged(BleStatus.ServiceError)
return
}
Log.d("BleRelayManager", "Services discovered, requesting MTU...")
gatt.requestMtu(256)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
Log.d("BleRelayManager", "MTU changed to $mtu")
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
if (relayCharacteristic != null && uuidChar != null) {
Log.d("BleRelayManager", "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e("BleRelayManager", "Service or characteristics not found")
callbacks.onStatusChanged(BleStatus.ServiceNotFound)
}
} else {
Log.e("BleRelayManager", "Service not found")
callbacks.onStatusChanged(BleStatus.ServiceNotFound)
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicRead(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val storedUuid = deviceUuid
if (storedUuid != null && value.contentEquals(storedUuid)) {
Log.d("BleRelayManager", "UUID verified, setting up relay")
relayCharacteristic?.let { rc ->
gatt.setCharacteristicNotification(rc, true)
val descriptor = rc.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
callbacks.onStatusChanged(BleStatus.Connected)
} else {
Log.w("BleRelayManager", "UUID mismatch -- disconnecting")
triedAddresses.add(gatt.device.address)
callbacks.onStatusChanged(BleStatus.WrongDevice)
gatt.disconnect()
}
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicWrite(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w("BleRelayManager", "Write failed (status=$status)")
callbacks.onStatusChanged(BleStatus.CommError)
}
}
}
```
- [ ] **Step 2: Build verificatie**
Run: `./gradlew assembleDebug`
Workdir: `android/`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
git commit -m "feat: add BleRelayManager GATT callback (connect, UUID verify, notifications)"
```
---
### Task 6: BleRelayManager — sendRelayCommand + disconnect
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
- [ ] **Step 1: Implementeer sendRelayCommand en disconnect**
Vervang de stubs voor `sendRelayCommand` en `disconnect`:
```kotlin
@SuppressLint("MissingPermission")
fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return
val packet = buildPacket(cmd, key)
char.value = packet
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
seq++
if (seq > PrefsHelper.SEQ_MAX) seq = 1
PrefsHelper.getPrefs(context).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply()
}
@SuppressLint("MissingPermission")
fun disconnect() {
handler.removeCallbacks(scanRunnable)
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
stopScan()
bluetoothGatt?.disconnect()
}
```
- [ ] **Step 2: Build verificatie**
Run: `./gradlew assembleDebug`
Workdir: `android/`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
git commit -m "feat: add BleRelayManager sendRelayCommand and disconnect"
```
---
### Task 7: Refactor MainActivity — gebruik BleRelayManager
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt`
- [ ] **Step 1: Herschrijf MainActivity.kt**
Vervang de volledige file content:
```kotlin
// SPDX-License-Identifier: GPL-3.0-or-later
package com.eddy.atomrelay
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.widget.Button
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
class MainActivity : AppCompatActivity(), BleCallbacks {
companion object {
private const val REQUEST_SETTINGS = 1
private const val POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L
}
private lateinit var manager: BleRelayManager
private val handler = Handler(Looper.getMainLooper())
private lateinit var statusText: TextView
private lateinit var relay1Button: Button
private lateinit var relay2Button: Button
private var lastTheme: String? = null
override fun onStatusChanged(status: BleStatus) {
when (status) {
BleStatus.Connected -> {
statusText.text = getString(R.string.status_connected)
setupRelayButton()
setupRelay2Button()
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
BleStatus.Disconnected -> {
statusText.text = getString(R.string.status_connecting)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning)
BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting)
BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key)
BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device)
BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found)
BleStatus.ServiceError -> statusText.text = getString(R.string.status_service_error)
BleStatus.CommError -> statusText.text = getString(R.string.status_comm_error)
BleStatus.BluetoothDisabled -> statusText.text = getString(R.string.status_bluetooth_disabled)
BleStatus.PermissionsDenied -> {
statusText.text = getString(R.string.status_permissions_denied)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
BleStatus.DeviceNotFound -> {
relay1Button.setOnTouchListener(null)
relay2Button.setOnTouchListener(null)
relay1Button.setOnClickListener(null)
relay2Button.setOnClickListener(null)
relay1Button.text = getString(R.string.button_retry_scan)
relay2Button.text = getString(R.string.button_hold)
relay2Button.isEnabled = false
relay1Button.isEnabled = true
relay1Button.setOnClickListener {
setupRelayButton()
setupRelay2Button()
manager.startScan()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
PrefsHelper.applyTheme(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
manager = BleRelayManager(this, this)
statusText = findViewById(R.id.statusText)
relay1Button = findViewById(R.id.relay1Button)
relay2Button = findViewById(R.id.relay2Button)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
settingsButton.setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
}
setupRelayButton()
setupRelay2Button()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) {
manager.disconnect()
manager.resetTriedAddresses()
manager.updateKeys()
handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
}
}
override fun onResume() {
super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
if (current != lastTheme) {
lastTheme = current
recreate()
return
}
if (!manager.hasPermissions()) {
requestPermissions()
return
}
manager.resetTriedAddresses()
handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS)
}
override fun onPause() {
super.onPause()
manager.stopScan()
}
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
manager.startScan()
} else {
onStatusChanged(BleStatus.PermissionsDenied)
}
}
}
private fun setupRelayButton() {
relay1Button.setOnClickListener(null)
relay1Button.text = getString(R.string.button_hold)
relay1Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
manager.sendRelayCommand(0x01.toByte())
relay1Button.text = getString(R.string.button_on)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
manager.sendRelayCommand(0x00.toByte())
relay1Button.text = getString(R.string.button_hold)
true
}
else -> false
}
}
}
private fun setupRelay2Button() {
relay2Button.setOnClickListener(null)
relay2Button.text = getString(R.string.button_hold)
relay2Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
manager.sendRelayCommand(0x02.toByte())
relay2Button.text = getString(R.string.button_on)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
manager.sendRelayCommand(0x03.toByte())
relay2Button.text = getString(R.string.button_hold)
true
}
else -> false
}
}
}
private fun requestPermissions() {
val perms = manager.getRequiredPermissions()
ActivityCompat.requestPermissions(this, perms, 1)
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
manager.destroy()
}
}
```
- [ ] **Step 2: Build verificatie**
Run: `./gradlew assembleDebug`
Workdir: `android/`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
git commit -m "refactor: extract BLE logic from MainActivity to BleRelayManager"
```
---
### Task 8: Functionele verificatie — deploy en test
- [ ] **Step 1: Deploy APK naar telefoon**
Gebruik de adb-wireless skill om te verbinden, dan:
```bash
adb -s 192.168.1.43:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk
```
- [ ] **Step 2: Test BLE scan, connect, UUID verify**
Start de app. Verifieer:
- Status: "Scannen..." -> "Verbinden..." -> "Verbonden"
- Relay 1 en 2 buttons enabled
- HMAC key en UUID correct geladen uit PrefsHelper
- [ ] **Step 3: Test relay knoppen**
- Relay 1 indrukken: relais 1 aan, button tekst "AAN"
- Relay 1 loslaten: relais 1 uit, button tekst "INGEDRUKT HOUDEN"
- Relay 2 indrukken: relais 2 aan, button tekst "AAN"
- Relay 2 loslaten: relais 2 uit, button tekst "INGEDRUKT HOUDEN"
- Snel ON->OFF cyclen: geen vastlopers
- [ ] **Step 4: Test disconnect + auto-reconnect**
- ESP uitzetten, verifieer "Verbinden...", zet ESP aan, verifieer "Verbonden"
- [ ] **Step 5: Test settings key exchange**
- Wis keys in app, open Settings, doe key exchange
- Verifieer dat app opnieuw scant en verbindt
- [ ] **Step 6: Test scan timeout**
- ESP uitzetten tijdens scan, verifieer na ~90s: timeout + retry knop
- Druk "Opnieuw scannen", verifieer scan herstart

View file

@ -1,900 +0,0 @@
# BLE OTA Firmware Updates — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable firmware updates over BLE from the Android app, with automatic version check on connect.
**Architecture:** ESP32 exposes a firmware version READ characteristic and an OTA WRITE+NOTIFY characteristic. Android reads version on every BLE connect, shows toast if an update is available. OTA flow: app sends firmware chunks via BLE → ESP writes to ota_1 via `esp_ota_*` API → reboot. Relays drop OFF during OTA.
**Tech Stack:** ESP32-S3 Arduino (NimBLE, Preferences, mbedtls, esp_ota), Android Kotlin (standard BLE APIs, coroutines)
**Spec:** `docs/superpowers/specs/2026-07-30-ble-ota-design.md`
---
### Task 1: Firmware — Add VERSION_UUID READ characteristic
**Files:**
- Modify: `src/main.cpp`
- [ ] **Step 1: Define constant and add version characteristic in setup()**
Add after `#define UUID_CHAR_UUID`:
```cpp
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
```
Replace the `NimBLECharacteristic *pUuidChar` section in setup() with:
```cpp
NimBLECharacteristic *pUuidChar = pService->createCharacteristic(
UUID_CHAR_UUID,
NIMBLE_PROPERTY::READ
);
pUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
NimBLECharacteristic *pVersionChar = pService->createCharacteristic(
VERSION_UUID,
NIMBLE_PROPERTY::READ
);
pVersionChar->setValue(FIRMWARE_VERSION);
```
- [ ] **Step 2: Build firmware**
```bash
cd /home/eddy/src/Project_SylDa && pio run
```
Expected: SUCCESS with no errors.
- [ ] **Step 3: Flash and verify via USB serial**
```bash
pio run -t upload
```
Open serial monitor (115200 baud) and send:
```
VERSION?
```
Expected: `BUILD_NR:1`
- [ ] **Step 4: Verify via BLE from Android side**
Open the Android app, connect to ESP. In BleRelayManager's `onMtuChanged`, add a read for the new VERSION_UUID characteristic (will be done in Task 5). For now, verify the characteristic is discoverable with nRF Connect or similar BLE scanner — should show a READ characteristic with value "1".
- [ ] **Step 5: Commit**
```bash
git add src/main.cpp
git commit -m "feat: add firmware version BLE characteristic (VERSION_UUID)"
```
---
### Task 2: Firmware — Add OTA_UUID characteristic and OTA logic
**Files:**
- Modify: `src/main.cpp`
- [ ] **Step 1: Add includes and constants**
Add at top of file after existing includes:
```cpp
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <nvs_flash.h>
```
Add after `#define VERSION_UUID`:
```cpp
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#define OTA_CHUNK_SIZE 500
#define OTA_CMD_START 0x10
#define OTA_CMD_END 0x11
#define OTA_STATUS_ACK 0x00
#define OTA_STATUS_NAK 0x01
#define OTA_STATUS_COMPLETE 0x10
#define OTA_STATUS_ERROR 0x11
```
Add global state variables after `bool relay2State = false;`:
```cpp
bool otaInProgress = false;
esp_ota_handle_t otaHandle = 0;
const esp_partition_t *otaPartition = nullptr;
uint32_t otaExpectedSize = 0;
uint32_t otaWritten = 0;
uint8_t otaExpectedSha[32];
```
Add helper functions before `setup()`:
```cpp
static void ledOtaBusy() {
digitalWrite(LED_PIN, (millis() / 100) % 2 ? HIGH : LOW);
}
static void otaReset() {
otaInProgress = false;
otaHandle = 0;
otaPartition = nullptr;
otaExpectedSize = 0;
otaWritten = 0;
}
static void sendOtaNotify(NimBLECharacteristic *otaChar, uint8_t status, uint32_t seq) {
uint8_t buf[5];
buf[0] = status;
buf[1] = (seq >> 24) & 0xFF;
buf[2] = (seq >> 16) & 0xFF;
buf[3] = (seq >> 8) & 0xFF;
buf[4] = seq & 0xFF;
otaChar->setValue(buf, 5);
otaChar->notify();
}
static void abortOta(NimBLECharacteristic *otaChar, const char *reason) {
Serial.printf("OTA: abort — %s\n", reason);
if (otaHandle != 0) {
esp_ota_end(otaHandle);
}
otaReset();
sendOtaNotify(otaChar, OTA_STATUS_ERROR, 0);
}
```
- [ ] **Step 2: Add OTA characteristic callback class**
Add before `class MyCharacteristicCallbacks`:
```cpp
class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
std::string value = pChar->getValue();
if (value.empty()) return;
const uint8_t *data = (const uint8_t *)value.data();
size_t len = value.length();
if (len == 5 && data[0] == OTA_CMD_START) {
if (otaInProgress) {
esp_ota_end(otaHandle);
otaReset();
}
otaExpectedSize = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) |
((uint32_t)data[3] << 8) | (uint32_t)data[4];
otaPartition = esp_ota_get_next_update_partition(nullptr);
if (otaPartition == nullptr) {
Serial.println("OTA: no update partition found");
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
esp_err_t err = esp_ota_begin(otaPartition, otaExpectedSize, &otaHandle);
if (err != ESP_OK) {
Serial.printf("OTA: begin failed (%d)\n", err);
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
otaInProgress = true;
otaWritten = 0;
digitalWrite(RELAY1_PIN, LOW);
digitalWrite(RELAY2_PIN, LOW);
relay1State = false;
relay2State = false;
bleRelay1 = false;
bleRelay2 = false;
Serial.printf("OTA: started, expecting %lu bytes\n", otaExpectedSize);
return;
}
if (len == 32 && otaInProgress) {
memcpy(otaExpectedSha, data, 32);
esp_err_t err = esp_ota_end(otaHandle);
if (err != ESP_OK) {
Serial.printf("OTA: end failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
err = esp_ota_set_boot_partition(otaPartition);
if (err != ESP_OK) {
Serial.printf("OTA: set boot partition failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
digitalWrite(LED_PIN, HIGH);
sendOtaNotify(pChar, OTA_STATUS_COMPLETE, 0);
Serial.println("OTA: complete, rebooting...");
delay(500);
esp_restart();
return;
}
if (!otaInProgress) return;
if (data[0] < 0x10) {
uint32_t seq = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) |
((uint32_t)data[2] << 8) | (uint32_t)data[3];
size_t dataLen = len - 4;
esp_err_t err = esp_ota_write(otaHandle, data + 4, dataLen);
if (err != ESP_OK) {
Serial.printf("OTA: write err at seq %lu (%d)\n", seq, err);
sendOtaNotify(pChar, OTA_STATUS_NAK, seq);
abortOta(pChar, "write error");
return;
}
otaWritten += dataLen;
sendOtaNotify(pChar, OTA_STATUS_ACK, seq);
}
}
};
```
- [ ] **Step 3: Add OTA characteristic to BLE service in setup()**
Add after the pVersionChar block in setup():
```cpp
NimBLECharacteristic *pOtaChar = pService->createCharacteristic(
OTA_UUID,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::NOTIFY
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
```
- [ ] **Step 4: Update loop() to handle OTA mode**
Modify the relay state logic in loop(). Replace:
```cpp
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
relay2State = t2Active || (deviceConnected && bleRelay2);
```
With:
```cpp
if (!otaInProgress) {
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
relay2State = t2Active || (deviceConnected && bleRelay2);
} else {
relay1State = false;
relay2State = false;
ledOtaBusy();
}
```
Also gate the serial command processing, BLE relay commands, and heartbeat logging with `!otaInProgress`. In the `onWrite` of `MyCharacteristicCallbacks`, add at the top:
```cpp
if (otaInProgress) return;
```
In the serial handler, wrap the command processing in:
```cpp
if (!otaInProgress) {
// existing serial command handling
}
```
And wrap the heartbeat log in:
```cpp
if (!otaInProgress && millis() - heartbeat > 5000) {
```
- [ ] **Step 5: Build firmware**
```bash
cd /home/eddy/src/Project_SylDa && pio run
```
Expected: SUCCESS. Firmware should still be ~530-600KB.
- [ ] **Step 6: Commit**
```bash
git add src/main.cpp
git commit -m "feat: add BLE OTA characteristic with esp_ota integration"
```
---
### Task 3: Firmware — Bump version to "2" and rebuild asset firmware
**Files:**
- Modify: `src/main.cpp`
- Overwrite: `android/app/src/main/assets/firmware/firmware.bin`
- [ ] **Step 1: Bump version**
Change:
```cpp
#define FIRMWARE_VERSION "1"
```
To:
```cpp
#define FIRMWARE_VERSION "2"
```
- [ ] **Step 2: Build and copy firmware to assets**
```bash
cd /home/eddy/src/Project_SylDa && pio run && cp .pio/build/s3lite-server/firmware.bin android/app/src/main/assets/firmware/firmware.bin
```
- [ ] **Step 3: Flash to ESP and verify version**
```bash
pio run -t upload
```
Serial: send `VERSION?` → Expected: `BUILD_NR:2`
- [ ] **Step 4: Commit**
```bash
git add src/main.cpp android/app/src/main/assets/firmware/firmware.bin
git commit -m "feat: bump firmware to v2, update bundled firmware asset"
```
---
### Task 4: Android — Update BleStatus and BleCallbacks
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleCallbacks.kt` (extract from BleStatus.kt — it's currently in the same file)
Wait, BleCallbacks is already in BleStatus.kt. Let me just modify BleStatus.kt.
- [ ] **Step 1: Add firmware version check callback to BleCallbacks**
Modify `BleCallbacks` interface in `BleStatus.kt`. Add the method:
```kotlin
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
fun onFirmwareVersion(espVersion: String, appVersion: String)
}
```
- [ ] **Step 2: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt
git commit -m "feat: add onFirmwareVersion callback to BleCallbacks"
```
---
### Task 5: Android — Update BleRelayManager for version + OTA characteristics
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt`
- [ ] **Step 1: Add companion instance for cross-activity access**
Add before `companion object`:
```kotlin
companion object {
var instance: BleRelayManager? = null
```
And at the end of the constructor body (before closing brace of companion object), add:
Wait — the `instance` assignment must happen in the class body, not in companion object. Add after the `init` block doesn't exist, so use a field initializer approach. Actually, just add at the end of the primary constructor body. Since Kotlin doesn't have an init block here, add it inline with the property declarations:
After `private var triedAddresses = mutableSetOf<String>()`, add:
```kotlin
init {
instance = this
}
```
**Full updated companion object + init:**
```kotlin
companion object {
var instance: BleRelayManager? = null
private const val TAG = "BleRelayManager"
// ... keep existing companion members ...
}
// ... existing fields ...
init {
instance = this
}
```
- [ ] **Step 2: Add UUID constants and MTU**
Add after existing UUID constants:
```kotlin
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db")
private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
```
Change `BLE_MTU` from 256 to 512:
```kotlin
private const val BLE_MTU = 512
```
- [ ] **Step 3: Add OTA characteristic field and version fields**
Add after `private var relayCharacteristic`:
```kotlin
private var otaCharacteristic: BluetoothGattCharacteristic? = null
private var espVersion: String? = null
private var appVersion: String = ""
```
Add a method to set the bundled app version:
```kotlin
fun setAppVersion(version: String) {
appVersion = version
}
```
- [ ] **Step 4: Read version characteristic on service discovery**
In `onMtuChanged`, after reading the UUID characteristic, also read the version characteristic. The current code reads `uuidChar` only. Modify `onMtuChanged`:
```kotlin
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
otaCharacteristic = service.getCharacteristic(OTA_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
val versionChar = service.getCharacteristic(VERSION_UUID)
if (relayCharacteristic != null && uuidChar != null) {
Log.d(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e(TAG, "Service or characteristics not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
} else {
Log.e(TAG, "Service not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
}
```
- [ ] **Step 5: Handle version characteristic read in onCharacteristicRead**
Add version handling to `onCharacteristicRead`. After the UUID check block, add:
```kotlin
if (characteristic.uuid == VERSION_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val version = String(value, Charsets.UTF_8).trim()
espVersion = version
Log.d(TAG, "ESP firmware version: $version, app bundled: $appVersion")
handler.post { callbacks.onFirmwareVersion(version, appVersion) }
}
```
- [ ] **Step 6: Add OTA write method**
Add a method to write OTA data:
```kotlin
fun sendOtaData(data: ByteArray) {
val char = otaCharacteristic ?: return
val gatt = bluetoothGatt ?: return
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
}
```
- [ ] **Step 7: Add method to enable OTA notifications**
```kotlin
@SuppressLint("MissingPermission")
fun enableOtaNotifications(): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
return gatt.setCharacteristicNotification(char, true)
}
```
- [ ] **Step 8: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt
git commit -m "feat: add version read, OTA characteristic, BLE MTU 512 in BleRelayManager"
```
---
### Task 6: Android — Update MainActivity for version check toast
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt`
- [ ] **Step 1: Add version constant and implement callback**
Add companion object constant:
```kotlin
private const val BUNDLED_FIRMWARE_VERSION = "2"
```
In `onCreate`, after initializing `manager`:
```kotlin
manager.setAppVersion(BUNDLED_FIRMWARE_VERSION)
```
Add `onFirmwareVersion` implementation to the `MainActivity` class:
```kotlin
override fun onFirmwareVersion(espVersion: String, appVersion: String) {
if (espVersion != appVersion) {
runOnUiThread {
Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_LONG).show()
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt
git commit -m "feat: show toast when firmware update available via BLE"
```
---
### Task 7: Android — Create OtaManager.kt
**Files:**
- Create: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt`
- [ ] **Step 1: Create OtaManager class**
```kotlin
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.annotation.SuppressLint
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
class OtaManager(
private val context: Context,
private val relayManager: BleRelayManager
) {
companion object {
private const val TAG = "OtaManager"
private const val OTA_CHUNK_DATA_SIZE = 500
private const val OTA_CMD_START: Byte = 0x10
private const val OTA_CMD_END: Byte = 0x11
private const val OTA_STATUS_ACK: Byte = 0x00
private const val OTA_STATUS_COMPLETE: Byte = 0x10
private const val OTA_STATUS_ERROR: Byte = 0x11
private const val OTA_CHUNK_TIMEOUT_MS = 5000L
private const val OTA_START_TIMEOUT_MS = 10000L
}
interface OtaCallback {
fun onProgress(percent: Int, message: String)
fun onComplete()
fun onError(message: String)
}
private var firmwareData: ByteArray? = null
fun loadFirmware(): Boolean {
return try {
firmwareData = context.assets.open("firmware/firmware.bin").readBytes()
true
} catch (e: Exception) {
Log.e(TAG, "Failed to load firmware", e)
false
}
}
suspend fun performOta(callback: OtaCallback): Boolean = withContext(Dispatchers.IO) {
val fw = firmwareData ?: run {
withContext(Dispatchers.Main) { callback.onError("Firmware niet geladen") }
return@withContext false
}
val sha256 = MessageDigest.getInstance("SHA-256").digest(fw)
try {
withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") }
val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN)
startPacket.put(OTA_CMD_START)
startPacket.putInt(fw.size)
relayManager.sendOtaData(startPacket.array())
withTimeout(OTA_START_TIMEOUT_MS) {
while (true) { delay(100); kotlinx.coroutines.ensureActive() }
}
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
// timeout is expected — ESP doesn't ACK OTA_START, just begins
}
val totalChunks = (fw.size + OTA_CHUNK_DATA_SIZE - 1) / OTA_CHUNK_DATA_SIZE
var seq = 0
var offset = 0
while (offset < fw.size) {
val remaining = fw.size - offset
val chunkSize = minOf(remaining, OTA_CHUNK_DATA_SIZE)
val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN)
packet.putInt(seq)
packet.put(fw, offset, chunkSize)
relayManager.sendOtaData(packet.array())
Log.d(TAG, "Sent chunk $seq ($chunkSize bytes)")
val pct = ((offset.toFloat() / fw.size.toFloat()) * 100).toInt().coerceAtMost(99)
withContext(Dispatchers.Main) {
callback.onProgress(pct, "OTA bezig: ${seq + 1}/${totalChunks}")
}
offset += chunkSize
seq++
kotlinx.coroutines.delay(50)
}
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
relayManager.sendOtaData(sha256)
withContext(Dispatchers.Main) { callback.onProgress(100, "Herstart...") }
withContext(Dispatchers.Main) { callback.onComplete() }
true
}
}
```
- [ ] **Step 2: Add import to OtaManager for delay**
The `delay` and `ensureActive` calls need `kotlinx.coroutines.delay` and `kotlinx.coroutines.ensureActive`. These are already available through the lifecycle-runtime-ktx dependency.
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "feat: add OtaManager for BLE firmware update"
```
---
### Task 8: Android — Add OTA button and flow to SettingsActivity
**Files:**
- Modify: `android/app/src/main/res/layout/activity_settings.xml`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt`
- [ ] **Step 1 (layout): Add OTA button to activity_settings.xml**
Add before the first `<View` divider (i.e., after the `flashFirmwareButton` Button):
```xml
<Button
android:id="@+id/otaUpdateButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/button_ota_update"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/otaStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:gravity="center"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:visibility="gone" />
```
- [ ] **Step 2 (code): Add OTA button handler and logic to SettingsActivity**
Add fields at top of class:
```kotlin
private var otaManager: OtaManager? = null
private var otaJob: Job? = null
```
Add OTA button setup in `onCreate` (after the flash button setup):
```kotlin
val otaButton = findViewById<Button>(R.id.otaUpdateButton)
otaButton.setOnClickListener {
startOtaUpdate()
}
```
Add the OTA methods:
```kotlin
private fun startOtaUpdate() {
val manager = BleRelayManager.instance
if (manager == null) {
Toast.makeText(this, "Niet verbonden met SylDa_Server", Toast.LENGTH_SHORT).show()
return
}
otaManager = OtaManager(this, manager)
if (otaManager?.loadFirmware() != true) {
Toast.makeText(this, getString(R.string.ota_load_failed), Toast.LENGTH_SHORT).show()
return
}
showOtaProgressDialog()
otaJob = lifecycleScope.launch {
val result = otaManager!!.performOta(object : OtaManager.OtaCallback {
override fun onProgress(percent: Int, message: String) {
lifecycleScope.launch(Dispatchers.Main) {
val d = progressDialog ?: return@launch
d.findViewById<ProgressBar>(R.id.progressBar)?.progress = percent
d.findViewById<TextView>(R.id.message)?.text = message
}
}
override fun onComplete() {
lifecycleScope.launch(Dispatchers.Main) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_complete), Toast.LENGTH_LONG).show()
}
}
override fun onError(message: String) {
lifecycleScope.launch(Dispatchers.Main) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_failed, message), Toast.LENGTH_LONG).show()
}
}
})
}
}
private fun showOtaProgressDialog() {
showProgressDialog(
getString(R.string.title_ota_update),
getString(R.string.msg_ota_starting),
STYLE_HORIZONTAL
)
}
```
Update `onDestroy` to cancel the OTA job:
```kotlin
override fun onDestroy() {
super.onDestroy()
currentAction = UsbAction.NONE
flasherJob?.cancel()
flasherJob = null
otaJob?.cancel()
otaJob = null
try { unregisterReceiver(usbAttachedReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbPermissionReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbDetachedReceiver) } catch (_: Exception) {}
}
```
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/res/layout/activity_settings.xml android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt
git commit -m "feat: add BLE OTA update button to SettingsActivity"
```
---
### Task 9: Android — Add OTA strings
**Files:**
- Modify: `android/app/src/main/res/values/strings.xml`
- [ ] **Step 1: Add OTA-related strings**
Add before `</resources>`:
```xml
<!-- OTA Updates -->
<string name="button_ota_update">Firmware bijwerken via Bluetooth</string>
<string name="title_ota_update">OTA Firmware Update</string>
<string name="msg_ota_starting">OTA update starten...</string>
<string name="ota_update_available">Nieuwe firmware beschikbaar — open Instellingen om bij te werken</string>
<string name="ota_complete">Update voltooid! ESP herstart...</string>
<string name="ota_failed">OTA update mislukt: %s</string>
<string name="ota_load_failed">Kan firmware bestand niet laden</string>
```
- [ ] **Step 2: Commit**
```bash
git add android/app/src/main/res/values/strings.xml
git commit -m "feat: add OTA UI strings"
```
---
### Task 10: Build APK and test full flow
**Files:**
- All modified files from Tasks 1-9
- [ ] **Step 1: Build APK**
```bash
cd /home/eddy/src/Project_SylDa/android && ./gradlew assembleDebug
```
Expected: BUILD SUCCESSFUL.
- [ ] **Step 2: Install APK on test phone**
Use the adb-wireless skill to connect to the phone, then:
```bash
adb -s 192.168.1.43:5555 install -r app/build/outputs/apk/debug/app-debug.apk
```
- [ ] **Step 3: Test flow**
1. Open the app — should connect to SylDa_Server via BLE
2. If ESP runs firmware v2 and app has v2: no toast (versions match)
3. Flash ESP with v1 firmware (`git checkout HEAD~2 src/main.cpp && pio run -t upload`), then test: should see toast "Nieuwe firmware beschikbaar"
4. Go to Settings → tap "Firmware bijwerken via Bluetooth" → should see progress bar
5. After completion: ESP should reboot and run v2 firmware
6. App reconnects, no toast (versions now match)
- [ ] **Step 4: Verify rollback (optional test)**
Build a bad firmware (e.g., intentionally corrupt) and push it. ESP should fail `esp_ota_end()` and stay on current firmware.
- [ ] **Step 5: Commit (re-flash v2)**
```bash
git stash # restore v2 main.cpp
pio run -t upload
```
Final commit if any changes were made during testing:
```bash
git add -A
git commit -m "fix: OTA test adjustments"
```

View file

@ -1,500 +0,0 @@
# OTA Improvements — Implementatieplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Zes code review aanbevelingen implementeren: SHA256 verificatie op ESP, OTA notification handling op Android, dynamische chunk size, writeCharacteristic return check, otaStatusText verwijderen.
**Architecture:** ESP stuurt al ACK/NAK/COMPLETE/ERROR notificaties — Android moet ze alleen nog verwerken. Chunk size wordt dynamisch afgeleid van onderhandelde MTU (512 via `setMTU`). SHA256 verificatie voorkomt dat corrupte firmware de boot partitie overschrijft.
**Tech Stack:** C++ (ESP32, NimBLE, esp_ota_ops, mbedtls), Kotlin (Android, BLE GATT, Coroutines)
**Verify:** Na elke task: compilatie (`pio run` voor ESP, `./gradlew assembleDebug` voor Android). Geen automated tests — handmatig flashen naar ESP en OTA testen na alle tasks.
---
### Task 1: ESP SHA256 verificatie
**Files:**
- Modify: `src/main.cpp:192-213`
De ESP ontvangt al de SHA256 hash als laatste chunk (32 bytes, opgeslagen in `otaExpectedSha`) en roept `esp_ota_end()` aan. Wat ontbreekt: `esp_ota_get_app_elf_sha256()` + `memcmp()` vóór `esp_ota_set_boot_partition()`.
- [ ] **Step 1: Vervang CMD_END handler met SHA256 verificatie**
In `src/main.cpp`, vervang de bestaande 32-byte handler (regels 192-213):
```cpp
if (len == 32 && otaInProgress) {
memcpy(otaExpectedSha, data, 32);
esp_err_t err = esp_ota_end(otaHandle);
if (err != ESP_OK) {
Serial.printf("OTA: end failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
uint8_t computedSha[32];
err = esp_ota_get_app_elf_sha256(otaHandle, (char *)computedSha, sizeof(computedSha));
if (err != ESP_OK) {
Serial.printf("OTA: SHA256 read failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
if (memcmp(computedSha, otaExpectedSha, 32) != 0) {
Serial.println("OTA: SHA256 mismatch — aborting");
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
err = esp_ota_set_boot_partition(otaPartition);
if (err != ESP_OK) {
Serial.printf("OTA: set boot partition failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
digitalWrite(LED_PIN, HIGH);
sendOtaNotify(pChar, OTA_STATUS_COMPLETE, 0);
Serial.println("OTA: SHA256 OK, rebooting...");
delay(500);
esp_restart();
return;
}
```
De wijziging: na `esp_ota_end()` wordt `esp_ota_get_app_elf_sha256()` aangeroepen en wordt `memcmp` gedaan. Bij mismatch: ERROR notificatie + `otaReset()` zonder boot partitie te wijzigen.
- [ ] **Step 2: Compileer ESP firmware**
```bash
pio run
```
- [ ] **Step 3: Commit**
```bash
git add src/main.cpp
git commit -m "feat: add SHA256 verification before esp_ota_set_boot_partition"
```
---
### Task 2: Android OTA notification handling
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt`
ESP stuurt 5-byte notificaties `[status:1][seq:4_BE]` met status `0x00`=ACK, `0x01`=NAK, `0x10`=COMPLETE, `0x11`=ERROR. Android moet deze verwerken met retry-logica.
- [ ] **Step 1: Callback interface toevoegen voor OTA notificaties**
In `BleRelayManager.kt`, voeg een callback interface toe voor OTA notificaties rond de `BleCallbacks` interface (regel 64):
In `BleRelayManager.kt`, voeg toe na de bestaande `BleCallbacks` interface:
```kotlin
interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int)
}
private var otaNotifyCallback: OtaNotifyCallback? = null
fun setOtaNotifyCallback(callback: OtaNotifyCallback?) {
otaNotifyCallback = callback
}
```
- [ ] **Step 2: `onCharacteristicChanged()` toevoegen aan `GattCallback`**
In `BleRelayManager.kt`, voeg `onCharacteristicChanged()` toe in de `GattCallback` class, na `onCharacteristicWrite()` (regel 327):
```kotlin
@SuppressLint("MissingPermission")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
if (characteristic.uuid == OTA_UUID && value.size >= 1) {
val status = value[0].toInt() and 0xFF
val seq = if (value.size >= 5) {
((value[1].toInt() and 0xFF) shl 24) or
((value[2].toInt() and 0xFF) shl 16) or
((value[3].toInt() and 0xFF) shl 8) or
(value[4].toInt() and 0xFF)
} else 0
Log.d(TAG, "OTA notify: status=$status seq=$seq")
handler.post { otaNotifyCallback?.onOtaNotify(status, seq) }
}
}
```
- [ ] **Step 3: Retry-logica in `OtaManager.kt`**
Herschrijf `performOta()` in `OtaManager.kt` (regels 54-109) om te wachten op ACK/NAK per chunk:
**Replace de hele `performOta()` method:**
```kotlin
suspend fun performOta(callback: OtaCallback): Boolean = withContext(Dispatchers.IO) {
val fw = firmwareData ?: run {
withContext(Dispatchers.Main) { callback.onError("Firmware niet geladen") }
return@withContext false
}
val sha256 = MessageDigest.getInstance("SHA-256").digest(fw)
var chunkAckReceived = false
var chunkNakReceived = false
var finalStatusReceived = false
var finalStatus = -1
val notifyCallback = object : BleRelayManager.OtaNotifyCallback {
override fun onOtaNotify(status: Int, seq: Int) {
when (status) {
0x00 -> chunkAckReceived = true
0x01 -> chunkNakReceived = true
0x10 -> { finalStatusReceived = true; finalStatus = 0x10 }
0x11 -> { finalStatusReceived = true; finalStatus = 0x11 }
}
}
}
relayManager.setOtaNotifyCallback(notifyCallback)
try {
withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") }
val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN)
startPacket.put(OTA_CMD_START)
startPacket.putInt(fw.size)
relayManager.sendOtaData(startPacket.array())
try {
withTimeout(OTA_START_TIMEOUT_MS) {
while (true) { delay(100); ensureActive() }
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {}
val totalChunks = (fw.size + OTA_CHUNK_DATA_SIZE - 1) / OTA_CHUNK_DATA_SIZE
var seq = 0
var offset = 0
while (offset < fw.size) {
val remaining = fw.size - offset
val chunkSize = minOf(remaining, OTA_CHUNK_DATA_SIZE)
val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN)
packet.putInt(seq)
packet.put(fw, offset, chunkSize)
chunkAckReceived = false
chunkNakReceived = false
var retries = 0
while (retries < 3) {
relayManager.sendOtaData(packet.array())
Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}")
try {
withTimeout(2000L) {
while (!chunkAckReceived && !chunkNakReceived) {
delay(50)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for ACK/NAK on chunk $seq")
}
if (chunkAckReceived) break
retries++
Log.w(TAG, "Chunk $seq NAK/timeout, retry $retries/3")
}
if (!chunkAckReceived) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA mislukt: chunk $seq geen ACK na 3 pogingen") }
return@withContext false
}
val pct = ((offset.toFloat() / fw.size.toFloat()) * 99).toInt().coerceAtMost(99)
withContext(Dispatchers.Main) {
callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks")
}
offset += chunkSize
seq++
}
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
finalStatusReceived = false
relayManager.sendOtaData(sha256)
try {
withTimeout(10000L) {
while (!finalStatusReceived) {
delay(100)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for final OTA status")
}
relayManager.setOtaNotifyCallback(null)
if (finalStatus == 0x10) {
withContext(Dispatchers.Main) {
callback.onProgress(100, "Herstart...")
callback.onComplete()
}
true
} else {
withContext(Dispatchers.Main) {
callback.onError("OTA mislukt: ESP SHA256 verificatie fout")
}
false
}
} catch (e: Exception) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA fout: ${e.message}") }
false
}
}
```
- [ ] **Step 4: Compileer Android app**
```bash
./gradlew assembleDebug
```
- [ ] **Step 5: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "feat: OTA notification handling with ACK/NAK retry logic"
```
---
### Task 3: ESP setMTU(512) + Android dynamische chunk size
**Files:**
- Modify: `src/main.cpp` (BLE setup, rond regel 270)
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt`
- [ ] **Step 1: ESP — zet MTU naar 512**
In `src/main.cpp`, in de `setup()` functie, voeg `NimBLEDevice::setMTU(512)` toe vóór de BLE initialisatie. Zoek `NimBLEDevice::init` (rond regel 275) en voeg de regel ervoor toe:
```cpp
NimBLEDevice::setMTU(512);
NimBLEDevice::init(deviceName);
```
- [ ] **Step 2: ESP — compileer**
```bash
pio run
```
- [ ] **Step 3: Android — MTU opslaan en doorgeven**
In `BleRelayManager.kt`, `onMtuChanged()` (regel 265), sla de MTU op:
Voeg een field toe bij de andere fields (rond regel 59):
```kotlin
private var negotiatedMtu: Int = 23
```
In `onMtuChanged()`:
```kotlin
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
negotiatedMtu = mtu
val service = gatt.getService(SERVICE_UUID)
...
```
Voeg getter toe (na `getEspVersion()`, regel 220):
```kotlin
fun getNegotiatedMtu(): Int = negotiatedMtu
```
- [ ] **Step 4: Android — dynamische chunk size in `OtaManager.kt`**
Vervang `OTA_CHUNK_DATA_SIZE` constant (regel 26) door een dynamische property:
```kotlin
private val OTA_CHUNK_DATA_SIZE: Int
get() {
val mtu = instance?.relayManager?.getNegotiatedMtu() ?: 23
return minOf(maxOf(mtu - 3 - 4, 20), OTA_ESP_MAX_CHUNK)
}
private const val OTA_ESP_MAX_CHUNK = 500
```
Maar `instance?.relayManager` werkt niet direct in companion. Maak `OtaManager` non-companion eigenschap of sla de chunk size op bij constructie:
In `OtaManager` class body (na companion object, voor `loadFirmware`):
```kotlin
private var chunkDataSize: Int = 248
init {
chunkDataSize = calculateChunkSize(relayManager.getNegotiatedMtu())
}
private fun calculateChunkSize(mtu: Int): Int {
return minOf(maxOf(mtu - 3 - 4, 20), 500)
}
```
Vervang alle referenties naar `OTA_CHUNK_DATA_SIZE` in `performOta()` naar `chunkDataSize` (komt 3x voor: regel 77, 83, en een impliciete in de chunkSize berekening).
- [ ] **Step 5: Android — compileer**
```bash
./gradlew assembleDebug
```
- [ ] **Step 6: Commit**
```bash
git add src/main.cpp android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "feat: ESP MTU 512 + dynamic Android chunk size"
```
---
### Task 4: writeCharacteristic return check
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt:186-192`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt`
- [ ] **Step 1: `sendOtaData()` return waarde toevoegen**
In `BleRelayManager.kt`, wijzig `sendOtaData()` (regel 186) om `Boolean` terug te geven:
```kotlin
@SuppressLint("MissingPermission")
fun sendOtaData(data: ByteArray): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return gatt.writeCharacteristic(char)
}
```
- [ ] **Step 2: Return check in `performOta()`**
In `OtaManager.kt`, in de retry-loop van `performOta()` (de while-loop die chunks stuurt), wijzig de `relayManager.sendOtaData(packet.array())` aanroep:
```kotlin
while (retries < 3) {
val writeOk = relayManager.sendOtaData(packet.array())
if (!writeOk) {
Log.w(TAG, "writeCharacteristic returned false for chunk $seq, retrying")
retries++
delay(100)
continue
}
Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}")
...
```
- [ ] **Step 3: Compileer**
```bash
./gradlew assembleDebug
```
- [ ] **Step 4: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "fix: check writeCharacteristic return value in sendOtaData"
```
---
### Task 5: otaStatusText widget verwijderen
**Files:**
- Modify: `android/app/src/main/res/layout/activity_settings.xml:39-47`
- [ ] **Step 1: Verwijder `otaStatusText` uit layout**
In `activity_settings.xml`, verwijder regels 39-47 (de `TextView` met id `otaStatusText`).
- [ ] **Step 2: Compileer en verifieer dat er geen referenties meer zijn**
```bash
./gradlew assembleDebug
```
Bevestig met grep dat er nergens meer `otaStatusText` voorkomt:
```bash
grep -r "otaStatusText" android/
```
Output moet leeg zijn.
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/res/layout/activity_settings.xml
git commit -m "chore: remove unused otaStatusText widget"
```
---
### Task 6: Eindverificatie — alles compileren en OTA testen
- [ ] **Step 1: ESP firmware compileren**
```bash
pio run
```
Expected: SUCCESS
- [ ] **Step 2: Android app compileren**
```bash
./gradlew assembleDebug
```
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: ESP flashen via USB**
```bash
pio run -t upload
```
- [ ] **Step 4: OTA testen via BLE**
1. Open app op telefoon, verbind met SylDa_Server
2. Ga naar Settings, selecteer Bluetooth
3. Klik "Module bijwerken"
4. Verify: progress bar vult, geen foutmeldingen
5. Verify: ESP reboot en toont v5 in versie
- [ ] **Step 5: Commit firmware.bin (indien build hash veranderd)**
```bash
cp .pio/build/esp32-s3-devkitc-1/firmware.bin android/app/src/main/assets/firmware/firmware.bin
git add android/app/src/main/assets/firmware/firmware.bin
git commit -m "chore: update bundled firmware.bin"
```

View file

@ -1,552 +0,0 @@
# Release Build & Forgejo Publishing — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Signed APK + AAB release builds, published as Forgejo release with firmware attachments.
**Architecture:** A `signing.properties` file (gitignored) provides Gradle with keystore credentials. A `scripts/release.sh` script builds APK and AAB, collects firmware, tags git, and uploads everything to Forgejo via HTTP API.
**Tech Stack:** Gradle 8.14, AGP 8.5.2, keytool, curl, Forgejo API v1
---
### Pre-check: BUILD_NUMBER timestamp
`buildConfigField "String", "BUILD_NUMBER", ..."` staat in `defaultConfig` in `build.gradle`, maar `BUILD_NUMBER` wordt nergens in de Kotlin code gebruikt. Geen impact op release build reproduceerbaarheid. Het veld blijft bestaan (geen actie nodig).
---
### Task 1: Genereer keystore en sla op in Obsidian vault
**Files:**
- Create: `android/keystore/sylda-release.jks` (gitignored, later)
- Create: Obsidian vault notitie met credentials
- [ ] **Step 1: Genereer keystore in workspace**
Run vanaf de project root:
```bash
keytool -genkey -v \
-keystore android/keystore/sylda-release.jks \
-alias sylda \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass "SyldaRel34se2026!" \
-keypass "SyldaRel34se2026!" \
-dname "CN=Eddy Devink, OU=SylDa, O=SylDa, L=Unknown, ST=Unknown, C=NL"
```
Verify:
```bash
keytool -list -v -keystore android/keystore/sylda-release.jks -storepass "SyldaRel34se2026!" | head -10
```
Expected: Alias `sylda`, certificate details.
- [ ] **Step 2: Bewaar in Obsidian vault**
Maak notitie `Homelab/Project_SylDa/keystore.md` via `obsidian-vault` skill of directe CouchDB API met:
```
# SylDa Release Keystore
- Path workspace: android/keystore/sylda-release.jks
- Alias: sylda
- Key password: SyldaRel34se2026!
- Store password: SyldaRel34se2026!
- Validity: 10000 days (tot ~2053)
- Keyalg: RSA 2048
## Keytool commando (hergeneratie)
keytool -genkey -v -keystore android/keystore/sylda-release.jks -alias sylda -keyalg RSA -keysize 2048 -validity 10000 -storepass "SyldaRel34se2026!" -keypass "SyldaRel34se2026!" -dname "CN=Eddy Devink, OU=SylDa, O=SylDa, L=Unknown, ST=Unknown, C=NL"
```
- [ ] **Step 3: Commit (keystore wordt later gitignored in Task 4 — nu nog niet gecommit)**
Geen git actie nog. Task 4 voegt gitignore toe.
---
### Task 2: Maak signing.properties
**Files:**
- Create: `android/signing.properties` (gitignored in Task 4)
- [ ] **Step 1: Creëer signing.properties**
Run:
```bash
cat > android/signing.properties << 'EOF'
KEYSTORE_PATH=keystore/sylda-release.jks
KEYSTORE_PASSWORD=SyldaRel34se2026!
KEY_ALIAS=sylda
KEY_PASSWORD=SyldaRel34se2026!
EOF
```
- [ ] **Step 2: Verifieer bestand**
```bash
cat android/signing.properties
```
Expected: 4 properties zoals hierboven.
---
### Task 3: Update build.gradle met signing config
**Files:**
- Modify: `android/app/build.gradle` (hele bestand overschrijven)
- [ ] **Step 1: Voeg signing block toe**
Huidig bestand (`android/app/build.gradle`):
```groovy
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'nl.eddydevink.sylda'
compileSdk 34
defaultConfig {
applicationId "nl.eddydevink.sylda"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
buildConfigField "String", "BUILD_NUMBER", "\"${new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date())}\""
}
buildFeatures {
buildConfig true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'io.github.dkaukov:esp32-flash-lib:1.1.17'
implementation 'com.eclipsesource.minimal-json:minimal-json:0.9.5'
implementation 'org.slf4j:slf4j-nop:2.0.16'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
}
```
Vervangen door (signing block toegevoegd na `buildTypes {}`):
```groovy
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
def signingPropsFile = rootProject.file('signing.properties')
def hasSigningConfig = signingPropsFile.exists()
android {
namespace 'nl.eddydevink.sylda'
compileSdk 34
if (hasSigningConfig) {
def props = new Properties()
props.load(new FileInputStream(signingPropsFile))
signingConfigs {
release {
storeFile rootProject.file(props['KEYSTORE_PATH'])
storePassword props['KEYSTORE_PASSWORD']
keyAlias props['KEY_ALIAS']
keyPassword props['KEY_PASSWORD']
}
}
}
defaultConfig {
applicationId "nl.eddydevink.sylda"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
buildConfigField "String", "BUILD_NUMBER", "\"${new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date())}\""
}
buildFeatures {
buildConfig true
}
buildTypes {
release {
if (hasSigningConfig) {
signingConfig signingConfigs.release
}
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'io.github.dkaukov:esp32-flash-lib:1.1.17'
implementation 'com.eclipsesource.minimal-json:minimal-json:0.9.5'
implementation 'org.slf4j:slf4j-nop:2.0.16'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
}
```
- [ ] **Step 2: Sync Gradle**
```bash
./gradlew --no-daemon dependencies > /dev/null 2>&1
echo $?
```
Expected: `0` (geen Gradle-fouten).
- [ ] **Step 3: Commit**
```bash
git add android/app/build.gradle
git commit -m "feat: add release signing config via signing.properties"
```
---
### Task 4: Update .gitignore
**Files:**
- Modify: `.gitignore` (toevoegen van nieuwe ignore-regels)
- [ ] **Step 1: Voeg keystore en signing.properties toe aan .gitignore**
Toevoegen aan `.gitignore`, onder de Android/Gradle sectie:
```
# Android signing (contains secrets)
android/keystore/
android/signing.properties
```
- [ ] **Step 2: Verifieer dat keystore nu gitignored is**
```bash
git status android/keystore/ android/signing.properties
```
Expected: geen output (bestanden worden genegeerd).
- [ ] **Step 3: Commit**
```bash
git add .gitignore
git commit -m "chore: gitignore keystore and signing.properties"
```
---
### Task 5: Maak release script
**Files:**
- Create: `scripts/release.sh`
- [ ] **Step 1: Creëer scripts/ director**
```bash
mkdir -p scripts
```
- [ ] **Step 2: Schrijf release.sh**
```bash
cat > scripts/release.sh << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
log() { echo -e "${GREEN}[release]${NC} $1"; }
err() { echo -e "${RED}[release]${NC} $1" >&2; }
# ------------------------------------------------------------------ config
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ANDROID_DIR="$PROJECT_DIR/android"
APP_BUILD_GRADLE="$ANDROID_DIR/app/build.gradle"
SIGNING_PROPS="$ANDROID_DIR/signing.properties"
FORGEJO_URL="https://git.eddydevink.nl/api/v1/repos/eddy/Project_SylDa"
FIRMWARE_DIR="$ANDROID_DIR/app/src/main/assets/firmware"
# lees versionName uit build.gradle
VERSION=$(grep -oP 'versionName\s+"\K[^"]+' "$APP_BUILD_GRADLE")
TAG="v$VERSION"
RELEASE_DIR="$PROJECT_DIR/release/$TAG"
log "Release v$VERSION (tag $TAG)"
# ------------------------------------------------------------------ checks
if [ ! -f "$SIGNING_PROPS" ]; then
err "signing.properties niet gevonden op $SIGNING_PROPS"
exit 1
fi
# ------------------------------------------------------------------ clean
log "Clean..."
cd "$ANDROID_DIR"
./gradlew clean
# ------------------------------------------------------------------ build
log "Build APK + AAB..."
./gradlew assembleRelease bundleRelease
# ------------------------------------------------------------------ verzamel artefacten
log "Verzamel artefacten in $RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
cp "$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" "$RELEASE_DIR/"
cp "$ANDROID_DIR/app/build/outputs/bundle/release/app-release.aab" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/bootloader.bin" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/partitions.bin" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/boot_app0.bin" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/firmware.bin" "$RELEASE_DIR/"
# ------------------------------------------------------------------ checksums
log "Checksums..."
cd "$RELEASE_DIR"
sha256sum * > checksums.txt
cat checksums.txt
# ------------------------------------------------------------------ git tag
cd "$PROJECT_DIR"
if git rev-parse "$TAG" >/dev/null 2>&1; then
log "Tag $TAG bestaat al, skip git tag."
else
log "Git tag $TAG..."
git tag "$TAG"
git push origin "$TAG"
fi
# ------------------------------------------------------------------ Forgejo release
log "Forgejo release aanmaken..."
TOKEN_FILE="$HOME/.config/sylda/forgejo-token"
if [ ! -f "$TOKEN_FILE" ]; then
err "Forgejo token niet gevonden op $TOKEN_FILE"
err "Genereer een token in Forgejo: Settings > Applications > Generate Token (scope: write:repository)"
err "Sla op als: echo 'je-token' > $TOKEN_FILE"
exit 1
fi
TOKEN=$(cat "$TOKEN_FILE")
# maak release aan
RELEASE_RESP=$(curl -s -X POST "$FORGEJO_URL/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"SylDa Android app + firmware\", \"draft\": false, \"prerelease\": false}")
RELEASE_ID=$(echo "$RELEASE_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
err "Release aanmaken mislukt. Response:"
echo "$RELEASE_RESP"
exit 1
fi
log "Release ID: $RELEASE_ID"
# upload artefacten
for f in *; do
log " Upload $f..."
curl -s -X POST "$FORGEJO_URL/releases/$RELEASE_ID/assets?name=$f" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$f" > /dev/null
done
log "Klaar! Release $TAG: $FORGEJO_URL/releases/tag/$TAG (vervang api/v1/repos door web URL)"
SCRIPT
chmod +x scripts/release.sh
```
- [ ] **Step 3: Verifieer script syntax**
```bash
bash -n scripts/release.sh
echo $?
```
Expected: `0`.
- [ ] **Step 4: Commit**
```bash
git add scripts/release.sh
git commit -m "feat: add release build script"
```
---
### Task 6: Droogtest build
**Files:**
- Geen wijzigingen — verifieer dat release build werkt
- [ ] **Step 1: Controleer dat keystore en signing.properties aanwezig zijn**
```bash
ls -la android/keystore/sylda-release.jks android/signing.properties
```
- [ ] **Step 2: Build release APK + AAB**
```bash
./gradlew assembleRelease bundleRelease
```
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 3: Verifieer ondertekening APK**
```bash
jarsigner -verify -verbose -certs android/app/build/outputs/apk/release/app-release.apk 2>&1 | grep -E "CN=|jar verified"
```
Expected melding: `CN=Eddy Devink` en `jar verified`.
- [ ] **Step 4: Verifieer ondertekening AAB**
```bash
jarsigner -verify -verbose -certs android/app/build/outputs/bundle/release/app-release.aab 2>&1 | grep -E "CN=|jar verified"
```
Expected melding: `CN=Eddy Devink` en `jar verified`.
---
### Task 7: Maak Forgejo access token aan
**Files:**
- Create: `~/.config/sylda/forgejo-token` (lokaal configuratiebestand, niet in repo)
- [ ] **Step 1: Maak token in Forgejo web UI**
Navigeer in browser naar: `https://git.eddydevink.nl/user/settings/applications`
- Klik "Generate Token"
- Token name: `syl-release`
- Scope: vink `write:repository` aan
- Klik "Generate"
Kopieer de gegenereerde token.
- [ ] **Step 2: Sla token op**
```bash
mkdir -p ~/.config/sylda
echo "<gekopieerde-token>" > ~/.config/sylda/forgejo-token
chmod 600 ~/.config/sylda/forgejo-token
```
- [ ] **Step 3: Sla token op in Obsidian vault**
Maak notitie `Homelab/Project_SylDa/forgejo-token.md` met:
```
# Forgejo API Token — syl-release
Token voor release uploads. Scope: write:repository.
Gegenereerd op: <datum>
In: Forgejo web UI → Settings → Applications
Token: <token>
Bestand: ~/.config/sylda/forgejo-token (chmod 600)
```
- [ ] **Step 4: Verifieer token werkt**
```bash
ssh aiaiaiaiagent@nextcloud 'curl -s http://localhost:3000/api/v1/user -H "Authorization: token '"$(cat ~/.config/sylda/forgejo-token)"'"' | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("login","FAIL"))'
```
Expected: `eddy` (de gebruikersnaam in Forgejo).
---
### Task 8: Draai volledige release
**Files:**
- Geen wijzigingen — voert het script uit
- [ ] **Step 1: Check git status (schoon)**
```bash
git status
```
Expected: `nothing to commit, working tree clean`
- [ ] **Step 2: Draai release script**
```bash
./scripts/release.sh
```
- [ ] **Step 3: Verifieer release op Forgejo**
```bash
ssh aiaiaiaiagent@nextcloud 'curl -s http://localhost:3000/api/v1/repos/eddy/Project_SylDa/releases/tags/v1.0' | python3 -c 'import sys,json; r=json.load(sys.stdin); print(f"Tag: {r[\"tag_name\"]}, Assets: {len(r[\"assets\"])}"); [print(f" - {a[\"name\"]} ({a[\"size\"]} bytes)") for a in r["assets"]]'
```
Expected: Tag `v1.0`, 6 assets (APK, AAB, 4 firmware bins, checksums.txt).
- [ ] **Step 4: Open browser ter verificatie**
Open `https://git.eddydevink.nl/eddy/Project_SylDa/releases` en controleer dat release `v1.0` zichtbaar is met alle attachments.
---
### Post-release: Cleanup
Release artefacten in `release/v1.0/` worden niet gecommit (`.gitignore` sluit `*.apk` en `*.aab` al uit). Na release draaien kan deze directory verwijderd worden:
```bash
rm -rf release/
```

View file

@ -1,380 +0,0 @@
# HS-S43A Touch Migration + S3R Cleanup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove internal pullups for HS-S43A touch sensors (invert logic), and purge all obsolete "Atom S3R Voice" references from code and docs.
**Architecture:** Two firmware files get identical pinMode+logic changes. Six documentation/config files get S3R→S3 Lite corrections. Compile verification via `pio run` for both environments.
**Tech Stack:** C++/Arduino, PlatformIO, Markdown docs, Bash scripts, YAML CI
---
### Task 1: Firmware — remove pullups and invert touch logic
**Files:**
- Modify: `src/main.cpp:400-401,561-562`
- Modify: `src/client.cpp:220-221,349-350`
- [ ] **Step 1: Change pinMode from INPUT_PULLUP to INPUT in main.cpp**
In `src/main.cpp`, change lines 400-401:
```
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
```
→
```
pinMode(TOUCH1_PIN, INPUT);
pinMode(TOUCH2_PIN, INPUT);
```
- [ ] **Step 2: Change pinMode from INPUT_PULLUP to INPUT in client.cpp**
In `src/client.cpp`, change lines 220-221:
```
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
```
→
```
pinMode(TOUCH1_PIN, INPUT);
pinMode(TOUCH2_PIN, INPUT);
```
- [ ] **Step 3: Invert touch read logic in main.cpp**
In `src/main.cpp`, change lines 561-562:
```
bool t1Raw = (digitalRead(TOUCH1_PIN) == LOW);
bool t2Raw = (digitalRead(TOUCH2_PIN) == LOW);
```
→
```
bool t1Raw = (digitalRead(TOUCH1_PIN) == HIGH);
bool t2Raw = (digitalRead(TOUCH2_PIN) == HIGH);
```
- [ ] **Step 4: Invert touch read logic in client.cpp**
In `src/client.cpp`, change lines 349-350:
```
bool t1Raw = (digitalRead(TOUCH1_PIN) == LOW);
bool t2Raw = (digitalRead(TOUCH2_PIN) == LOW);
```
→
```
bool t1Raw = (digitalRead(TOUCH1_PIN) == HIGH);
bool t2Raw = (digitalRead(TOUCH2_PIN) == HIGH);
```
- [ ] **Step 5: Compile both firmware environments**
Run: `pio run`
Expected: SUCCESS for both `s3lite-server` and `s3lite-client`
- [ ] **Step 6: Commit firmware changes**
```bash
git add src/main.cpp src/client.cpp
git commit -m "fix: remove pullups, invert touch logic for HS-S43A sensors"
```
---
### Task 2: AGENTS.md — update board, pins, flash command
**Files:**
- Modify: `AGENTS.md:3,7,8,9,16,23`
- [ ] **Step 1: Update line 3 (project summary)**
```
ESP32-S3-PICO-1-N8R8 (Atom S3R Voice), 2 relais, 2 touch sensors (JR223B), BLE, Android app USB OTG.
```
→
```
M5Stack Atom S3 Lite (ESP32-S3), 2 relais, 2 touch sensors (HS-S43A), BLE, Android app USB OTG.
```
- [ ] **Step 2: Update line 7 (board spec)**
```
- **Board:** Atom S3R Voice (ESP32-S3-PICO-1-N8R8), 16MB OPI flash
```
→
```
- **Board:** M5Stack Atom S3 Lite (ESP32-S3)
```
- [ ] **Step 3: Update line 8 (pin definitions)**
```
- **Pinnen:** G1=RELAY1, G2=RELAY2, G8+pullup=TOUCH1, G6+pullup=TOUCH2, G41=BUTTON (active low), G46=LED
```
→
```
- **Pinnen:** G1=RELAY1, G2=RELAY2, G8=TOUCH1, G6=TOUCH2, G41=BUTTON (active low), G46=LED
```
- [ ] **Step 4: Update line 9 (touch sensor type)**
```
- **Touch:** DC3-24V Capacitieve Touch Schakelaar JR223B (GEEN TTP223). Minder C op punt A = gevoeliger = meer false triggers.
```
→
```
- **Touch:** HS-S43A Digital Capacitive Switch Module (epenelab.io), actief HIGH, geen pullup nodig.
```
- [ ] **Step 5: Update line 16 (workspace description)**
```
`/home/eddy/src/Project_SylDa/` — S3R firmware + Android app
```
→
```
`/home/eddy/src/Project_SylDa/` — Atom S3 Lite firmware (server + client) + Android app
```
- [ ] **Step 6: Update line 23 (manual flash command)**
Replace the entire line 23 (manual flash command) to use s3lite-server build dir:
```
- **Direct flashen (handmatig):** `~/.platformio/packages/tool-esptoolpy/esptool.py --no-stub --chip esp32s3 --port /dev/ttyACM1 --baud 115200 --before default_reset --after hard_reset write_flash --flash_mode keep --flash_size keep 0x0 .pio/build/s3lite-server/bootloader.bin 0x8000 .pio/build/s3lite-server/partitions.bin 0xE000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin 0x10000 .pio/build/s3lite-server/firmware.bin`
```
- [ ] **Step 7: Commit**
```bash
git add AGENTS.md
git commit -m "docs: update AGENTS.md for S3 Lite + HS-S43A touch sensors"
```
---
### Task 3: README.md — update all S3R references and touch sensor docs
**Files:**
- Modify: `README.md:5,13,17,18,24,27,28,45,134,138,140,184,197,380`
- [ ] **Step 1: Update line 5 (project description)**
```
BLE-gestuurde relais-switch voor de Atom S3R Voice (ESP32-S3-PICO-1-N8R8, 16MB OPI flash) met een bijbehorende Android-app. De relais kunnen worden bediend via capacitieve touch-sensoren (JR223B) óf via Bluetooth Low Energy vanaf een Android-telefoon.
```
→
```
BLE-gestuurde relais-switch voor de M5Stack Atom S3 Lite (ESP32-S3) met een bijbehorende Android-app. De relais kunnen worden bediend via capacitieve touch-sensoren (HS-S43A) óf via Bluetooth Low Energy vanaf een Android-telefoon.
```
- [ ] **Step 2: Update line 13 (board spec table)**
```
| Board | Atom S3R Voice (ESP32-S3-PICO-1-N8R8) |
```
→
```
| Board | M5Stack Atom S3 Lite (ESP32-S3) |
```
- [ ] **Step 3: Remove line 14 (SoC row — ESP32-S3-PICO-1-N8R8 detail)**
Delete line 14: `| SoC | ESP32-S3-PICO-1-N8R8 (8MB Flash, 8MB PSRAM, 16MB OPI flash) |`
- [ ] **Step 4: Update lines 17-18 (touch sensor table rows)**
```
| Touch sensor 1 | DC3-24V Capacitieve Touch Schakelaar JR223B op **G8** (interne pullup) |
| Touch sensor 2 | DC3-24V Capacitieve Touch Schakelaar JR223B op **G6** (interne pullup) |
```
→
```
| Touch sensor 1 | HS-S43A Digital Capacitive Switch Module op **G8** (actief HIGH) |
| Touch sensor 2 | HS-S43A Digital Capacitive Switch Module op **G6** (actief HIGH) |
```
- [ ] **Step 5: Update pinout diagram (lines 24-31)**
```
G6 = TOUCH2 (INPUT_PULLUP)
G8 = TOUCH1 (INPUT_PULLUP)
```
→
```
G6 = TOUCH2 (INPUT)
G8 = TOUCH1 (INPUT)
```
- [ ] **Step 6: Update line 44-45 (JR223B note)**
Replace the JR223B note block:
```
> **JR223B touch sensor:** Minder C op punt A = gevoeliger = meer false triggers. Bij lange draden of elektrische ruis een externe pullup plaatsen.
```
→
```
> **HS-S43A touch sensor:** Digitale output (actief HIGH). Geen pullup nodig — de sensor geeft actief 0V of 3.3V.
```
- [ ] **Step 7: Update relais logica tabel (lines 134, 138, 140)**
```
| Relais 1 | Touch sensor 1 | G8 = LOW (aanraken) | Relais 1 aan |
```
→
```
| Relais 1 | Touch sensor 1 | G8 = HIGH (aanraken) | Relais 1 aan |
```
```
| Relais 2 | Touch sensor 2 | G6 = LOW (aanraken) | Relais 2 aan |
```
→
```
| Relais 2 | Touch sensor 2 | G6 = HIGH (aanraken) | Relais 2 aan |
```
- [ ] **Step 8: Update serial monitor examples (lines 184, 197)**
```
=== Atom VoiceS3R BLE Relay ===
```
→ (both occurrences)
```
=== SylDa BLE Relay ===
```
- [ ] **Step 9: Update line 380 (technical notes)**
```
- De Atom S3R Voice gebruikt de ES8311 audio codec op G45/G0/G48/G4/G3/G17/G11, IR TX op G47, en de knop op G41. Deze pinnen zijn niet beschikbaar voor eigen gebruik (behalve G41 als input).
```
→
```
- De Atom S3 Lite heeft GPIO G1, G2, G5-G8, G38, G39 beschikbaar op de headers. G41 is de interne knop (active low). G46 is de interne LED.
```
- [ ] **Step 10: Commit**
```bash
git add README.md
git commit -m "docs: update README for S3 Lite + HS-S43A, remove S3R references"
```
---
### Task 4: CI and tools — fix build output paths
**Files:**
- Modify: `.forgejo/workflows/ci.yml:25-27`
- Modify: `tools/copy_firmware.sh:4`
- [ ] **Step 1: Fix ci.yml build paths**
In `.forgejo/workflows/ci.yml`, change lines 25-27:
```
cp .pio/build/m5stack-atoms3r/firmware.bin /tmp/ci-artifacts/firmware/
cp .pio/build/m5stack-atoms3r/bootloader.bin /tmp/ci-artifacts/firmware/
cp .pio/build/m5stack-atoms3r/partitions.bin /tmp/ci-artifacts/firmware/
```
→
```
cp .pio/build/s3lite-server/firmware.bin /tmp/ci-artifacts/firmware/
cp .pio/build/s3lite-server/bootloader.bin /tmp/ci-artifacts/firmware/
cp .pio/build/s3lite-server/partitions.bin /tmp/ci-artifacts/firmware/
```
- [ ] **Step 2: Fix copy_firmware.sh build path**
In `tools/copy_firmware.sh`, change line 4:
```
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/m5stack-atoms3r"
```
→
```
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/s3lite-server"
```
- [ ] **Step 3: Commit**
```bash
git add .forgejo/workflows/ci.yml tools/copy_firmware.sh
git commit -m "fix: update build paths m5stack-atoms3r → s3lite-server"
```
---
### Task 5: Historical docs — purge S3R references
**Files:**
- Modify: `docs/superpowers/specs/2026-07-28-esp32-pico-d4-port-design.md`
- Modify: `docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md`
- Modify: `docs/superpowers/plans/2026-07-30-ble-ota.md`
- Modify: `docs/superpowers/plans/2025-07-28-esp32-pico-d4-port.md`
- Modify: `docs/superpowers/plans/2026-07-28-esp32-pico-d4-port.md`
- [ ] **Step 1: Fix `docs/superpowers/specs/2026-07-28-esp32-pico-d4-port-design.md`**
S3R references in this file (lines 3, 7, 48, 50, 116):
- Line 3: `Atom VoiceS3R` → `Atom S3 Lite`
- Line 7: `Atom VoiceS3R with ESP32-S3-PICO-1-N8R8 (8MB Flash, 8MB PSRAM)` → `Atom S3 Lite (ESP32-S3)`
- Line 48: BLE device name row — keep as-is (it documents the rename from S3R), no change needed
- Line 50: `"Atom VoiceS3R BLE Relay"` → keep as-is (documents the rename)
- Line 116: `m5stack-atoms3r` → `s3lite-server`
- [ ] **Step 2: Fix `docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md`**
Line 115: `S3R pinmap + 1-client code` → `old pinmap + 1-client code`
- [ ] **Step 3: Fix `docs/superpowers/plans/2026-07-30-ble-ota.md`**
Line 331: `m5stack-atoms3r` → `s3lite-server`
- [ ] **Step 4: Fix `docs/superpowers/plans/2025-07-28-esp32-pico-d4-port.md`**
Line 170: `"AtomS3R-Relay"` → keep as-is (documents the old name)
- [ ] **Step 5: Fix `docs/superpowers/plans/2026-07-28-esp32-pico-d4-port.md`**
Line 140: `"=== Atom VoiceS3R BLE Relay ==="` → keep as-is (documents old serial banner)
Line 150: `"AtomS3R-Relay"` → keep as-is (documents old BLE name)
Line 160: `'AtomS3R-Relay'` → keep as-is (documents old BLE name)
Line 168: `S3-specific or AtomS3R references` → `S3-specific references`
Line 171: `"S3R\|S3\|Atom Voice\|46\|G38\|G1\b"` → `"S3\|Atom Voice\|46\|G38\|G1\b"`
Line 195: `m5stack-atoms3r` → `s3lite-server`
- [ ] **Step 6: Commit**
```bash
git add docs/superpowers/
git commit -m "docs: purge S3R references from historical design docs and plans"
```
---
### Task 6: Final verification
- [ ] **Step 1: Verify no remaining S3R references**
Run: `grep -rPn '(?i)s3r|atoms3r|m5stack-atoms3r|Atom S3R|VoiceS3R' --include='*.md' --include='*.cpp' --include='*.yml' --include='*.sh' .`
Expected: no output
- [ ] **Step 2: Verify firmware compiles**
Run: `pio run`
Expected: SUCCESS for both environments
- [ ] **Step 3: Commit design doc and spec**
```bash
git add docs/superpowers/specs/2026-08-01-hs-s43a-touch-migration-design.md docs/superpowers/plans/2026-08-01-hs-s43a-touch-migration.md
git commit -m "docs: add design spec and implementation plan for HS-S43A migration"
```

View file

@ -1,292 +0,0 @@
# LED Stable Connection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Uniform LED signaling for server and client — LED acts as debug indicator, turns off after 60s stable connection.
**Architecture:** Shared LED state machine: OTA overrides all, then relay-flash overlay, then connection-status base color (Red=disconnected, Yellow=connected<60s, Off=stable>=60s). Both firmwares use identical logic.
**Tech Stack:** C++/Arduino, FastLED, NimBLE, ESP32-S3 (PlatformIO)
---
## File Map
| File | Action | Purpose |
|------|--------|---------|
| `src/main.cpp` | Modify | Server LED logic, new globals, remove setup blinks |
| `src/client.cpp` | Modify | Client LED logic, new globals, OTA placeholders |
| `src/version.h` | Modify | Version bumps (server 15→16, client 1→2) |
---
### Task 1: Server LED logic
**Files:**
- Modify: `src/main.cpp`
- [ ] **Step 1: Add new global variables after line 56 (after `int connectedCount = 0;`)**
```cpp
#define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0;
```
- [ ] **Step 2: Remove `ledOtaBusy()` function (lines 181-183)**
Delete this function — it will be inlined in the new LED logic:
```cpp
static void ledOtaBusy() {
leds[0] = (millis() / 100) % 2 ? CRGB::Green : CRGB::Black;
}
```
Remove all 3 lines.
- [ ] **Step 3: Replace setup LED blinks (lines 414-422) with solid red**
Replace:
```cpp
for (int i = 0; i < 3; i++) {
leds[0] = CRGB::Green;
FastLED.show();
delay(200);
leds[0] = CRGB::Black;
FastLED.show();
delay(200);
}
Serial.println("LED blink done, starting BLE...");
```
With:
```cpp
leds[0] = CRGB::Red;
FastLED.show();
Serial.println("LED red (no connection), starting BLE...");
```
- [ ] **Step 4: Remove `leds[0] = CRGB::Yellow;` from otaReady handler (line 553)**
In the otaReady block (lines 542-558), remove line 553 `leds[0] = CRGB::Yellow;`. The wait-for-confirm LED is now handled by the main LED logic checking `otaInProgress && otaEnded`.
- [ ] **Step 5: Replace the LED block in loop() (lines 599-615) with new logic**
Replace lines 599-615:
```cpp
if (!otaInProgress) {
relay1State = t1Active || btnActive ||
(connActive[0] && bleRelay1[0]) || (connActive[1] && bleRelay1[1]);
relay2State = t2Active ||
(connActive[0] && bleRelay2[0]) || (connActive[1] && bleRelay2[1]);
leds[0] = (connectedCount > 0) ? CRGB::Green : CRGB::Black;
} else {
relay1State = false;
relay2State = false;
ledOtaBusy();
}
digitalWrite(RELAY1_PIN, relay1State ? HIGH : LOW);
digitalWrite(RELAY2_PIN, relay2State ? HIGH : LOW);
FastLED.show();
```
With:
```cpp
if (!otaInProgress) {
relay1State = t1Active || btnActive ||
(connActive[0] && bleRelay1[0]) || (connActive[1] && bleRelay1[1]);
relay2State = t2Active ||
(connActive[0] && bleRelay2[0]) || (connActive[1] && bleRelay2[1]);
} else {
relay1State = false;
relay2State = false;
}
// --- LED logic ---
CRGB ledColor;
if (otaInProgress) {
ledColor = otaEnded
? CRGB::Yellow
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);
} else {
if (connectedCount > 0) {
if (connectionStableMs == 0) connectionStableMs = millis();
} else {
connectionStableMs = 0;
}
if (connectedCount > 0) {
ledColor = (millis() - connectionStableMs >= STABLE_LED_TIMEOUT_MS)
? CRGB::Black
: CRGB::Yellow;
} else {
ledColor = CRGB::Red;
}
static bool lastRelayActive = false;
bool relayActive = relay1State || relay2State;
if (relayActive != lastRelayActive) {
relayFlashUntil = millis() + 150;
}
lastRelayActive = relayActive;
if (millis() < relayFlashUntil) {
ledColor = CRGB::Green;
}
}
leds[0] = ledColor;
digitalWrite(RELAY1_PIN, relay1State ? HIGH : LOW);
digitalWrite(RELAY2_PIN, relay2State ? HIGH : LOW);
FastLED.show();
```
- [ ] **Step 6: Commit server changes**
```bash
git add src/main.cpp
git commit -m "feat(server): uniform LED signaling — off after 60s stable connection"
```
---
### Task 2: Client LED logic
**Files:**
- Modify: `src/client.cpp`
- [ ] **Step 1: Add new global variables after line 48 (after `static uint32_t seq = 0;`)**
```cpp
#define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0;
bool otaInProgress = false;
bool otaEnded = false;
```
- [ ] **Step 2: Replace the LED block in loop() (lines 368-375) with new logic**
Replace lines 368-375:
```cpp
if (relay1State || relay2State) {
leds[0] = CRGB::Green;
} else if (connected) {
leds[0] = CRGB::Blue;
} else {
leds[0] = CRGB::Red;
}
FastLED.show();
```
With:
```cpp
// --- LED logic ---
CRGB ledColor;
if (otaInProgress) {
ledColor = otaEnded
? CRGB::Yellow
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);
} else {
if (connected) {
if (connectionStableMs == 0) connectionStableMs = millis();
} else {
connectionStableMs = 0;
}
if (connected) {
ledColor = (millis() - connectionStableMs >= STABLE_LED_TIMEOUT_MS)
? CRGB::Black
: CRGB::Yellow;
} else {
ledColor = CRGB::Red;
}
static bool lastRelayActive = false;
bool relayActive = relay1State || relay2State;
if (relayActive != lastRelayActive) {
relayFlashUntil = millis() + 150;
}
lastRelayActive = relayActive;
if (millis() < relayFlashUntil) {
ledColor = CRGB::Green;
}
}
leds[0] = ledColor;
FastLED.show();
```
- [ ] **Step 3: Commit client changes**
```bash
git add src/client.cpp
git commit -m "feat(client): uniform LED signaling — off after 60s stable connection"
```
---
### Task 3: Version bumps
**Files:**
- Modify: `src/version.h`
- [ ] **Step 1: Bump versions**
Replace:
```cpp
#define FIRMWARE_VERSION_SERVER "15"
#define FIRMWARE_VERSION_CLIENT "1"
```
With:
```cpp
#define FIRMWARE_VERSION_SERVER "16"
#define FIRMWARE_VERSION_CLIENT "2"
```
- [ ] **Step 2: Commit version bump**
```bash
git add src/version.h
git commit -m "chore: bump server v16, client v2"
```
---
### Task 4: Build verification
**Files:**
- None (build only)
- [ ] **Step 1: Build server firmware**
Run: `pio run -e s3lite-server`
Expected: SUCCESS, no errors.
- [ ] **Step 2: Build client firmware**
Run: `pio run -e s3lite-client`
Expected: SUCCESS, no errors.
- [ ] **Step 3: Verify both targets compile cleanly**
Run: `pio run`
Expected: Both environments compile SUCCESS.

View file

@ -1,181 +0,0 @@
# Auto-detect Upload Port Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix auto-detect upload port failure when active log output interferes with ID? response detection.
**Architecture:** Two-part fix — script-side filtering continues reading until ID match, firmware-side serial mute pauses all output during ID? response.
**Tech Stack:** Python 3 + pyserial (script), ESP32-S3 + Arduino + ESP-IDF logging (firmware)
---
## File Structure
| File | Action | Purpose |
|------|--------|---------|
| `tools/pio_upload_port.py` | Modify | Filter ID responses in query_id() |
| `src/main.cpp` | Modify | Add serial mute for server |
| `src/client.cpp` | Modify | Add serial mute for client |
---
### Task 1: Script-side ID filtering
**Files:**
- Modify: `tools/pio_upload_port.py:46-61`
- [ ] **Step 1: Update query_id() to filter for ID response**
Replace lines 46-61 in `tools/pio_upload_port.py`:
```python
def query_id(port, timeout=5):
try:
ser = serial.Serial(port, 115200, timeout=0.5)
time.sleep(0.1)
ser.reset_input_buffer()
ser.write(b"ID?\n")
deadline = time.time() + timeout
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if "SylDa_Server" in line or "SylDa_Client" in line:
ser.close()
return line
ser.close()
except (serial.SerialException, OSError):
pass
return None
```
- [ ] **Step 2: Commit**
```bash
git add tools/pio_upload_port.py
git commit -m "fix(script): filter ID response in query_id()"
```
---
### Task 2: Server firmware serial mute
**Files:**
- Modify: `src/main.cpp`
- [ ] **Step 1: Add serialMuted flag and mute helper**
After line 17 (`static const char *TAG = "server";`), add:
```cpp
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", CONFIG_LOG_DEFAULT_LEVEL);
}
```
- [ ] **Step 2: Update ID? handler to use mute**
Replace lines 591-592:
```cpp
} else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Server");
delay(50);
unmuteSerial();
```
- [ ] **Step 3: Commit**
```bash
git add src/main.cpp
git commit -m "fix(server): add serial mute during ID? response"
```
---
### Task 3: Client firmware serial mute
**Files:**
- Modify: `src/client.cpp`
- [ ] **Step 1: Add serialMuted flag and mute helper**
After line 15 (`static const char *TAG = "client";`), add:
```cpp
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", CONFIG_LOG_DEFAULT_LEVEL);
}
```
- [ ] **Step 2: Update ID? handler to use mute**
Replace lines 425-426:
```cpp
} else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Client");
delay(50);
unmuteSerial();
```
- [ ] **Step 3: Commit**
```bash
git add src/client.cpp
git commit -m "fix(client): add serial mute during ID? response"
```
---
### Task 4: Build and verify
- [ ] **Step 1: Build both environments**
```bash
pio run -e s3lite-server
pio run -e s3lite-client
```
Expected: Both compile without errors.
- [ ] **Step 2: Verify firmware version bump**
Check `version.txt` exists with correct format.
- [ ] **Step 3: Push branch**
```bash
git push -u origin fix/issue-16-auto-detect-port
```
---
## Testing Checklist
After implementation, test with actual hardware:
1. Connect both server and client via USB
2. Run `pio run -e s3lite-client -t upload` — should auto-detect client
3. Run `pio run -e s3lite-server -t upload` — should auto-detect server
4. Verify log output resumes after upload completes
5. Test during active ECDH handshake (worst case)

View file

@ -1,400 +0,0 @@
# Client BLE scan-based connection Implementation Plan
> **Voor agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Client verbindt even snel en betrouwbaar als de Android app — via BLE-scan met UUID-prefix matching + ECDH-handshake via de bestaande `connectToServer()` functie.
**Architecture:** Vervang hardcoded MAC-connectie door BLE-scan (service UUID + manufacturer data UUID-prefix), bewaar gevonden server-MAC in NVS voor snelle herconnectie, roep de reeds correcte `connectToServer()` aan voor UUID-verificatie + ECDH.
**Tech Stack:** ESP32-S3, NimBLE-Arduino, Preferences (NVS)
---
## Scope
Één bestand: `src/client.cpp`. Geen nieuwe bestanden.
**Wat verandert er:**
- `setup()`: alleen BLE init, geen direct connect meer
- `scan`-logica: nieuwe statemachine in `loop()` — scannen → gevonden → connectToServer() → herconnectie
- `MyScanCallbacks`: uitgebreid met UUID-prefix matching uit manufacturer data
- NVS: `server_addr` key voor opgeslagen BLE-adres (48-bit MAC als 6 bytes, little-endian in `NimBLEAddress` formaat)
- Dode code: `SCAN_TIMEOUT` define (ongebruikt, lijn 34) en `SCAN_INTERVAL` vervangen door nieuwe constanten
**Wat blijft:**
- `connectToServer()` (lijn 239-293): ongewijzigd, hier gebeurt UUID-verify + ECDH
- `performEcdhHandshake()` (lijn 158-237): ongewijzigd, hier gebeurt de ECDH-handshake
- Alle serial-command handlers, touch-logica, relay-command sending
---
### Task 1: Definieer nieuwe constanten en voeg NVS-key + globals toe
**File:** `src/client.cpp`
- [ ] **Step 1: Vervang ongebruikte constanten en voeg nieuwe toe**
Verwijder `#define SCAN_TIMEOUT 5000` (ongebruikt) en voeg nieuwe constanten toe direct eronder:
```cpp
#define SCAN_INTERVAL 6000
#define SCAN_TIMEOUT 5000 // <-- verwijder deze regel
```
Vervang door:
```cpp
#define SCAN_DURATION_MS 5000 // hoe lang scannen per poging
#define SCAN_INTERVAL_MS 8000 // tijd tussen scanpogingen
#define RECONNECT_DELAY_MS 1000 // wachttijd na disconnect voor herscan
```
- [ ] **Step 2: Voeg NVS-key en globals toe na `#define SCAN_INTERVAL` (lijn 33)**
Voeg toe onder lijn 35 (`#define NVS_SERVER_NAME "server"`):
```cpp
#define NVS_ADDR_NAME "addr"
```
Voeg toe onder `bool allProvisioned = false;` (lijn 42):
```cpp
bool serverAddrStored = false;
NimBLEAddress storedServerAddr = NimBLEAddress("");
```
Voeg toe onder `static bool serverFound = false;` (lijn 63):
```cpp
static bool scanning = false;
static uint32_t scanStarted = 0;
static uint32_t disconnectTime = 0;
```
- [ ] **Step 3: Bouw en verifieer**
```bash
pio run -e s3lite-client
```
Expected: Compileert (gebruikt nog niet, maar geen syntaxfouten).
- [ ] **Step 4: Commit**
```bash
git add src/client.cpp
git commit -m "feat(client): add scan constants, NVS addr key, and state globals"
```
---
### Task 2: Breid MyScanCallbacks uit met UUID-prefix matching
**File:** `src/client.cpp`
- [ ] **Step 1: Vervang MyScanCallbacks (lijn 70-78)**
Vervang de bestaande klasse:
```cpp
class MyScanCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override {
if (scanning && advertisedDevice->isAdvertisingService(NimBLEUUID(SERVICE_UUID))) {
std::string mfgData = advertisedDevice->getManufacturerData();
if (mfgData.length() >= 4) {
if (memcmp(mfgData.data(), deviceUuid, 4) == 0) {
serverAddress = advertisedDevice->getAddress();
serverFound = true;
ESP_LOGI(TAG, "BLE: server found at %s", serverAddress.toString().c_str());
NimBLEDevice::getScan()->stop();
}
}
}
}
};
```
De UUID-prefix check (`memcmp(mfgData.data(), deviceUuid, 4)`) matcht exact met Android's `scanRecord.getManufacturerSpecificData(0xFFFF)` + `advPrefix.contentEquals(storedPrefix)` (BleRelayManager.kt lijn 122-129).
- [ ] **Step 2: Bouw en verifieer**
```bash
pio run -e s3lite-client
```
Expected: Compileert.
- [ ] **Step 3: Commit**
```bash
git add src/client.cpp
git commit -m "feat(client): extend MyScanCallbacks with UUID prefix matching"
```
---
### Task 3: scan + connect statemachine in loop()
**File:** `src/client.cpp`
- [ ] **Step 1: Herschrijf setup() lijn 346-363 — alleen BLE init, geen connect**
Vervang het `if (allProvisioned)` blok in `setup()`:
```cpp
if (allProvisioned) {
NimBLEDevice::init("SylDa_Client");
bleReady = true;
preferences.begin(NVS_NAMESPACE, true);
uint8_t addrBuf[6];
size_t addrLen = preferences.getBytes(NVS_ADDR_NAME, addrBuf, 6);
preferences.end();
if (addrLen == 6) {
storedServerAddr = NimBLEAddress(addrBuf, BLE_ADDR_RANDOM);
serverAddrStored = true;
ESP_LOGI(TAG, "BLE: stored server address %s", storedServerAddr.toString().c_str());
}
ESP_LOGI(TAG, "BLE: initialized, will scan for server");
}
```
- [ ] **Step 2: Voeg scan-start functie toe vóór loop()**
Voeg toe direct vóór `void loop()` (vóór lijn 368):
```cpp
static void startScan() {
if (scanning) return;
NimBLEScan *pScan = NimBLEDevice::getScan();
pScan->setScanCallbacks(new MyScanCallbacks());
pScan->setActiveScan(true);
pScan->setInterval(32);
pScan->setWindow(16);
serverFound = false;
scanning = true;
scanStarted = millis();
pScan->start(SCAN_DURATION_MS / 100, false); // duration in 10ms units
ESP_LOGI(TAG, "BLE: scanning started");
}
```
- [ ] **Step 3: Vervang het reconnect-blok in loop() (lijn 550-568)**
Vervang het `if (!connected && bleReady)` blok met de volledige scan+connect statemachine. Het oude blok (lijn 550-568) en de `statusLog` log (lijn 541-548) worden vervangen:
```cpp
static uint32_t lastScanAttempt = 0;
static uint32_t statusLog = 0;
if (bleReady && millis() - statusLog > 2000) {
ESP_LOGI(TAG, "BLE: connected=%d scanning=%d ecdh=%d serverFound=%d",
connected, scanning, ecdhReady, serverFound);
statusLog = millis();
}
if (!connected && bleReady) {
if (!scanning) {
uint32_t delayNeeded = disconnectTime > 0 ? RECONNECT_DELAY_MS : 0;
if (millis() - lastScanAttempt > SCAN_INTERVAL_MS) {
if (serverAddrStored) {
ESP_LOGI(TAG, "BLE: trying stored address %s", storedServerAddr.toString().c_str());
if (pClient == nullptr) {
pClient = NimBLEDevice::createClient();
pClient->setConnectTimeout(10);
pClient->setClientCallbacks(&clientCallbacks);
}
if (pClient->connect(storedServerAddr)) {
serverAddress = storedServerAddr;
if (connectToServer()) {
connected = true;
ESP_LOGI(TAG, "BLE: connected via stored address");
}
} else {
ESP_LOGI(TAG, "BLE: stored address failed, starting scan");
serverAddrStored = false;
startScan();
}
} else {
startScan();
}
lastScanAttempt = millis();
}
} else {
if (serverFound) {
scanning = false;
ESP_LOGI(TAG, "BLE: connecting to found server at %s", serverAddress.toString().c_str());
if (pClient != nullptr && pClient->isConnected()) {
pClient->disconnect();
delay(100);
}
pClient = NimBLEDevice::createClient();
pClient->setConnectTimeout(10);
pClient->setClientCallbacks(&clientCallbacks);
if (connectToServer()) {
connected = true;
uint8_t addrBuf[6];
memcpy(addrBuf, serverAddress.getNative(), 6);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_ADDR_NAME, addrBuf, 6);
preferences.end();
storedServerAddr = serverAddress;
serverAddrStored = true;
ESP_LOGI(TAG, "BLE: server address stored in NVS");
} else {
ESP_LOGW(TAG, "BLE: connectToServer failed");
}
} else if (millis() - scanStarted > SCAN_DURATION_MS) {
NimBLEDevice::getScan()->stop();
scanning = false;
ESP_LOGI(TAG, "BLE: scan timeout, no server found");
}
}
}
```
- [ ] **Step 4: Update disconnect handler — zet disconnectTime**
In `onDisconnect` (lijn 85-93), voeg `disconnectTime = millis();` toe:
```cpp
void onDisconnect(NimBLEClient *client, int reason) override {
connected = false;
disconnectTime = millis();
relay1SendState = false;
relay2SendState = false;
pCmdChar = nullptr;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
ESP_LOGW(TAG, "BLE: disconnected (reason=%d)", reason);
}
```
- [ ] **Step 5: Bouw en verifieer**
```bash
pio run -e s3lite-client
```
Expected: Compileert.
- [ ] **Step 6: Commit**
```bash
git add src/client.cpp
git commit -m "feat(client): replace hardcoded connect with scan-based discovery + connectToServer()"
```
---
### Task 4: Verwijder dode code
**File:** `src/client.cpp`
- [ ] **Step 1: Verwijder ongebruikte defines**
Verwijder lijn 34 (`#define SCAN_TIMEOUT 5000`) als dat nog niet gebeurd is.
- [ ] **Step 2: Verwijder ongebruikte variabele `lastConnectAttempt`**
De oude `static uint32_t lastConnectAttempt = 0;` in `loop()` (lijn 541) is niet meer nodig — vervangen door `lastScanAttempt` in de nieuwe code.
- [ ] **Step 3: Verwijder ongebruikte `MyScanCallbacks` instantiatie in `onResult`**
De klasse `MyScanCallbacks` moet nu een constructor hebben of via `new` aangemaakt worden in `startScan()`. Check: NimBLE verwacht raw pointer, we gebruiken `new` (geheugenlek is acceptabel — callbacks leven de hele uptime).
- [ ] **Step 4: Bouw en verifieer**
```bash
pio run -e s3lite-client
```
Expected: Compileert zonder warnings over ongebruikte variabelen.
- [ ] **Step 5: Commit**
```bash
git add src/client.cpp
git commit -m "chore(client): remove dead code, unused defines and variables"
```
---
### Task 5: Bouw, flash, test
- [ ] **Step 1: Bouw**
```bash
pio run -e s3lite-client
```
- [ ] **Step 2: Flash client ESP32**
Sluit de client USB aan, check poort:
```bash
ls /dev/serial/by-id/ | grep espressif
```
Flash:
```bash
pio run -e s3lite-client -t upload --upload-port /dev/ttyACM<client_poort>
```
- [ ] **Step 3: Monitor serial output**
```bash
cat /dev/ttyACM<client_poort>
```
Expected: `BLE: scanning started`, daarna `BLE: server found at <mac>`, `BLE: server UUID verified, ECDH done, ready`.
- [ ] **Step 4: Test relay commands**
Druk op touch-sensors op de client. Verifieer dat relais op server schakelen.
- [ ] **Step 5: Test herconnectie**
Zet server uit en weer aan. Verifieer dat client automatisch herverbindt na server weer adverteert — via opgeslagen adres (snel) of via new scan.
- [ ] **Step 6: Kopieer firmware naar Android assets**
```bash
cp .pio/build/s3lite-client/bootloader.bin .pio/build/s3lite-client/partitions.bin .pio/build/s3lite-client/firmware.bin android/app/src/main/assets/firmware/client/
```
- [ ] **Step 7: Commit en push**
```bash
git add src/client.cpp android/app/src/main/assets/firmware/client/
git commit -m "chore: update client firmware in Android assets"
git push
```
---
## Flow overzicht
```
setup()
└─ BLE init, laad stored server addr uit NVS
loop() [niet connected]
├─ stored addr beschikbaar? → direct connect poging
│ ├─ success → connectToServer() → UUID-verify + ECDH → connected!
│ └─ fail → startScan()
└─ geen stored addr → startScan()
loop() [scanning]
├─ serverFound → stop scan → pClient->connect() → connectToServer()
│ └─ success → sla MAC op in NVS → connected!
└─ scan timeout → stop scan, wacht SCAN_INTERVAL_MS, herstart
onDisconnect()
└─ zet disconnectTime, cleanup state → herstart scan na RECONNECT_DELAY_MS
```
Dit is exact wat de Android app doet: scan → UUID-prefix match → connect → UUID-verify → ECDH.

View file

@ -1,269 +0,0 @@
# SylDa NimBLE + HMAC — Design Spec
Datum: 2025-07-31
Branch: nieuw, van `main`
## Doel
Stabiele basis: 1× Atom S3 Lite als SylDa_Server + 1× Atom S3 Lite als Sylda_Client + Android app, met NimBLE, UUID identificatie, HMAC-SHA256 authenticatie. Later refactoren naar AES-GCM.
## Architectuur
```
┌──────────────────────────────────────────────┐
│ Android App │
│ USB CDC: flash + provision (server & client) │
│ BLE: relay knoppen → HMAC → server │
│ OTA firmware update → server & client │
│ Radio buttons: [Server | Client] × [BLE|USB] │
└────────────────┬──────────────────────────────┘
│ BLE (client 2) │ USB CDC
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ SylDa_Server │ │ Sylda_Client │
│ AtomS3 Lite │ │ AtomS3 Lite │
│ │◄─┤ │
│ Relais G1, G2 (ext) │ │ Touch1 G8, Touch2 G6 │
│ Touch1 G8, Touch2 G6 │ │ Button G41 │
│ Button G41 │ │ Relay-state → BLE HMAC │
│ OR-logica │ │ OTA (BLE+USB) │
│ OTA (BLE+USB) │ │ │
│ LED G35 (NeoPixel) │ │ LED G35 (NeoPixel) │
└─────────────────────────┘ └─────────────────────────┘
```
### Provisioning flow
1. Android app flasht firmware via USB CDC (EspFlasher)
2. Android leest server UUID via BLE READ
3. Android stuurt `SET_KEY`, `SET_ID`, `SET_SERVER` naar client via USB CDC
4. Server genereert eigen UUID bij eerste boot (NVS persistent)
5. Gedeelde HMAC key (32 bytes) = enige authenticatie voor beide clients
6. Server UUID in client NVS = verificatie bij connect (geen spoofing)
7. UUIDs + HMAC key overleven NVS bij firmware updates (partities 0x9000-0xDFFF)
## Hardware
Beide devices: AtomS3 Lite (ESP32-S3), qio_qspi flash.
### Server
| Pin | Functie | Type |
|-----|---------|------|
| G39 | RELAY1 | OUTPUT (externe relais module) |
| G38 | RELAY2 | OUTPUT (externe relais module) |
| G8 | TOUCH1 | INPUT_PULLUP |
| G6 | TOUCH2 | INPUT_PULLUP |
| G41 | BUTTON | INPUT (active low) |
| G35 | LED | FastLED NeoPixel |
### Client
| Pin | Functie | Type |
|-----|---------|------|
| G8 | TOUCH1 | INPUT_PULLUP |
| G6 | TOUCH2 | INPUT_PULLUP |
| G41 | BUTTON | INPUT (active low) |
| G35 | LED | FastLED NeoPixel |
## Server firmware (`src/main.cpp`)
Basis: `main` branch. NimBLE, UUID, HMAC, relays, touch, button, OTA, NVS.
### Connection tracking
Max 2 verbindingen: client1 (AtomS3 Lite) + client2 (Android app).
```cpp
bool bleRelay1[2] = {false, false};
bool bleRelay2[2] = {false, false};
bool connActive[2] = {false, false};
int connectedCount = 0;
```
### Server callbacks
```cpp
onConnect(pServer, connInfo):
connActive[cid] = true;
connectedCount++;
if (connectedCount < 2) NimBLEDevice::startAdvertising();
onDisconnect(pServer, connInfo, reason):
connActive[cid] = false;
bleRelay1[cid] = false;
bleRelay2[cid] = false;
connectedCount--;
NimBLEDevice::startAdvertising();
```
CID mapping: via `NimBLEConnInfo::getConnHandle()` in callbacks. ConnHandle → index 0/1 in arrays. Eerste connect = index 0, tweede = index 1. Disconnect wist de entry en reset die index.
### Relay logica (OR over alle sources)
```cpp
relay1 = touch1 || button || (connActive[0] && bleRelay1[0]) || (connActive[1] && bleRelay1[1]);
relay2 = touch2 || (connActive[0] && bleRelay2[0]) || (connActive[1] && bleRelay2[1]);
```
OTA schakelt alle relays uit. LED = FastLED NeoPixel op G35 (geen digitalWrite meer).
### BLE characteristics
| UUID | Type | Functie |
|------|------|---------|
| `4fafc201-...` | Service | |
| `...f26d8` | WRITE_NR | Relay commands (HMAC) |
| `...f26d9` | READ | Server UUID (identificatie) |
| `...f26db` | READ | Firmware version |
| `...f26da` | WRITE_NR + NOTIFY | OTA |
### Protocol
HMAC-SHA256, 37-byte packets: `[cmd:1][seq:4][hmac:32]`
| Cmd | Actie |
|-----|-------|
| 0x00 | Relay1 OFF |
| 0x01 | Relay1 ON |
| 0x02 | Relay2 OFF |
| 0x03 | Relay2 ON |
Seq = monotoon per connection, replay protection per client.
### USB CDC
```
KEY? → DEVICE_ID:<32 hex> + HMAC_KEY:<64 hex>
SET_KEY:<64> → "OK" (slaat op in NVS)
SET_ID:<32> → "OK" (slaat op in NVS)
VERSION? → BUILD_NR:<n>
```
### FIRMWARE_VERSION
Server: "6"
Client: "1"
Single source of truth: `version.txt` in project root.
```
server=6
client=1
```
Build scripts lezen `version.txt`:
- PlatformIO: `-DFIRMWARE_VERSION=<n>` via `extra_scripts = pre:tools/pio_version_pre.py`
- Android: `build.gradle` leest `version.txt` voor `EXPECTED_FIRMWARE_VERSION`
## Client firmware (`src/client.cpp`)
Nieuw bestand op deze branch. HMAC, geen AES-GCM.
### BLE flow
```
1. Scan (actief, interval=100, window=99) → filter op naam "SylDa_Server"
2. Connect → READ UUID characteristic
3. Verify: server UUID == geprovisioneerde serverUuid in NVS
✓ match → HMAC relay commands
✗ mismatch → disconnect, scan opnieuw
```
### Relay logica (lokaal, stuurt eindresultaat)
```cpp
relay1 = touch1 || button → stuurt 0x01/0x00
relay2 = touch2 → stuurt 0x02/0x03
```
Debounce: 50ms, identiek aan server.
### HMAC packet build
Zelfde 37-byte packet: `[cmd:1][seq:4][hmac:32]`, zelfde HMAC key.
### NVS
```
namespace "hmac": key "key" (32 bytes HMAC), "uuid" (16 bytes eigen UUID), "server" (16 bytes server UUID)
```
### USB CDC
```
KEY? → DEVICE_ID:<32 hex> + HMAC_KEY:<64 hex> + SERVER_UUID:<32 hex>
SET_KEY:<64> → "OK"
SET_ID:<32> → "OK"
SET_SERVER:<32> → "OK"
VERSION? → BUILD_NR:<n>
```
### OTA
Zelfde mechanisme als server, via BLE OTA characteristic. Chunks: 240 bytes.
## Android app
Basis: `main` branch. Wijzigingen t.o.v. `main`:
### SettingsActivity
Radio buttons:
```
[Server ▸ Client] × [BLE ▸ USB]
Server + USB = flash + provision (SET_KEY, SET_ID)
Server + BLE = relay knoppen + OTA + UUID READ
Client + USB = flash + provision (SET_KEY, SET_ID, SET_SERVER)
Client + BLE = OTA (via client UUID)
```
### BleRelayManager
Ongewijzigd — HMAC 37-byte packets, commands 0x00-0x03.
### EspFlasher
Server én client firmware binaries. Assets gesplitst:
```
assets/firmware/server/{bootloader,partitions,boot_app0,firmware}.bin
assets/firmware/client/{bootloader,partitions,boot_app0,firmware}.bin
```
### PrefsHelper
Extra keys: `deviceMode` (server/client), `serverUuid`, `clientUuid`.
## OTA protocol
Blijft ongewijzigd van `main`. Chunk size: 240 bytes (past in MTU 248 single write).
| Packet | Grootte | Inhoud |
|--------|---------|--------|
| START | 5 bytes | `[0x10][size:4]` |
| Chunk | 4 + 240 bytes | `[seq:4][data:240]` |
| Checksum | 32 bytes | SHA256 hash |
| Response | 5 bytes | `[status:1][seq:4]` via notify |
## PlatformIO
```ini
[env:s3lite-server] # AtomS3 Lite, qio_qspi, main.cpp
[env:s3lite-client] # AtomS3 Lite, qio_qspi, DEVICE_ID=1, client.cpp
upload_flags = --no-stub # beide envs (QSPI flash)
```
Beide envs identieke `qio_qspi` (beide AtomS3 Lite).
## Teststrategie
1. Server solo: touch/knop schakelen relais lokaal
2. Server + client: BLE relay commands via touch/knop op client
3. Server + client + Android: alle drie bronnen tegelijk, OR-logica
4. OTA: firmware update server én client via BLE
5. Reconnect: client + Android herstellen na disconnect
6. NVS persistentie: HMAC key + UUIDs overleven reboot
## Toekomst: AES-GCM refactor
Na stabiele NimBLE+HMAC basis: vervang HMAC door AES-256-GCM (18-byte packets, encryptie + authenticatie). Code op `Sylda_Client` branch dient als referentie.

View file

@ -1,127 +0,0 @@
# ESP32-PICO-D4 (M5Stack Atom Lite) Port — Design Spec
> **Goal:** Port the Sylvia Touch Relay project from ESP32-S3-Pico (Atom S3 Lite) to ESP32-PICO-D4 (Atom Lite), update the Android app for CDC-class USB detection, compile, bundle, and deploy via ADB wireless.
## Current State
The project targets the M5Stack Atom S3 Lite (ESP32-S3). The Android app filters USB devices by Espressif vendor ID (`0x303A`) for key exchange and firmware flashing. The `esp32-flash-lib` handles DTR/RTS via the `SerialTransport` interface implemented by `UsbSerialTransport`.
## Target Hardware
M5Stack Atom Lite with ESP32-PICO-D4:
- 4MB Flash (no PSRAM)
- FTDI FT2232C/D USB-to-UART bridge (VID `0x0403`) — appears as `/dev/ttyUSB0`
- SK6812 addressable RGB LED on GPIO27
- Relay on GPIO32 (G32)
- Touch sensor on GPIO33 (G33) — open-drain type, needs pullup
## Design Decisions
### 1. Side Branch & Git Worktree
Create branch `esp32picod4` via git worktree at `.worktrees/esp32picod4` to isolate the port from the main S3 variant.
### 2. Firmware (`platformio.ini` + `src/main.cpp`)
**New PlatformIO environment `m5stack-atom`:**
| Setting | Old (S3) | New (PICO-D4) | Reason |
|---------|----------|---------------|--------|
| `board` | `esp32-s3-devkitc-1` | `m5stack-atom` | PlatformIO board ID for M5Stack Atom Lite |
| `board_build.arduino.memory_type` | `qio_opi` | removed | Not needed for PICO-D4 |
| `-DESP32S3` | present | `-DESP32` | Correct chip family |
| `-DBOARD_HAS_PSRAM` | present | removed | Atom Lite has no PSRAM |
| `-mfix-esp32-psram-cache-issue` | present | removed | Only needed with PSRAM |
| `-DARDUINO_USB_CDC_ON_BOOT=1` | present | removed | No native USB on PICO-D4 |
| `-DARDUINO_USB_MODE=1` | present | removed | No native USB |
| `upload_port` | `/dev/ttyACM0` | `/dev/ttyUSB0` | FTDI serial device |
| `lib_deps` | (empty) | `fastled/FastLED@^3.7` | SK6812 Neopixel library |
**Pin definitions:**
| Constant | Old | New | Notes |
|----------|-----|-----|-------|
| `SENSOR_PIN` | 38 | 33 | G33, open-drain, `INPUT_PULLUP` unchanged |
| `RELAY_PIN` | 1 | 32 | G32 |
| `LED_PIN` | 27 | 27 | SK6812 Neopixel — replaces `digitalWrite` with FastLED API |
**BLE device name:** `"SylDa_Server"` (was `"SyldaServer"`)
**Serial banner:** `"Atom Lite BLE Relay"` (was `"SylDa BLE Relay"`)
**LED code change:** Replace `digitalWrite(LED_PIN, HIGH/LOW)` with FastLED:
- Include `FastLED.h`
- Define `#define NUM_LEDS 1`
- `CRGB leds[NUM_LEDS]`
- `FastLED.addLeds<SK6812, LED_PIN, GRB>(leds, NUM_LEDS)` in setup
- Startup blink: 3x green (`leds[0] = CRGB::Green; FastLED.show()`)
No other firmware logic changes — HMAC key exchange, BLE relay control, touch sensor logic remain identical.
### 3. Android USB Detection
**Problem:** The Atom Lite's FTDI bridge presents VID `0x0403`, not Espressif's `0x303A`. The app currently filters on VID and won't detect the device.
**Solution:** Replace VID (`0x303A`) filtering with CDC-ACM class filtering (interface class `0x0A`).
**Changes in `UsbUtils.kt`:**
- Add `hasCdcDataInterface(device: UsbDevice): Boolean` — iterates interfaces, returns true if any has `interfaceClass == 0x0A`
**Changes in `SettingsActivity.kt`:**
- Remove `ESP_VENDOR_ID` constant
- Replace all `device?.vendorId == ESP_VENDOR_ID` checks with `UsbUtils.hasCdcDataInterface(device)`
This detects all CDC-ACM serial devices including:
- FTDI FT2232C/D (Atom Lite)
- CP210x (some Atom Lite revisions)
- ESP32-S3 native USB (current hardware)
- CH340/CH9102 (other Atom variants)
### 4. DTR/RTS — No Changes Needed
**Research findings:**
The `esp32-flash-lib`'s `EspFlasherProtocol.enterBootLoader()` implements the standard DTR/RTS download-mode sequence:
```
setControlLines(true, false) // DTR=High → EN released, RTS=Low → GPIO0=Low (download mode)
delayMS(100)
setControlLines(false, true) // DTR=Low → reset pulse via capacitor, RTS=High → GPIO0=High
delayMS(100)
setControlLines(true, false) // DTR=High → release reset, RTS=Low → GPIO0=Low → boot in download mode
```
The `UsbSerialTransport.setControlLines()` implementation sends `CDC_SET_CONTROL_LINE_STATE` control transfers with correct DTR/RTS bit flags. This works for FTDI, CP2104, and native USB CDC-ACM.
The `SettingsActivity.setupCdcControl()` sets DTR|RTS both HIGH for normal serial operation (key exchange), which is correct for all bridge types.
**Verdict: No changes to `EspFlasher.kt`, `UsbSerialTransport.kt`, or key exchange logic.**
### 5. Build, Bundle & Deploy
1. `pio run -e m5stack-atom` to compile firmware
2. `tools/copy_firmware.sh` to copy binaries to `android/app/src/main/assets/firmware/` (environment dir is now `m5stack-atom`)
3. `./gradlew assembleDebug` in `android/` to build APK
4. ADB wireless to connect phone (`adb-wireless` skill)
5. `adb install -r app-debug.apk` to deploy
## Files Changed
| File | Change |
|------|--------|
| `platformio.ini` | New environment `m5stack-atom` replacing S3 config |
| `src/main.cpp` | New pin definitions, BLE name, FastLED LED code |
| `android/.../UsbUtils.kt` | Add `hasCdcDataInterface()` |
| `android/.../SettingsActivity.kt` | VID filter → CDC class filter |
| `tools/copy_firmware.sh` | Environment name → `s3lite-server` |
## Files Not Changed
| File | Reason |
|------|--------|
| `EspFlasher.kt` | DTR/RTS handled by library |
| `UsbSerialTransport.kt` | Correct implementation |
| `MainActivity.kt` | No BLE changes beyond device name (auto-discovered) |
| `PrefsHelper.kt` | No changes |
| `AndroidManifest.xml` | No changes |
| `build.gradle` | No dependency changes |

View file

@ -1,172 +0,0 @@
# BLE Refactor: BleRelayManager extractie
**Datum:** 2026-07-29
**Status:** goedgekeurd
## Doel
Extractie van alle BLE-logica uit `MainActivity.kt` naar een aparte `BleRelayManager` klasse. MainActivity wordt verantwoordelijk voor UI en lifecycle only.
## Scope
- **In:** BLE scan, GATT connectie, UUID verificatie, HMAC relay commands, key/seq management, auto-retry logica
- **Uit:** UI binding (views, touch listeners, string resources), lifecycle (onCreate/onResume/etc), permissions result handling, theme switching
- **Niet aangeraakt:** `PrefsHelper`, `SettingsActivity`, `EspFlasher`, `UsbSerialTransport`, firmware
## Nieuwe bestanden
### `BleStatus.kt` — enum + callback interface
```kotlin
enum class BleStatus {
Scanning,
Connecting,
Connected,
Disconnected,
NoKey,
WrongDevice,
ServiceNotFound,
ServiceError,
CommError,
DeviceNotFound,
BluetoothDisabled,
PermissionsDenied
}
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
}
```
### `BleRelayManager.kt` — publieke API
```kotlin
class BleRelayManager(
private val context: Context,
private val callbacks: BleCallbacks
) {
fun startScan()
fun stopScan()
fun sendRelayCommand(cmd: Byte)
fun disconnect()
fun updateKeys() // herlaadt keys na settings key exchange
fun resetTriedAddresses() // wist triedAddresses (bij onResume)
fun hasPermissions(): Boolean
fun getRequiredPermissions(): Array<String>
fun destroy() // cleanup handler, stop scan, close gatt
}
```
### Interne componenten (private)
1. **State:** `bluetoothGatt`, `relayCharacteristic`, `hmacKey`, `deviceUuid`, `seq`, `triedAddresses`, `isScanning`, `scanTimedOut`, `scanTimeoutRetries`
2. **Handler:** `Handler(Looper.getMainLooper())` voor scan scheduling en timeouts
3. **ScanCallback:** scan op `SERVICE_UUID`, skip `triedAddresses`, bij match → `stopScan()` + `connectToDevice()`
4. **GattCallback:** connectie → check key → discoverServices → requestMtu → read UUID char → verify → enable notifications. Bij disconnect: auto-retry na delay.
5. **HMAC:** `hexStringToBytes()`, `hmacSha256()`, `buildPacket()` — ongewijzigd verplaatst
6. **Key loading:** `loadState()` leest via `PrefsHelper.getPrefs(context)`
**Companion constants** (ongewijzigd): `SERVICE_UUID`, `CHARACTERISTIC_UUID`, `UUID_CHAR_UUID`, `CCCD_UUID`, `DEVICE_NAME`, `HMAC_PKT_SIZE`, `HMAC_LEN`, `SCAN_TIMEOUT_MS`, `RECONNECT_DELAY_MS`, `MAX_SCAN_RETRIES`
```mermaid
sequenceDiagram
participant MA as MainActivity
participant BRM as BleRelayManager
participant ESP as ESP32-S3
MA->>BRM: startScan()
BRM->>BRM: loadState() via PrefsHelper
BRM-->>MA: onStatusChanged(Scanning)
BRM->>ESP: BLE scan (filter SERVICE_UUID)
ESP-->>BRM: scan result
BRM->>BRM: skip triedAddresses?
BRM->>ESP: connectGatt()
BRM-->>MA: onStatusChanged(Connecting)
ESP-->>BRM: STATE_CONNECTED
BRM->>BRM: check hmacKey != null
BRM->>ESP: discoverServices → requestMtu(256)
BRM->>ESP: readCharacteristic(UUID_CHAR)
ESP-->>BRM: UUID value
BRM->>BRM: verify against stored deviceUuid
BRM->>ESP: setCharacteristicNotification + writeDescriptor(CCCD)
BRM-->>MA: onStatusChanged(Connected)
MA->>BRM: sendRelayCommand(0x01)
BRM->>BRM: buildPacket(cmd, hmacKey, seq)
BRM->>ESP: writeCharacteristic (NO_RESPONSE)
BRM->>Prefs: save seq
ESP-->>BRM: STATE_DISCONNECTED
BRM-->>MA: onStatusChanged(Disconnected)
BRM->>BRM: handler.postDelayed(startScan, 2s)
```
## MainActivity — wat blijft
| Component | Blijft in MainActivity |
|---|---|
| `onCreate` | View binding, settings button, `BleRelayManager` instantiatie |
| `onResume` | Theme check, `manager.resetTriedAddresses()`, `manager.startScan()` of toon NoKey |
| `onPause` | `manager.stopScan()` |
| `onDestroy` | `manager.destroy()` |
| `onActivityResult` | Bij RESULT_SCAN: `manager.disconnect()`, `manager.updateKeys()`, `manager.resetTriedAddresses()`, `manager.startScan()` |
| `onRequestPermissionsResult` | Bij granted: `manager.startScan()`; bij denied: UI update |
| `setupRelayButton()` | Touch listener → `manager.sendRelayCommand(...)` |
| `setupRelay2Button()` | Touch listener → `manager.sendRelayCommand(...)` |
| Status mapping | `onStatusChanged` → `statusText.text = getString(R.string.status_*)` + knop enabled/disabled |
### Status → UI mapping
```kotlin
callbacks = object : BleCallbacks {
override fun onStatusChanged(status: BleStatus) {
when (status) {
BleStatus.Connected -> {
statusText.text = getString(R.string.status_connected)
setupRelayButton()
setupRelay2Button()
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
BleStatus.Disconnected -> {
statusText.text = getString(R.string.status_connecting)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
BleStatus.DeviceNotFound -> {
// retry knop op relay1Button (huidig gedrag behouden)
relay1Button.setOnTouchListener(null)
relay2Button.setOnTouchListener(null)
relay1Button.setOnClickListener(null)
relay2Button.setOnClickListener(null)
relay1Button.text = getString(R.string.button_retry_scan)
relay2Button.text = getString(R.string.button_hold)
relay2Button.isEnabled = false
relay1Button.isEnabled = true
relay1Button.setOnClickListener {
setupRelayButton()
setupRelay2Button()
manager.startScan()
}
}
else -> statusText.text = getString(R.string.status_scanning) // fallback
}
}
}
```
## Risico's & mitigatie
| Risico | Mitigatie |
|---|---|
| Race condition bij snelle key-update + scan | `updateKeys()` stopt scan vóór herladen |
| Context leak via Handler | `destroy()` roept `handler.removeCallbacksAndMessages(null)` |
| GattCallback refs naar oude Activity | Manager instantieert per Activity, geen singleton |
| Touch listeners verwijzen naar verouderde manager | `setupRelay*Button()` opnieuw aanroepen bij statuswissel (huidig patroon) |
## Verificatie
1. Build: `./gradlew assembleDebug` moet slagen
2. Lint: `./gradlew lint` geen nieuwe warnings
3. Functioneel: APK deployen via ADB, BLE scan → connect → UUID verify → relay knoppen ON/OFF
4. Randgevallen: app herstarten (keys behouden), settings key exchange → rescan, timeout + retry, disconnect + auto-reconnect, permissie deny/accept

View file

@ -1,68 +0,0 @@
# Device UUID Pairing — Design
**Datum:** 2026-07-29
**Status:** goedgekeurd
## Doel
Voorkomen dat twee ESP-telefoon paren in dezelfde ruimte met elkaars ESP verbinden. Elk ESP-telefoon paar deelt een unieke UUID die tijdens key exchange wordt uitgewisseld.
## Flow
### Verse ESP (geen UUID, geen HMAC key)
1. ESP boot, genereert random UUID (16 bytes via `esp_random()`), slaat op in NVS
2. ESP start BLE advertising als `"SylDa_Server"` (UUID nog onbekend bij app)
3. App opent Settings → Key Exchange
4. App stuurt `KEY?` over USB CDC, ESP antwoordt `NO_KEY`
5. App genereert random 32-byte HMAC key + 16-byte UUID
6. App stuurt `SET_KEY:<64 hex>` → ESP slaat op → `OK`
7. App stuurt `SET_ID:<32 hex>` → ESP slaat op → `OK`
8. App slaat beide op in EncryptedSharedPreferences
9. App keert terug naar main, start scan
### Bestaand paar (beide hebben UUID + HMAC key)
1. App stuurt `KEY?`, ESP antwoordt `DEVICE_ID:<32 hex>\nHMAC_KEY:<64 hex>`
2. App slaat beide op
3. Klaar
### BLE connectie
1. App scant op BLE service UUID `4fafc201-...` (niet op naam)
2. Bij elke gevonden device: connect, discover services
3. Lees UUID characteristic (READ-only)
4. UUID match → zet relay knoppen actief
5. UUID mismatch → disconnect, voeg address toe aan `triedAddresses`, herstart scan
6. Relay commands blijven HMAC-beveiligd zoals voorheen
## Componenten
### Firmware (`src/main.cpp`)
| Item | Details |
|------|---------|
| NVS key `"uuid"` | 16 bytes, namespace `"hmac"` |
| UUID generatie | `esp_random()` per byte, alleen bij eerste boot |
| BLE naam | `"SylDa_Server"` |
| UUID characteristic | Nieuwe READ-only characteristic, los UUID |
| `KEY?` response | `DEVICE_ID:32HEX\nHMAC_KEY:64HEX` of `NO_KEY` |
| `SET_ID:32HEX` | Nieuw commando, slaat UUID in NVS |
### Android — PrefsHelper
- `PREF_UUID = "device_uuid"` (EncryptedSharedPreferences)
### Android — SettingsActivity
- `readKeyFromEsp()`: parse `DEVICE_ID:` lijn
- `KeyResult.Success` krijgt `deviceUuid` veld
- Bij `NO_KEY`: app genereert UUID + stuurt `SET_KEY` en `SET_ID`
### Android — MainActivity
- `loadState()`: laadt UUID
- `startScan()`: `ScanFilter` op service UUID
- `onScanResult`: connect bij elke match (naam genegeerd)
- `gattCallback.onMtuChanged`: lees UUID characteristic, verify
- UUID mismatch: disconnect, `triedAddresses.add(address)`, herstart scan

View file

@ -1,128 +0,0 @@
# BLE OTA Firmware Updates — Design
## Doel
Firmware-updates via Bluetooth LE vanaf de Android app, zonder USB-kabel. Doelgroep: lichamelijk gehandicapten — zelfstandigheid zonder fysieke kabels.
## Huidige situatie
- Firmware: ~523KB (534.793 bytes, 16% van 3.2MB beschikbaar)
- BLE: NimBLE-Arduino, service "SylDa_Server" actief
- Partitietabel: al OTA-capable — `ota_0` (0x10000, 3264K), `ota_1` (0x340000, 3264K), `otadata` (0xe000, 8K)
- Android app: flasht nu via USB CDC (EspFlasher)
## Architectuur
### ESP32-S3 kant (`src/main.cpp`)
- Nieuwe BLE characteristic `VERSION_UUID = "beb5483e-36e1-4688-b7f5-ea07361f26db"` — READ, exposeert firmwareversie als string
- Nieuwe BLE characteristic `OTA_UUID = "beb5483e-36e1-4688-b7f5-ea07361f26da"` — WRITE + NOTIFY
- Bestaande relay service blijft volledig intact, aparte characteristic in dezelfde service
- OTA-modus flag: tijdens OTA worden relay-commando's genegeerd, relais vallen UIT
- Gebruikt ESP-IDF `esp_ota_*` API:
- `esp_ota_get_next_update_partition()` — vindt target partitie
- `esp_ota_begin()` — initialiseert schrijfactie
- `esp_ota_write()` — schrijft chunk naar flash
- `esp_ota_end()` — valideert image, markeert als bootable
- `esp_ota_set_boot_partition()` — zet target als actieve bootpartitie
- `esp_restart()` — herstart naar nieuwe firmware
### Android kant
- Nieuwe `OtaManager.kt` — leest firmware, hasht, chunkt, verstuurt
- OTA-knop in `SettingsActivity.kt` met progress UI
- Bij BLE connect: leest firmwareversie van ESP via `VERSION_UUID` characteristic, vergelijkt met versie in app. Toont toast bij beschikbare update.
- Firmware bron: `assets/firmware.bin` (of bestandskiezer voor handmatige update)
- SHA-256 hash berekening voor eindverificatie
- Bundled firmwareversie (uit `assets/`) vergeleken met ESP-versie bij connect
## Versie-check bij BLE connect
Bij elke BLE-verbinding leest de Android app de `VERSION_UUID` READ characteristic van de ESP. De ESP retourneert de stringwaarde van `FIRMWARE_VERSION`. De app vergelijkt met de versie van de gebundelde `firmware.bin` in assets. Als de ESP een lagere versie heeft toont de app een toast: "Firmware-update beschikbaar" — de gebruiker kan dan in SettingsActivity de OTA starten.
| VERSION_UUID | "beb5483e-36e1-4688-b7f5-ea07361f26db" |
|---|---|
| Properties | READ |
| Waarde | String, bijv. `"1"` |
## BLE OTA Protocol
```
OTA Characteristic UUID: beb5483e-36e1-4688-b7f5-ea07361f26da
Properties: WRITE, NOTIFY
MTU: 512 bytes (negotiated)
Chunk nuttige data: 488 bytes (512 - 24 header overhead)
```
### Write commando's (App → ESP)
| Byte(s) | Commando |
|---|---|
| `0x10` + 4 bytes size (LE) | OTA_START — firmware grootte in bytes |
| 4 bytes seq (LE) + data[...] | OTA_DATA — chunk met sequentienummer |
| 32 bytes SHA-256 | OTA_END — hash van volledige firmware |
### Notificatie commando's (ESP → App)
| Byte(s) | Betekenis |
|---|---|
| `0x00` + 4 bytes seq (LE) | ACK — chunk ontvangen en geschreven |
| `0x01` + 4 bytes seq (LE) | NAK — chunk opnieuw versturen |
| `0x10` | OTA_COMPLETE — succes, reboot volgt |
| `0x11` + 1 byte error | OTA_ERROR |
### Flow
1. App stuurt `OTA_START` met firmware-grootte
2. ESP roept `esp_ota_begin()` aan, zet OTA-modus, relais UIT
3. App stuurt chunks sequentieel (seq 0, 1, 2, ...)
4. ESP schrijft elke chunk met `esp_ota_write()`, stuurt ACK terug
5. Bij timeout of corruptie: ESP stuurt NAK met seq-nummer, app herhaalt chunk
6. App stuurt `OTA_END` met SHA-256
7. ESP: `esp_ota_end()`, `esp_ota_set_boot_partition()`, stuurt `OTA_COMPLETE`
8. ESP: `esp_restart()`
## LED-signalering
| Patroon | Betekenis |
|---|---|
| Snel knipperen (100ms) | OTA bezig |
| Continu aan | OTA voltooid, reboot imminent |
| Langzaam knipperen (500ms) | OTA fout |
## Beveiliging
- Firmware wordt NIET per chunk met HMAC geauthenticeerd (snelheid)
- SHA-256 hash van volledige firmware meegezonden in OTA_END
- `esp_ota_end()` verifieert image header checksum automatisch
- Alleen een BLE-verbonden device kan OTA starten (BLE link-level encryptie via pairing)
- Bestaande HMAC-key voor relay-commando's blijft ongewijzigd
## Foutafhandeling
| Scenario | Afhandeling |
|---|---|
| Verbinding verbroken tijdens OTA | Huidige firmware blijft actief, `ota_1` ongeldig |
| Corrupte chunk | ESP stuurt NAK, app herhaalt chunk |
| SHA-256 mismatch in OTA_END | `esp_ota_end()` faalt, huidige firmware blijft |
| ESP crash tijdens OTA | Bootloader boot `ota_0`, `otadata` wijst er nog naar |
| Nieuwe firmware boot niet (8x bootloop) | ESP-IDF rollback: schakelt terug naar `ota_0` |
## Edge cases
- **OTA opnieuw starten:** `ota_1` wordt overschreven, geen clean-up nodig
- **Versie-check:** Bij BLE connect leest de app de firmwareversie van de ESP (`VERSION_UUID` characteristic). Bij mismatch met gebundelde firmware toont app een toast "Firmware-update beschikbaar" en activeert de OTA-knop.
- **Tijdens actieve relais:** relais vallen UIT bij start OTA (rust-toestand)
## Transfertijd schatting
523KB bij ~30 KB/s effectieve BLE throughput ≈ 17 seconden. Bij slechtere verbinding ~45-60 seconden. Acceptabel.
## Bestanden
| Bestand | Wijziging |
|---|---|
| `src/main.cpp` | VERSION characteristic, OTA characteristic, `esp_ota_*` logica, OTA-modus flag, LED-signalering |
| `platformio.ini` | Geen wijziging (partitietabel al OTA-ready) |
| `android/.../OtaManager.kt` | Nieuw: firmware lezen, hashen, chunken, BLE verzenden |
| `android/.../SettingsActivity.kt` | OTA-knop + progress UI |

View file

@ -1,90 +0,0 @@
# OTA Improvements — post code review
Datum: 2026-07-30
Branch: `feature/ble-ota`
## Doel
Zes code review aanbevelingen implementeren voor robuustere OTA firmware update, in volgorde van prioriteit.
---
## 1. ESP SHA256 verificatie
### ESP (`src/main.cpp`)
In `onWrite()` voor de BLE OTA characteristic:
- Android stuurt SHA256 hash van `firmware.bin` als laatste chunk (32 bytes, `chunkIndex == totalChunks`)
- ESP herkent laatste chunk aan `dataLen == 32` in combinatie met `chunkIndex >= totalChunks`
- Bij ontvangst: `esp_ota_end(update_handle)` afronden, dan `esp_ota_get_app_elf_sha256(update_handle, computedSha)` aanroepen
- `memcmp(computedSha, expectedSha, 32)` — bij mismatch:
- abortOta(update_handle) → bestaande partitie blijft actief
- NAK-notificatie sturen naar Android (0x01)
- Bij match:
- ACK-notificatie sturen (0x00)
- `esp_ota_set_boot_partition(update_handle)`
- `esp_restart()`
### Android (`OtaManager.kt`)
- Na laatste data-chunk: SHA256 van `firmware.bin` berekenen met `java.security.MessageDigest.getInstance("SHA-256")`
- 32-byte hash als aparte chunk sturen met `chunkIndex = totalChunks + 1`
- Wachten op ACK/NAK response van ESP (punt 2)
---
## 2. OTA notification handling (Android)
### `BleRelayManager.kt`
- `onCharacteristicChanged()` in OTA callback doorsturen naar `OtaManager.otaNotificationHandler(bytes: ByteArray)`
### `OtaManager.kt`
- `otaNotificationHandler(bytes)`: leest `bytes[0]` — `0x00`=ACK, `0x01`=NAK
- ACK → `sendNextChunk()`, update progress
- NAK → retry huidige chunk (max 3x per chunk)
- Timeout: na versturen chunk, 2s timer — geen antwoord = retry (max 3x)
- Bij overschrijding max retries: OTA abort met foutmelding in dialog
---
## 3+4. Dynamische chunk size + setMTU(512)
### ESP
- `NimBLEDevice::setMTU(512)` aanroepen in `setupBLE()`
### Android
- `OTA_CHUNK_DATA_SIZE` vervangen door `chunkDataSize` variabele
- `onMtuChanged(mtu: Int)` callback → `chunkDataSize = mtu - 3 - 4` (ATT header + chunk header)
- Default: 248 (valt terug als MTU nog niet onderhandeld is)
- Chunk header blijft 4 bytes: `[chunkIndex:2][totalChunks:2]`
---
## 5. writeCharacteristic return check
### `OtaManager.kt`
In `sendOtaData()`:
```kotlin
val success = bleRelayManager.writeOtaCharacteristic(chunk)
if (!success) {
retriesLeft--
if (retriesLeft <= 0) abortOta()
else retryCurrentChunk()
}
```
---
## 6. otaStatusText widget verwijderen
- `otaStatusText` verwijderen uit `activity_settings.xml`
- `otaStatusText` property verwijderen uit `SettingsActivity.kt`
De progress bar in de OTA dialog toont al percentage — aparte statuswidget is overbodig.

View file

@ -1,99 +0,0 @@
# Release build & Forgejo publishing — design
**Datum:** 2026-07-30
**Context:** Android app (AtomRelay) heeft nog geen ondertekende release build. Doel: signing keystore aanmaken, release APK + AAB bouwen, en publiceren als release op Forgejo (`git.eddydevink.nl/eddy/Project_SylDa`). Vooruitkijkend op multi-store publishing (Amazon, Samsung, F-Droid, later Google Play).
## Scope
- Keystore genereren en veilig opslaan (Obsidian vault + gitignored workspace copy)
- Signing configuratie in `build.gradle` via extern `signing.properties`
- Release script `scripts/release.sh` dat APK, AAB en firmware binaries bundelt
- Upload naar Forgejo als git-tagged release met attachments
- Buiten scope: daadwerkelijk uploaden naar app stores, Forgejo Actions/CI
## Huidige situatie
- `versionCode 1`, `versionName "1.0"`
- Geen keystore, geen signing config
- `gradle.properties` is tracked (geen secrets)
- APK/AAB bestanden al in `.gitignore`
- Forgejo repo `eddy/Project_SylDa`, API op `http://localhost:3000/api/v1`, 0 releases, 0 tags
- Firmware binaries in `android/app/src/main/assets/firmware/`
- Root project: `AtomRelay`, Gradle 8.14, AGP 8.5.2, Kotlin 2.0.21
## Ontwerp
### 1. Keystore & signing config
| Bestand | Pad | Git |
|---|---|---|
| Keystore | `android/keystore/sylda-release.jks` | `.gitignore` |
| Wachtwoorden | `android/signing.properties` | `.gitignore` |
| Signing block | `android/app/build.gradle` (aanpassing) | tracked |
**`signing.properties`** inhoud:
```properties
KEYSTORE_PATH=keystore/sylda-release.jks
KEYSTORE_PASSWORD=<wachtwoord>
KEY_ALIAS=sylda
KEY_PASSWORD=<wachtwoord>
```
**`build.gradle`** aanpassing: onder `android {}` block wordt signing block toegevoegd dat `signing.properties` inleest. Alleen actief als het bestand bestaat → debug builds blijven werken zonder keystore.
**Keystore generatie:**
```bash
keytool -genkey -v -keystore android/keystore/sylda-release.jks \
-alias sylda -keyalg RSA -keysize 2048 -validity 10000
```
**Opslag:** kopie van `.jks` + wachtwoorden naar Obsidian vault. Gitignored kopie blijft in workspace voor Gradle.
### 2. .gitignore
Toevoegen:
```
android/keystore/
android/signing.properties
```
### 3. Release script (`scripts/release.sh`)
Flow:
1. Controleert dat `signing.properties` bestaat
2. `./gradlew clean`
3. `./gradlew bundleRelease assembleRelease`
4. Verzamelt artefacten in `release/v{version}/`:
- `app-release.apk`
- `app-release.aab`
- `bootloader.bin`, `partitions.bin`, `boot_app0.bin`, `firmware.bin`
- `checksums.txt` (SHA256 van alle bestanden)
5. `git tag v{version} && git push origin v{version}`
6. Maakt Forgejo release aan via API
7. Uploadt alle artefacten als attachments
**Forgejo token:** opgeslagen in `~/.config/sylda/forgejo-token` (niet in repo). Moet handmatig aangemaakt worden in Forgejo web UI (Settings > Applications > Generate Token, scope `write:repository`).
**Versie-detectie:** script leest `versionName` uit `build.gradle` via grep, géén handmatige argumenten nodig.
### 4. F-Droid compatibiliteit
- Geen timestamp in APK/AAB build (huidige `BUILD_NUMBER` buildConfigField wordt alleen voor debug gebruikt en zit niet in release builds — check)
- Vaste Gradle wrapper versie (8.14)
- Check: `buildConfigField` met timestamp staat in `defaultConfig`, niet in release buildType. Dit is OK voor F-Droid want F-Droid bouwt reproduceerbare builds met eigen tooling.
### 5. Multi-store vooruitblik
| Platform | Artefact | Extra nodig |
|---|---|---|
| Forgejo release | APK + AAB + firmware | Deze release |
| Google Play | AAB | Google Play developer account |
| Amazon Appstore | APK | Amazon developer account |
| Samsung Galaxy Store | APK | Samsung developer account |
| F-Droid | Source | F-Droid metadata + merge request |
## Beslissingen
- Keystore in Obsidian vault (conform AGENTS.md credentials policy)
- Huidig versienummer behouden (1/1.0) — eerste release
- Aanpak A: release script, handmatig (geen CI voor nu)

View file

@ -1,52 +0,0 @@
# Design: Touch sensor HS-S43A migratie + S3R referenties opruimen
> **Issue:** [#5](https://git.eddydevink.nl/eddy/Project_SylDa/issues/5) — Verwijder internal pullups voor touch sensors
> **Datum:** 2026-08-01
## Aanleiding
De touch sensoren zijn vervangen van JR223B (open collector/drain, heeft pullup nodig) naar **HS-S43A** (digitale output 0/1, actief = HIGH, geen pullup). Daarnaast bevat de documentatie verouderde verwijzingen naar "Atom S3R Voice" — de hardware is inmiddels **M5Stack Atom S3 Lite** (board `esp32-s3-devkitc-1`).
## Wijzigingen
### 1. Firmware: pullup verwijderen + schakellogica inverteren
Beide firmwares hebben identiek patroon, 2 regels per file:
| Bestand | Locatie | Huidig | Nieuw |
|---------|---------|--------|-------|
| `src/main.cpp` | `pinMode(TOUCH1/2_PIN, ...)` | `INPUT_PULLUP` | `INPUT` |
| `src/main.cpp` | `digitalRead(TOUCH1/2_PIN) == LOW` | `== LOW` | `== HIGH` |
| `src/client.cpp` | `pinMode(TOUCH1/2_PIN, ...)` | `INPUT_PULLUP` | `INPUT` |
| `src/client.cpp` | `digitalRead(TOUCH1/2_PIN) == LOW` | `== LOW` | `== HIGH` |
**Rationale:** JR223B met pullup → normaal HIGH, aanraking trekt naar LOW. HS-S43A → actieve digitale output, aanraking = HIGH.
Debounce (`DEBOUNCE_MS=50`) blijft ongewijzigd — HS-S43A is stabieler, bestaande debounce is ruim voldoende.
### 2. Documentatie: S3R → S3 Lite
Alle 27 verwijzingen naar "S3R", "AtomS3R", "Atom VoiceS3R", "Atom S3R Voice", "m5stack-atoms3r" in 8 bestanden:
| Bestand | Aanpassing |
|---------|------------|
| `AGENTS.md` | Board: S3R Voice → S3 Lite. Flash commando pad: `m5stack-atoms3r` → `s3lite-server`. Touch: G8+pullup → G8, G6+pullup → G6 |
| `README.md` | Board spec, serial banner, pin-doc: S3R → S3 Lite |
| `.forgejo/workflows/ci.yml` | Build output pad: `m5stack-atoms3r` → `s3lite-server` |
| `tools/copy_firmware.sh` | Build output pad: `m5stack-atoms3r` → `s3lite-server` |
| `docs/superpowers/specs/2026-07-28-esp32-pico-d4-port-design.md` | Contextuele S3R verwijzingen — doel/resultaat beschrijvingen intact laten, alleen boardnaam corrigeren |
| `docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md` | 1 match: comment over S3R pinmap |
| `docs/superpowers/plans/2026-07-30-ble-ota.md` | Build output pad: `m5stack-atoms3r` → `s3lite-server` |
| `docs/superpowers/plans/2025-07-28-esp32-pico-d4-port.md` | Meerdere referenties naar S3R — historische context behouden, boardnaam corrigeren |
| `docs/superpowers/plans/2026-07-28-esp32-pico-d4-port.md` | Build output pad + serial banner referenties |
### 3. Risico's
- **Geen rollback zonder herflashen** — na flashen werkt JR223B niet meer.
- **Beide devices flashen** — server + client(s) moeten gesynchroniseerd zijn.
- **Geen runtime-switch** tussen sensortypes — YAGNI.
## Niet in scope
- Geen wijzigingen aan BLE-protocol, HMAC, OTA, of Android app.
- Geen configuratie-mechanisme voor sensortype.

View file

@ -1,97 +0,0 @@
# LED-signalering: stabiele verbinding
**Issue:** [#4](https://git.eddydevink.nl/eddy/Project_SylDa/issues/4) — Leds mogen uit na een minuut stabiele verbinding
**Datum:** 2026-08-02
**Status:** goedgekeurd
## Doel
Uniforme LED-signalering voor zowel server als client. De LED dient als debug-indicator tijdens opstarten en bij verbindingsproblemen. Na 60 seconden continue stabiele verbinding schakelt de LED uit.
## Kleurenschema
Identiek voor server (`main.cpp`) en client (`client.cpp`):
| State | LED | Prioriteit |
|-------|-----|-----------|
| OTA in progress | Groen knipperend (100ms interval) | 1 (hoogst) |
| OTA completed, waiting confirm | Geel vast | 1 |
| Geen verbinding | Rood | 2 |
| Verbinding actief (< 60s) | Geel | 3 |
| Verbinding stabiel (>= 60s) | **Uit** | 4 (laagst) |
| Relais-flash (overlay) | Groen 150ms | overlay |
Relais-flash is een overlay: toont kort groen bij verandering in relais-status, daarna keert LED terug naar de kleur die hoort bij de huidige verbindingsstatus.
## Timer-logica
- Timer (`connectionStableMs`) start zodra de **eerste** verbinding tot stand komt
- `connectionStableMs` telt op zolang er minstens 1 verbinding is (connectedCount > 0 voor server, connected == true voor client)
- Nieuwe verbindingen die later bijkomen resetten de timer **niet**
- Bij disconnect van een verbinding terwijl er nog andere verbindingen zijn: timer loopt door
- Pas als **alle** verbindingen wegvallen: `connectionStableMs` = 0, LED direct rood
- Drempel: `STABLE_LED_TIMEOUT_MS = 60000`
### Server-specifiek
- `connectedCount`: aantal actieve BLE-clients (0-2)
- Timer loopt als `connectedCount > 0`
- Alle clients disconnect = reset
### Client-specifiek
- `connected`: boolean, of client verbonden is met server
- Timer loopt als `connected == true`
- Disconnect = reset
## Relais-flash mechanisme
- Detectie: vergelijk huidige relais-state met vorige loop-iteratie
- Bij verandering: zet `relayFlashUntil = millis() + 150`
- Zolang `millis() < relayFlashUntil`: LED groen (overschrijft verbindingskleur)
- Flash reset de 60s timer **niet**
- **Server:** detecteert verandering in `relay1State || relay2State` (combinatie van touch, button, en BLE-commands)
- **Client:** detecteert verandering in `relay1State || relay2State` (alleen touch/button)
## OTA (beide firmwares)
OTA heeft hoogste prioriteit en override alle andere LED-states:
- **OTA in progress** (`otaInProgress == true`): groen knipperend, 100ms interval
- **OTA completed, waiting confirm** (`otaEnded == true && !otaConfirmed`): geel vast
- **OTA confirm timeout** of **OTA error**: valt terug naar normale verbindings-LED
- **OTA success + reboot**: groen vast, dan reboot
Client heeft OTA-logica nog niet geïmplementeerd, maar de LED-code wordt alvast voorbereid met dezelfde OTA-override structuur zodat toekomstige OTA-implementatie direct past.
## LED-update volgorde in loop()
```
1. Bepaal base-kleur op basis van verbindingsstatus (rood/geel/uit)
2. Controleer relais-flash: als actief -> groen
3. Controleer OTA-override: als actief -> OTA-kleur
4. FastLED.show()
```
## Wijzigingen per bestand
### `src/main.cpp` (server)
- Toevoegen: `STABLE_LED_TIMEOUT_MS`, `connectionStableMs`, `relayFlashUntil`, `lastRelayActive`
- Aanpassen: LED-logica in `loop()` — base-kleur uit `connectionStableMs` i.p.v. `connectedCount > 0`
- Toevoegen: relais-flash detectie
- Behouden: OTA LED-override (werkt al, prioriteit blijft hoogst)
- Verwijderen: setup LED-blinks (drie groene knipperingen) — vervangen door rood (geen verbinding)
### `src/client.cpp` (client)
- Toevoegen: `STABLE_LED_TIMEOUT_MS`, `connectionStableMs`, `relayFlashUntil`, `lastRelayActive`
- Aanpassen: LED-logica in `loop()` — base-kleur uit `connectionStableMs`, geen aparte relais-kleur meer
- Toevoegen: relais-flash detectie
- Toevoegen: OTA-override structuur (placeholder, `otaInProgress` default `false`)
- Setup: LED rood i.p.v. direct rood (blijft rood, maar nu als onderdeel van uniform schema)
### `src/version.h`
- Server versie bump naar "16"
- Client versie bump naar "2"

View file

@ -1,152 +0,0 @@
# Auto-detect Upload Port Fix
**Issue:** #16
**Date:** 2026-08-04
**Status:** Approved
## Problem
The post-script `tools/pio_upload_port.py` sends `ID?` to the ESP and reads the first non-empty response to determine device type (server/client). The client responds correctly with `SylDa_Client`, but during upload there was active log output (ECDH handshake) on the serial port. The `query_id()` function reads the first log message instead of the ID response, causing auto-detect to fail.
Root cause: `query_id()` blindly takes the first non-empty line, even if it's log output.
## Solution
Two-part fix for maximum robustness:
1. **Script-side:** Filter responses — keep reading until a line containing `SylDa_Server` or `SylDa_Client` appears
2. **Firmware-side:** Pause all serial output during `ID?` response
No protocol changes. Both parts work independently but together provide robustness.
## Architecture
```
┌─────────────────────┐ ID?\n ┌─────────────────────┐
│ pio_upload_port │ ─────────────▶│ Firmware (ESP32) │
│ (Python script) │ │ │
│ │◀─────────────│ serialMuted=true │
│ query_id() reads │ SylDa_* │ Serial.flush() │
│ until match │ │ response │
└─────────────────────┘ │ serialMuted=false │
└─────────────────────┘
```
## Components
### 1. Script: `tools/pio_upload_port.py`
**Change:** `query_id()` function filters for expected ID instead of returning first non-empty line.
**Before:**
```python
def query_id(port, timeout=5):
# ...
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
ser.close()
return line # Returns first non-empty, even if log
# ...
```
**After:**
```python
def query_id(port, timeout=5):
try:
ser = serial.Serial(port, 115200, timeout=0.5)
time.sleep(0.1)
ser.reset_input_buffer()
ser.write(b"ID?\n")
deadline = time.time() + timeout
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if "SylDa_Server" in line or "SylDa_Client" in line:
ser.close()
return line
ser.close()
except (serial.SerialException, OSError):
pass
return None
```
**No other changes needed** — the rest of the script already uses `expected_id in resp` checks.
### 2. Firmware: `src/client.cpp` and `src/server.cpp`
**Changes:**
1. Global `serialMuted` flag
2. Serial mute during `ID?` handling
3. Wrapper macros for existing Serial calls
**Implementation:**
```cpp
// Global flag (file scope)
static bool serialMuted = false;
// Wrapper macros
#define SERIAL_PRINTLN(msg) if (!serialMuted) Serial.println(msg)
#define SERIAL_PRINT(msg) if (!serialMuted) Serial.print(msg)
// In serial command handling
} else if (serialBuf == "ID?") {
Serial.flush();
serialMuted = true;
Serial.println("SylDa_Client"); // or SylDa_Server
delay(50); // Ensure response transmitted
serialMuted = false;
}
```
**Existing `Serial.println()` calls:** Replace with `SERIAL_PRINTLN()` macro.
**ESP_LOG macros:** Unchanged — these use IDF logging, not Arduino Serial.
### 3. Server firmware
Identical implementation to client, with `SylDa_Server` response.
## Data Flow
1. Script opens serial port, sends `ID?\n`
2. Firmware receives command:
- `Serial.flush()` empties TX buffer
- `serialMuted = true` blocks new output
- `Serial.println("SylDa_Client")` — only output
- `delay(50)` ensures transmission
- `serialMuted = false` releases mute
3. Script reads lines until `SylDa_*` appears (timeout 5s)
4. Match → port selected
## Error Handling
| Scenario | Behavior |
|----------|----------|
| No response | Script timeout → manual port selection (existing) |
| Wrong response | Script detects mismatch → manual selection (existing) |
| Multiple ports, both respond | Script matches correct one, selects it |
| Firmware mute stuck | Impossible — mute released immediately after response |
| Buffer overflow | `Serial.flush()` prevents this |
## Testing
1. Connect both server and client simultaneously
2. Run `pio run -e s3lite-client -t upload` — should auto-detect client
3. Run `pio run -e s3lite-server -t upload` — should auto-detect server
4. Verify log output resumes after `ID?` response
5. Test with active ECDH handshake in progress
## Files Changed
- `tools/pio_upload_port.py` — `query_id()` filtering
- `src/client.cpp` — serial mute implementation
- `src/server.cpp` — serial mute implementation
## Rollback
If issues arise, revert to manual port selection:
```bash
pio run -e s3lite-client -t upload --upload-port /dev/serial/by-id/...
```

View file

@ -1,111 +0,0 @@
# User-facing text — Atom Relay
## MainActivity — statusbalk
| Huidige tekst | strings.xml key |
|---|---|
| Connected | `status_connected` |
| Verbinden… | `status_connecting` |
| Servicefout | `status_service_error` |
| Service not found | `status_service_not_found` |
| Communicatiefout | `status_comm_error` |
| Geen HMAC sleutel - open Instellingen | `status_no_key` |
| Permissies geweigerd — open Instellingen | `status_permissions_denied` |
| Bluetooth not enabled | `status_bluetooth_disabled` |
| Scanning… | `status_scanning` |
| Device not found | `status_device_not_found` |
| Verkeerd apparaat — opnieuw scannen… | `status_wrong_device` |
| Verbonden, services zoeken… | `status_discovering_services` |
## MainActivity — knoppen
| Huidige tekst | strings.xml key |
|---|---|
| Instellingen (contentDescription) | `cd_settings` |
| Disconnected (placeholder) | `status_default` |
| HOLD (fallback knoplabel) | `button_hold` |
| Opnieuw scannen (fallback) | `button_retry_scan` |
| User-defined labels 1 & 2 | PrefsHelper (user input) |
## SettingsActivity — UI
| Huidige tekst | strings.xml key |
|---|---|
| Sluit de ESP aan op USB en kies een actie. | `msg_settings_intro` |
| Sleutel Uitwisselen | `button_key_exchange` |
| Zet de ESP in download-modus (houd reset 2s in tot de groene LED aangaat) en tik op Firmware Flashen. | `msg_flash_mode_help` |
| Firmware Flashen | `button_firmware_flash` |
| Knoptekst | `section_labels` |
| Relay 1 | `hint_label_relay1` |
| Relay 2 | `hint_label_relay2` |
| Weergave | `section_theme` |
| Systeem | `theme_system` |
| Licht | `theme_light` |
| Donker | `theme_dark` |
| Thema: %s | `theme_applied` |
| Buildnummer: %s | `build_number` |
## SettingsActivity — dialogs / toasts
| Huidige tekst | strings.xml key |
|---|---|
| Sleutel Uitwisselen | `title_key_exchange` |
| Sluit de ESP aan via USB. | `msg_connect_usb` |
| Sleutel uitlezen… | `msg_reading_key` |
| Beveiligingssleutel gelezen | `key_read_success` |
| Sleutel uitwisselen mislukt - probeer opnieuw | `key_exchange_failed` |
| USB-toegang geweigerd | `usb_access_denied` |
| Annuleren | `button_cancel` |
| Sluiten | `button_close` |
| Firmware Flashen | `title_firmware_flash` |
| Zet de ESP in download-modus (houd reset 2s in tot groene LED) en sluit aan via USB. | `msg_flash_download_mode` |
| Initialiseren… | `msg_initializing` |
| Firmware geflashed!\\n\\nOntkoppel de ESP en sluit opnieuw aan om de sleutel in te stellen. | `msg_flash_complete` |
| Flashen mislukt: %s | `flash_failed` |
| Sleutels komen niet overeen | `title_key_conflict` |
| De ESP (SylDa_Server) heeft andere sleutels dan deze app. Wat wil je doen? | `msg_key_conflict` |
| ESP sleutels overnemen | `button_keep_esp_keys` |
| App sleutels overzetten | `button_overwrite_esp` |
| ESP sleutels overschrijven? | `title_overwrite_confirm` |
| Dit vervangt de sleutels van de ESP. De ESP kan daarna niet meer met de oorspronkelijke app communiceren. | `msg_overwrite_warning` |
| Ik weet het zeker | `checkbox_confirm_overwrite` |
| Bevestigen | `button_confirm` |
| Sleutels overschrijven | `title_overwriting_keys` |
| Sleutels schrijven naar ESP… | `msg_writing_keys` |
## SettingsActivity — foutmeldingen (hardcoded in .kt)
| Huidige tekst | strings.xml key |
|---|---|
| Geen CDC interface gevonden | `err_no_cdc` |
| Kan USB apparaat niet openen | `err_usb_open` |
| Kan USB interface niet claimen | `err_usb_claim` |
| Geen USB OUT endpoint gevonden | `err_no_out` |
| Geen USB IN endpoint gevonden | `err_no_in` |
| Ongeldig sleutel/UUID formaat | `err_invalid_key` |
| Geen sleutel gevonden na 5 pogingen | `err_key_retries` |
| Fout: %s | `err_generic` |
| SET_KEY schrijven mislukt | `err_set_key_write` |
| SET_ID schrijven mislukt | `err_set_id_write` |
| ESP bevestigde sleutel niet | `err_key_not_ack` |
| ESP bevestigde UUID niet | `err_uuid_not_ack` |
## EspFlasher — hardcoded in .kt
| Huidige tekst | strings.xml key |
|---|---|
| Kan USB-apparaat niet openen | `err_usb_open` (reuse) |
| Geen CDC data interface gevonden | `err_no_cdc` (reuse) |
| Kan USB interface niet claimen | `err_usb_claim` (reuse) |
| Geen USB OUT endpoint gevonden | `err_no_out` (reuse) |
| Geen USB IN endpoint gevonden | `err_no_in` (reuse) |
| Verbinden met ESP bootloader... | `flash_connecting` |
| Bootloader | `flash_step_bootloader` |
| Partitie tabel | `flash_step_partitions` |
| Boot app0 | `flash_step_boot_app0` |
| Firmware | `flash_step_firmware` |
| Sync met bootloader... | `flash_sync` |
| Chip detecteren... | `flash_detect` |
| Chip: %s | `flash_chip` |
| Stub laden... | `flash_stub` |
| Firmware geflashed! | `flash_done` |
## AndroidManifest
| Huidige tekst | strings.xml key |
|---|---|
| Atom Relay | `app_name` |
| Instellingen | `settings` |

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

View file

@ -1,34 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="12" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Achtergrond: Dark Antraciet Squircle -->
<rect x="32" y="32" width="448" height="448" rx="105" ry="105" fill="#121212" />
<rect x="32" y="32" width="448" height="448" rx="105" ry="105" fill="none" stroke="#2A2A32" stroke-width="4" />
<!-- Centrale Icon Groep met Gloed -->
<g filter="url(#glow)">
<!-- Power Cirkel (geopend aan de bovenkant) -->
<path d="M 186 210 A 110 110 0 1 0 326 210"
fill="none"
stroke="#00E5FF"
stroke-width="28"
stroke-linecap="round" />
<!-- Bluetooth Pijl / Power Pin -->
<path d="M 256 120 L 256 260 M 256 120 L 285 145 L 240 180 L 270 205 L 256 216"
fill="none"
stroke="#00E5FF"
stroke-width="24"
stroke-linecap="round"
stroke-linejoin="round" />
</g>
<!-- Twee status-stippen voor Relais 1 & 2 -->
<circle cx="216" cy="370" r="10" fill="#00E5FF" opacity="0.8" />
<circle cx="296" cy="370" r="10" fill="#00E5FF" opacity="0.8" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,40 +1,15 @@
[env:s3lite-server]
[env:m5stack-atoms3r]
platform = espressif32@6.7.0
board = esp32-s3-devkitc-1
framework = arduino
board_build.arduino.memory_type = qio_qspi
board_build.arduino.memory_type = qio_opi
build_flags =
-DESP32S3
-DCORE_DEBUG_LEVEL=3
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DCORE_DEBUG_LEVEL=5
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
lib_deps =
fastled/FastLED@^3
h2zero/NimBLE-Arduino@^2
upload_port = /dev/ttyACM0
upload_speed = 115200
upload_flags = --no-stub
build_src_filter = +<main.cpp> -<client.cpp>
extra_scripts =
pre:tools/pio_version_pre.py
post:tools/pio_upload_port.py
[env:s3lite-client]
platform = espressif32@6.7.0
board = esp32-s3-devkitc-1
framework = arduino
board_build.arduino.memory_type = qio_qspi
build_flags =
-DESP32S3
-DCORE_DEBUG_LEVEL=3
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DDEVICE_ID=1
lib_deps =
fastled/FastLED@^3
h2zero/NimBLE-Arduino@^2
upload_speed = 115200
upload_flags = --no-stub
build_src_filter = +<client.cpp> -<main.cpp>
extra_scripts =
pre:tools/pio_version_pre.py
post:tools/pio_upload_port.py

View file

@ -1,142 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[release]${NC} $1"; }
warn() { echo -e "${YELLOW}[release]${NC} $1"; }
err() { echo -e "${RED}[release]${NC} $1" >&2; }
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ANDROID_DIR="$PROJECT_DIR/android"
APP_BUILD_GRADLE="$ANDROID_DIR/app/build.gradle"
SIGNING_PROPS="$ANDROID_DIR/signing.properties"
FORGEJO_URL="https://git.eddydevink.nl/api/v1/repos/eddy/Project_SylDa"
CHECK_VERSIONS="$PROJECT_DIR/tools/check_versions.sh"
COPY_FIRMWARE="$PROJECT_DIR/tools/copy_firmware.sh"
FIRMWARE_DIR="$ANDROID_DIR/app/src/main/assets/firmware"
VERSION=$(grep -oP 'versionName\s+"\K[^"]+' "$APP_BUILD_GRADLE")
TAG="v$VERSION"
RELEASE_DIR="$PROJECT_DIR/release/$TAG"
log "Release v$VERSION (tag $TAG)"
log "========================================="
if [ ! -f "$SIGNING_PROPS" ]; then
err "signing.properties niet gevonden op $SIGNING_PROPS"
exit 1
fi
# ── Build firmware ────────────────────────────────────────────
log "Build firmware (pio run)..."
cd "$PROJECT_DIR"
pio run
log "Firmware built."
log "Copy firmware to Android assets..."
bash "$COPY_FIRMWARE"
log "Firmware copied."
# ── Build Android ─────────────────────────────────────────────
log "Clean Gradle..."
cd "$ANDROID_DIR"
./gradlew clean
log "Build release APK + AAB..."
./gradlew assembleRelease bundleRelease
log "Android built."
# ── Verify version consistency ────────────────────────────────
log "Verify version consistency..."
if ! bash "$CHECK_VERSIONS"; then
err "Version mismatch detected — aborting release."
err "version.txt is source of truth. Both version.h and SylDaVersions.kt are regenerated above."
exit 1
fi
# ── Gather artifacts ──────────────────────────────────────────
log "Verzamel artefacten in $RELEASE_DIR"
rm -rf "$RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
cp "$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" "$RELEASE_DIR/"
cp "$ANDROID_DIR/app/build/outputs/bundle/release/app-release.aab" "$RELEASE_DIR/"
# Server firmware
cp "$FIRMWARE_DIR/server/bootloader.bin" "$RELEASE_DIR/server-bootloader.bin"
cp "$FIRMWARE_DIR/server/partitions.bin" "$RELEASE_DIR/server-partitions.bin"
cp "$FIRMWARE_DIR/server/boot_app0.bin" "$RELEASE_DIR/server-boot_app0.bin"
cp "$FIRMWARE_DIR/server/firmware.bin" "$RELEASE_DIR/server-firmware.bin"
# Client firmware
cp "$FIRMWARE_DIR/client/bootloader.bin" "$RELEASE_DIR/client-bootloader.bin"
cp "$FIRMWARE_DIR/client/partitions.bin" "$RELEASE_DIR/client-partitions.bin"
cp "$FIRMWARE_DIR/client/boot_app0.bin" "$RELEASE_DIR/client-boot_app0.bin"
cp "$FIRMWARE_DIR/client/firmware.bin" "$RELEASE_DIR/client-firmware.bin"
log "Checksums..."
cd "$RELEASE_DIR"
sha256sum * > checksums.txt
cat checksums.txt
# ── Git tag ───────────────────────────────────────────────────
cd "$PROJECT_DIR"
if git diff --quiet && git diff --cached --quiet; then
log "Working tree clean."
else
warn "Working tree has uncommitted changes. Commit before releasing."
warn "Uncommitted files:"
git status --short
exit 1
fi
if git rev-parse "$TAG" >/dev/null 2>&1; then
log "Tag $TAG bestaat al, skip git tag."
else
log "Git tag $TAG..."
git tag "$TAG"
git push origin "$TAG"
fi
# ── Create Forgejo release ────────────────────────────────────
log "Forgejo release aanmaken..."
TOKEN_FILE="$HOME/.config/sylda/forgejo-token"
if [ ! -f "$TOKEN_FILE" ]; then
err "Forgejo token niet gevonden op $TOKEN_FILE"
err "Genereer een token in Forgejo: Settings > Applications > Generate Token (scope: write:repository)"
err "Sla op als: echo 'je-token' > $TOKEN_FILE"
exit 1
fi
TOKEN=$(cat "$TOKEN_FILE")
RELEASE_RESP=$(curl -s -X POST "$FORGEJO_URL/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"SylDa Android app + firmware\", \"draft\": false, \"prerelease\": false}")
RELEASE_ID=$(echo "$RELEASE_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
err "Release aanmaken mislukt. Response:"
echo "$RELEASE_RESP"
exit 1
fi
log "Release ID: $RELEASE_ID"
cd "$RELEASE_DIR"
for f in *; do
log " Upload $f..."
curl -s -X POST "$FORGEJO_URL/releases/$RELEASE_ID/assets?name=$f" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$f" > /dev/null
done
log "========================================="
log "Klaar! Release $TAG staat op Forgejo."
log " $FORGEJO_URL/releases/tag/$TAG" | sed 's|/api/v1/repos/||'

View file

@ -1,651 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <FastLED.h>
#include <mbedtls/gcm.h>
#include <mbedtls/sha256.h>
#include <Preferences.h>
#include <esp_log.h>
#include "version.h"
#include "ecdh_helper.h"
#include "serial_utils.h"
static const char *TAG = "client";
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", (esp_log_level_t)CONFIG_LOG_DEFAULT_LEVEL);
}
#define TOUCH1_PIN 8
#define TOUCH2_PIN 6
#define BUTTON_PIN 41
#define LED_PIN 35
#define NUM_LEDS 1
CRGB leds[NUM_LEDS];
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc"
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "syl"
#define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid"
#define NVS_SERVER_NAME "server"
#define NVS_ADDR_NAME "addr"
#define DEBOUNCE_MS 50
#define SCAN_DURATION_MS 5000
#define SCAN_INTERVAL_MS 8000
#define RECONNECT_DELAY_MS 1000
uint8_t psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
uint8_t serverUuid[ESP_UUID_LEN];
bool pskProvisioned = false;
bool uuidProvisioned = false;
bool serverProvisioned = false;
bool allProvisioned = false;
bool serverAddrStored = false;
NimBLEAddress storedServerAddr;
Preferences preferences;
static bool connected = false;
NimBLEClient *pClient = nullptr;
NimBLERemoteCharacteristic *pCmdChar = nullptr;
static bool relay1SendState = false;
static bool relay2SendState = false;
static uint32_t seq = 0;
static uint8_t aesKey[AES_KEY_LEN];
static bool ecdhReady = false;
#define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0;
bool otaInProgress = false;
bool otaEnded = false;
static bool bleReady = false;
static bool serverFound = false;
static NimBLEAddress serverAddress;
static NimBLEAddress foundAddr;
static bool scanning = false;
static uint32_t scanStarted = 0;
static uint32_t disconnectTime = 0;
static bool ecdhNotifyReceived = false;
static uint8_t ecdhNotifyBuf[ECDH_PKT_SIZE];
static size_t ecdhNotifyLen = 0;
class MyScanCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override {
if (scanning && advertisedDevice->isAdvertisingService(NimBLEUUID(SERVICE_UUID))) {
std::string mfgData = advertisedDevice->getManufacturerData();
if (mfgData.length() >= 6) {
if (memcmp(mfgData.data() + 2, serverUuid, 4) == 0) {
foundAddr = advertisedDevice->getAddress();
serverFound = true;
ESP_LOGI(TAG, "BLE: server found at %s", foundAddr.toString().c_str());
}
}
}
}
void onScanEnd(const NimBLEScanResults &results, int reason) override {
scanning = false;
ESP_LOGI(TAG, "BLE: scan ended (reason=%d, found=%d)", reason, serverFound);
}
};
class MyClientCallbacks : public NimBLEClientCallbacks {
void onConnect(NimBLEClient *client) override {
connected = true;
ESP_LOGI(TAG, "BLE: connected to server");
}
void onDisconnect(NimBLEClient *client, int reason) override {
connected = false;
disconnectTime = millis();
relay1SendState = false;
relay2SendState = false;
pCmdChar = nullptr;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
ESP_LOGW(TAG, "BLE: disconnected (reason=%d)", reason);
}
};
static MyClientCallbacks clientCallbacks;
static void buildAesPacket(uint8_t cmd, uint8_t *packet) {
uint32_t s = ++seq;
packet[0] = (s >> 24) & 0xFF;
packet[1] = (s >> 16) & 0xFF;
packet[2] = (s >> 8) & 0xFF;
packet[3] = s & 0xFF;
uint8_t nonceRandom[8];
for (int i = 0; i < 8; i++) nonceRandom[i] = (uint8_t)(esp_random() & 0xFF);
memcpy(packet + 4, nonceRandom, 8);
uint8_t nonce[12];
memcpy(nonce, packet, 4);
memcpy(nonce + 4, nonceRandom, 8);
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "AES: setkey failed (ret=%d)", ret);
mbedtls_gcm_free(&gcm);
return;
}
uint8_t pt[1] = { cmd };
mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT,
1, nonce, 12, NULL, 0,
pt, packet + 12, 16, packet + 13);
mbedtls_gcm_free(&gcm);
}
static void disconnectClient() {
if (pClient != nullptr) {
if (pClient->isConnected()) {
pClient->disconnect();
delay(100);
}
}
pCmdChar = nullptr;
connected = false;
relay1SendState = false;
relay2SendState = false;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
}
static bool performEcdhHandshake(NimBLERemoteService *pSvc) {
if (!pskProvisioned) {
ESP_LOGE(TAG, "ECDH: no PSK");
return false;
}
CryptoECDH ecdh;
if (!ecdh.init()) {
ESP_LOGE(TAG, "ECDH: init failed");
return false;
}
if (!ecdh.generateKeys()) {
ESP_LOGE(TAG, "ECDH: keygen failed");
ecdh.free();
return false;
}
NimBLERemoteCharacteristic *pEcdhChar = pSvc->getCharacteristic(ECDH_UUID);
if (pEcdhChar == nullptr) {
ESP_LOGW(TAG, "ECDH: characteristic not found");
ecdh.free();
return false;
}
if (!pEcdhChar->subscribe(true, [](NimBLERemoteCharacteristic *pChar, const uint8_t *data, size_t len, bool isNotify) {
if (ecdhReady) return;
if (len != ECDH_PKT_SIZE) {
ESP_LOGW(TAG, "ECDH: bad notify length %d", len);
return;
}
memcpy(ecdhNotifyBuf, data, len);
ecdhNotifyLen = len;
ecdhNotifyReceived = true;
ESP_LOGI(TAG, "ECDH: notify received (%d bytes)", len);
}, true)) {
ESP_LOGW(TAG, "ECDH: subscribe failed");
ecdh.free();
return false;
}
uint8_t handshake[ECDH_PKT_SIZE];
memcpy(handshake, ecdh.public_key, ECDH_PUBKEY_LEN);
CryptoECDH::signPubkey(psk, ecdh.public_key, handshake + ECDH_PUBKEY_LEN);
ecdhNotifyReceived = false;
if (!pEcdhChar->writeValue(handshake, ECDH_PKT_SIZE, false)) {
ESP_LOGW(TAG, "ECDH: write pubkey failed");
ecdh.free();
return false;
}
uint32_t start = millis();
while (!ecdhNotifyReceived && (millis() - start < 5000)) {
delay(10);
}
if (!ecdhNotifyReceived) {
ESP_LOGW(TAG, "ECDH: notify timeout");
ecdh.free();
return false;
}
if (!CryptoECDH::verifyPubkey(psk, ecdhNotifyBuf, ecdhNotifyBuf + ECDH_PUBKEY_LEN)) {
ESP_LOGW(TAG, "ECDH: server pubkey HMAC mismatch");
ecdh.free();
return false;
}
if (!ecdh.computeSharedSecret(ecdhNotifyBuf, ECDH_PUBKEY_LEN)) {
ESP_LOGE(TAG, "ECDH: shared secret failed");
ecdh.free();
return false;
}
memcpy(aesKey, ecdh.aes_key, AES_KEY_LEN);
ecdh.free();
ecdhReady = true;
ESP_LOGI(TAG, "ECDH: handshake complete");
return true;
}
static bool connectToServer() {
if (pClient == nullptr) {
pClient = NimBLEDevice::createClient();
pClient->setConnectTimeout(5000);
pClient->setClientCallbacks(&clientCallbacks);
}
if (!pClient->connect(serverAddress)) {
ESP_LOGE(TAG, "BLE: connect failed, retry later");
return false;
}
NimBLERemoteService *pSvc = pClient->getService(SERVICE_UUID);
if (pSvc == nullptr) {
ESP_LOGW(TAG, "BLE: service not found");
disconnectClient();
return false;
}
NimBLERemoteCharacteristic *pUuidChar = pSvc->getCharacteristic(UUID_CHAR_UUID);
if (pUuidChar == nullptr) {
ESP_LOGW(TAG, "BLE: UUID characteristic not found");
disconnectClient();
return false;
}
std::string uuidVal = pUuidChar->readValue();
if (uuidVal.length() != ESP_UUID_LEN ||
memcmp(uuidVal.data(), serverUuid, ESP_UUID_LEN) != 0) {
ESP_LOGW(TAG, "BLE: server UUID mismatch — disconnecting");
disconnectClient();
return false;
}
pCmdChar = pSvc->getCharacteristic(CHARACTERISTIC_UUID);
if (pCmdChar == nullptr) {
ESP_LOGW(TAG, "BLE: command characteristic not found");
disconnectClient();
return false;
}
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
if (!performEcdhHandshake(pSvc)) {
ESP_LOGE(TAG, "BLE: ECDH handshake failed");
disconnectClient();
return false;
}
seq = 0;
relay1SendState = false;
relay2SendState = false;
ESP_LOGI(TAG, "BLE: server UUID verified, ECDH done, ready");
return true;
}
void setup() {
Serial.begin(115200);
delay(2000);
ESP_LOGI(TAG, "=== SylDa Client v%s ===", FIRMWARE_VERSION_CLIENT);
FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
leds[0] = CRGB::Red;
FastLED.show();
preferences.begin(NVS_NAMESPACE, true);
size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN);
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
size_t serverLen = preferences.getBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
if (pskLen == PSK_LEN) {
pskProvisioned = true;
ESP_LOGI(TAG, "PSK loaded from NVS");
} else {
pskProvisioned = false;
ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision");
}
if (uuidLen == ESP_UUID_LEN) {
uuidProvisioned = true;
ESP_LOGD(TAG, "UUID loaded from NVS");
} else {
uuidProvisioned = false;
for (int i = 0; i < ESP_UUID_LEN; i++) deviceUuid[i] = (uint8_t)(esp_random() & 0xFF);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
ESP_LOGD(TAG, "Generated new UUID");
}
if (serverLen == ESP_UUID_LEN) {
serverProvisioned = true;
ESP_LOGD(TAG, "Server UUID loaded from NVS");
} else {
serverProvisioned = false;
ESP_LOGW(TAG, "No server UUID — use USB SET_SERVER to provision");
}
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT);
if (allProvisioned) {
NimBLEDevice::init("SylDa_Client");
bleReady = true;
preferences.begin(NVS_NAMESPACE, true);
uint8_t addrBuf[6];
size_t addrLen = preferences.getBytes(NVS_ADDR_NAME, addrBuf, 6);
preferences.end();
if (addrLen == 6) {
ble_addr_t addr;
addr.type = BLE_ADDR_PUBLIC;
memcpy(addr.val, addrBuf, 6);
storedServerAddr = NimBLEAddress(addr);
serverAddrStored = true;
ESP_LOGI(TAG, "BLE: stored server address %s", storedServerAddr.toString().c_str());
}
ESP_LOGI(TAG, "BLE: initialized, will scan for server");
}
ESP_LOGI(TAG, "Setup done.");
}
static void startScan() {
if (scanning) return;
NimBLEScan *pScan = NimBLEDevice::getScan();
pScan->setScanCallbacks(new MyScanCallbacks());
pScan->setActiveScan(true);
pScan->setInterval(32);
pScan->setWindow(16);
serverFound = false;
scanning = true;
scanStarted = millis();
pScan->start(SCAN_DURATION_MS / 100, false);
ESP_LOGI(TAG, "BLE: scanning started");
}
void loop() {
if (Serial.available()) {
static String serialBuf;
char c = Serial.read();
serialBuf += c;
if (c == '\n') {
serialBuf.trim();
if (serialBuf == "KEY?") {
if (pskProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
Serial.print("PSK:");
for (int i = 0; i < PSK_LEN; i++) Serial.printf("%02X", psk[i]);
Serial.println();
Serial.print("SERVER_UUID:");
if (serverProvisioned) {
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", serverUuid[i]);
}
Serial.println();
} else {
Serial.println("NO_KEY");
}
} else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Client");
delay(50);
unmuteSerial();
} else if (serialBuf == "VERSION?") {
Serial.print("BUILD_NR:");
Serial.println(FIRMWARE_VERSION_CLIENT);
} else if (serialBuf.startsWith("SET_KEY:")) {
String hex = serialBuf.substring(8);
hex.toUpperCase();
if (hex.length() == 64 && isHex(hex)) {
parseHex(hex, psk, PSK_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN);
preferences.end();
pskProvisioned = true;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("PSK stored in NVS");
if (allProvisioned && !bleReady) {
NimBLEDevice::init("SylDa_Client");
bleReady = true;
ESP_LOGI(TAG, "BLE: initialized after key provisioning");
}
} else {
ESP_LOGE(TAG, "Invalid key format");
Serial.println("ERROR");
}
} else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7);
hex.toUpperCase();
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, deviceUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("UUID stored in NVS");
} else {
ESP_LOGE(TAG, "Invalid UUID format");
Serial.println("ERROR");
}
} else if (serialBuf.startsWith("SET_SERVER:")) {
String hex = serialBuf.substring(11);
hex.toUpperCase();
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, serverUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
serverProvisioned = true;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("Server UUID stored in NVS");
if (allProvisioned && !bleReady) {
NimBLEDevice::init("SylDa_Client");
bleReady = true;
ESP_LOGI(TAG, "BLE: initialized after server provisioning");
}
} else {
ESP_LOGE(TAG, "Invalid server UUID format");
Serial.println("ERROR");
}
}
serialBuf = "";
}
}
if (!allProvisioned) {
delay(20);
return;
}
static bool t1Stable = false;
static bool t2Stable = false;
static bool btnStable = false;
static uint32_t t1Change = 0;
static uint32_t t2Change = 0;
static uint32_t btnChange = 0;
bool t1Raw = (digitalRead(TOUCH1_PIN) == HIGH);
bool t2Raw = (digitalRead(TOUCH2_PIN) == HIGH);
bool btnRaw = (digitalRead(BUTTON_PIN) == LOW);
if (t1Raw != t1Stable) { t1Change = millis(); t1Stable = t1Raw; }
if (t2Raw != t2Stable) { t2Change = millis(); t2Stable = t2Raw; }
if (btnRaw != btnStable) { btnChange = millis(); btnStable = btnRaw; }
bool t1Active = (millis() - t1Change > DEBOUNCE_MS) && t1Stable;
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
bool relay1State = !t1Active || btnActive;
bool relay2State = !t2Active;
CRGB ledColor;
if (otaInProgress) {
ledColor = otaEnded
? CRGB::Yellow
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);
} else {
if (connected) {
if (connectionStableMs == 0) connectionStableMs = millis();
} else {
connectionStableMs = 0;
}
if (connected) {
ledColor = (millis() - connectionStableMs >= STABLE_LED_TIMEOUT_MS)
? CRGB::Black
: CRGB::Yellow;
} else {
ledColor = CRGB::Red;
}
static bool lastRelayActive = false;
bool relayActive = relay1State || relay2State;
if (relayActive != lastRelayActive) {
relayFlashUntil = millis() + 150;
}
lastRelayActive = relayActive;
if (millis() < relayFlashUntil) {
ledColor = CRGB::Green;
}
}
leds[0] = ledColor;
FastLED.show();
if (connected && pCmdChar != nullptr && ecdhReady) {
static uint32_t lastCmd = 0;
if (millis() - lastCmd > 200) {
if (relay1State != relay1SendState) {
uint8_t pkt[AES_PKT_SIZE];
buildAesPacket(relay1State ? 0x01 : 0x00, pkt);
pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay1SendState = relay1State;
ESP_LOGD(TAG, "TX: relay1 %s (seq=%lu)", relay1State ? "ON" : "OFF", seq);
lastCmd = millis();
}
if (relay2State != relay2SendState) {
uint8_t pkt[AES_PKT_SIZE];
buildAesPacket(relay2State ? 0x02 : 0x03, pkt);
pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay2SendState = relay2State;
ESP_LOGD(TAG, "TX: relay2 %s (seq=%lu)", relay2State ? "ON" : "OFF", seq);
lastCmd = millis();
}
}
}
static uint32_t lastScanAttempt = 0;
static uint32_t statusLog = 0;
if (bleReady && millis() - statusLog > 2000) {
ESP_LOGI(TAG, "BLE: connected=%d scanning=%d ecdh=%d serverFound=%d",
connected, scanning, ecdhReady, serverFound);
statusLog = millis();
}
if (!connected && bleReady) {
if (serverFound && !scanning) {
serverFound = false;
serverAddress = foundAddr;
ESP_LOGI(TAG, "BLE: connecting to %s type=%d", serverAddress.toString().c_str(), serverAddress.getType());
if (connectToServer()) {
connected = true;
uint8_t addrBuf[6];
memcpy(addrBuf, serverAddress.getVal(), 6);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_ADDR_NAME, addrBuf, 6);
preferences.end();
storedServerAddr = serverAddress;
serverAddrStored = true;
ESP_LOGI(TAG, "BLE: server address stored in NVS");
} else {
ESP_LOGW(TAG, "BLE: connectToServer failed");
}
} else if (!scanning) {
if (millis() - lastScanAttempt > SCAN_INTERVAL_MS) {
if (serverAddrStored) {
ESP_LOGI(TAG, "BLE: trying stored address %s", storedServerAddr.toString().c_str());
serverAddress = storedServerAddr;
if (connectToServer()) {
connected = true;
ESP_LOGI(TAG, "BLE: connected via stored address");
} else {
ESP_LOGI(TAG, "BLE: stored address failed, starting scan");
serverAddrStored = false;
startScan();
}
} else {
startScan();
}
lastScanAttempt = millis();
}
} else {
if (millis() - scanStarted > SCAN_DURATION_MS) {
NimBLEDevice::getScan()->stop();
scanning = false;
ESP_LOGI(TAG, "BLE: scan timeout, no server found");
}
}
}
if (connected && pClient != nullptr && !pClient->isConnected()) {
ESP_LOGW(TAG, "BLE: lost connection");
disconnectClient();
}
static uint32_t heartbeat = 0;
if (millis() - heartbeat > 60000) {
ESP_LOGD(TAG, "[hb] t1=%s t2=%s btn=%s r1=%s r2=%s conn=%s ecdh=%s",
!t1Active ? "YES" : "no",
!t2Active ? "YES" : "no",
btnActive ? "YES" : "no",
relay1State ? "ON" : "OFF",
relay2State ? "ON" : "OFF",
connected ? "yes" : "no",
ecdhReady ? "yes" : "no");
heartbeat = millis();
}
delay(20);
}

View file

@ -1,116 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <Arduino.h>
#include <mbedtls/ecdh.h>
#include <mbedtls/entropy.h>
#include <mbedtls/ctr_drbg.h>
#include <mbedtls/md.h>
#include <mbedtls/sha256.h>
#define ECDH_PUBKEY_LEN 65
#define AES_KEY_LEN 32
#define PSK_LEN 32
#define AES_PKT_SIZE 29
#define ECDH_PKT_SIZE 97 // pubkey(65) + HMAC(32)
class CryptoECDH {
private:
mbedtls_ecdh_context ecdh;
mbedtls_entropy_context entropy;
mbedtls_ctr_drbg_context ctr_drbg;
bool initialized = false;
CryptoECDH(const CryptoECDH&) = delete;
CryptoECDH& operator=(const CryptoECDH&) = delete;
public:
CryptoECDH() = default;
uint8_t public_key[ECDH_PUBKEY_LEN];
size_t pub_len = 0;
uint8_t shared_secret[32];
uint8_t aes_key[AES_KEY_LEN];
bool init() {
mbedtls_ecdh_init(&ecdh);
mbedtls_entropy_init(&entropy);
mbedtls_ctr_drbg_init(&ctr_drbg);
const char *pers = "sylda_ecdh";
int ret = mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy,
(const unsigned char *)pers, strlen(pers));
if (ret != 0) {
mbedtls_ecdh_free(&ecdh);
mbedtls_entropy_free(&entropy);
mbedtls_ctr_drbg_free(&ctr_drbg);
return false;
}
ret = mbedtls_ecp_group_load(&ecdh.grp, MBEDTLS_ECP_DP_SECP256R1);
if (ret != 0) {
mbedtls_ecdh_free(&ecdh);
mbedtls_entropy_free(&entropy);
mbedtls_ctr_drbg_free(&ctr_drbg);
return false;
}
initialized = true;
return true;
}
bool generateKeys() {
if (!initialized) return false;
int ret = mbedtls_ecdh_gen_public(&ecdh.grp, &ecdh.d, &ecdh.Q,
mbedtls_ctr_drbg_random, &ctr_drbg);
if (ret != 0) return false;
ret = mbedtls_ecp_point_write_binary(&ecdh.grp, &ecdh.Q,
MBEDTLS_ECP_PF_UNCOMPRESSED,
&pub_len, public_key, ECDH_PUBKEY_LEN);
return (ret == 0 && pub_len == ECDH_PUBKEY_LEN);
}
bool computeSharedSecret(const uint8_t *peerPubKey, size_t peerPubLen) {
if (!initialized || peerPubLen != ECDH_PUBKEY_LEN) return false;
int ret = mbedtls_ecp_point_read_binary(&ecdh.grp, &ecdh.Qp,
peerPubKey, peerPubLen);
if (ret != 0) return false;
size_t olen;
ret = mbedtls_ecdh_compute_shared(&ecdh.grp, &ecdh.z, &ecdh.Qp, &ecdh.d,
mbedtls_ctr_drbg_random, &ctr_drbg);
if (ret != 0) return false;
ret = mbedtls_mpi_write_binary(&ecdh.z, shared_secret, 32);
if (ret != 0) return false;
mbedtls_sha256(shared_secret, 32, aes_key, 0);
return true;
}
static void signPubkey(const uint8_t *psk, const uint8_t *pubkey, uint8_t *hmacOut) {
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(mdInfo, psk, PSK_LEN, pubkey, ECDH_PUBKEY_LEN, hmacOut);
}
static bool verifyPubkey(const uint8_t *psk, const uint8_t *pubkey, const uint8_t *hmac) {
uint8_t computed[32];
signPubkey(psk, pubkey, computed);
return memcmp(computed, hmac, 32) == 0;
}
void free() {
if (initialized) {
mbedtls_ecdh_free(&ecdh);
mbedtls_ctr_drbg_free(&ctr_drbg);
mbedtls_entropy_free(&entropy);
memset(shared_secret, 0, sizeof(shared_secret));
memset(aes_key, 0, sizeof(aes_key));
memset(public_key, 0, sizeof(public_key));
initialized = false;
}
}
};

View file

@ -1,784 +1,256 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <NimBLEAdvertising.h>
#include <FastLED.h>
#include <mbedtls/gcm.h>
#include <mbedtls/sha256.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <BLESecurity.h>
#include <mbedtls/md.h>
#include <Preferences.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <nvs_flash.h>
#include <esp_log.h>
#include "version.h"
#include "ecdh_helper.h"
#include "serial_utils.h"
static const char *TAG = "server";
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", (esp_log_level_t)CONFIG_LOG_DEFAULT_LEVEL);
}
#define RELAY1_PIN 1
#define RELAY2_PIN 2
#define TOUCH1_PIN 8
#define TOUCH2_PIN 6
#define BUTTON_PIN 41
#define LED_PIN 35
#define NUM_LEDS 1
CRGB leds[NUM_LEDS];
#define MAX_CLIENTS 2
#define SENSOR_PIN 38
#define RELAY_PIN 1
#define LED_PIN 46
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc"
#define OTA_CHUNK_SIZE 500
#define OTA_CMD_START 0x10
#define OTA_CMD_END 0x11
#define OTA_STATUS_ACK 0x00
#define OTA_STATUS_NAK 0x01
#define OTA_STATUS_COMPLETE 0x10
#define OTA_STATUS_ERROR 0x11
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "syl"
#define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid"
#define FIRMWARE_VERSION FIRMWARE_VERSION_SERVER
#define HMAC_PKT_SIZE 37 // 1 cmd + 4 seq + 32 hmac
#define HMAC_KEY_LEN 32
#define NVS_NAMESPACE "hmac"
#define NVS_KEY_NAME "key"
NimBLECharacteristic *pCharacteristic;
NimBLECharacteristic *pUuidChar;
NimBLECharacteristic *pEcdhChar;
bool relay1State = false;
bool relay2State = false;
BLECharacteristic *pCharacteristic;
bool deviceConnected = false;
bool bleCommand = false;
bool relayState = false;
uint32_t lastSeq = 0;
bool connActive[MAX_CLIENTS] = {false, false};
bool bleRelay1[MAX_CLIENTS] = {false, false};
bool bleRelay2[MAX_CLIENTS] = {false, false};
uint32_t lastSeq[MAX_CLIENTS] = {0, 0};
uint16_t connHandles[MAX_CLIENTS] = {0xFFFF, 0xFFFF};
int connectedCount = 0;
struct ClientState {
CryptoECDH ecdh;
uint8_t aesKey[AES_KEY_LEN];
bool ecdhReady;
};
ClientState clients[MAX_CLIENTS];
uint8_t psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
bool pskProvisioned = false;
bool uuidProvisioned = false;
uint8_t hmacKey[HMAC_KEY_LEN];
bool keyProvisioned = false;
Preferences preferences;
#define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0;
bool otaInProgress = false;
esp_ota_handle_t otaHandle = 0;
const esp_partition_t *otaPartition = nullptr;
uint32_t otaExpectedSize = 0;
uint32_t otaWritten = 0;
uint8_t otaExpectedSha[32];
mbedtls_sha256_context otaShaCtx;
bool otaShaStarted = false;
bool otaReady = false;
bool otaEnded = false;
bool otaConfirmed = false;
bool otaEarlyConfirm = false;
uint32_t otaEndedAt = 0;
#define OTA_CONFIRM_TIMEOUT_MS 10000
static int findClientIdx(uint16_t connHandle) {
for (int i = 0; i < MAX_CLIENTS; i++) {
if (connActive[i] && connHandles[i] == connHandle) return i;
class MyServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer *pServer) override {
deviceConnected = true;
lastSeq = 0;
Serial.println("BLE: device connected");
}
return -1;
}
class MyServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) override {
uint16_t handle = connInfo.getConnHandle();
int slot = -1;
for (int i = 0; i < MAX_CLIENTS; i++) {
if (!connActive[i]) {
slot = i;
break;
}
}
if (slot >= 0) {
connHandles[slot] = handle;
connActive[slot] = true;
bleRelay1[slot] = false;
bleRelay2[slot] = false;
lastSeq[slot] = 0;
clients[slot].ecdhReady = false;
memset(clients[slot].aesKey, 0, AES_KEY_LEN);
clients[slot].ecdh.free();
connectedCount++;
ESP_LOGI(TAG, "BLE: connected (slot=%d, clients=%d/%d, mac=%s)",
slot, connectedCount, MAX_CLIENTS,
connInfo.getAddress().toString().c_str());
} else {
ESP_LOGW(TAG, "BLE: connected but no free slot (clients=%d/%d)",
connectedCount, MAX_CLIENTS);
}
pServer->startAdvertising();
}
void onDisconnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo, int reason) override {
uint16_t handle = connInfo.getConnHandle();
for (int i = 0; i < MAX_CLIENTS; i++) {
if (connActive[i] && connHandles[i] == handle) {
connActive[i] = false;
bleRelay1[i] = false;
bleRelay2[i] = false;
lastSeq[i] = 0;
clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN);
clients[i].ecdh.free();
connHandles[i] = 0xFFFF;
connectedCount--;
ESP_LOGI(TAG, "BLE: disconnected (slot=%d, clients=%d/%d, mac=%s)",
i, connectedCount, MAX_CLIENTS,
connInfo.getAddress().toString().c_str());
break;
}
}
void onDisconnect(BLEServer *pServer) override {
deviceConnected = false;
bleCommand = false;
lastSeq = 0;
Serial.println("BLE: device disconnected");
BLEDevice::startAdvertising();
}
};
static bool decryptAesGcm(const uint8_t *packet, int idx, uint8_t *outCmd) {
if (idx < 0 || idx >= MAX_CLIENTS || !connActive[idx]) return false;
if (!clients[idx].ecdhReady) {
ESP_LOGW(TAG, "AES: ECDH not done for slot=%d", idx);
static bool verifyHmac(const uint8_t *packet) {
uint8_t cmd = packet[0];
uint32_t seq = ((uint32_t)packet[1] << 24) |
((uint32_t)packet[2] << 16) |
((uint32_t)packet[3] << 8) |
(uint32_t)packet[4];
if (seq <= lastSeq) {
Serial.printf("BLE: replay rejected (seq=%lu, last=%lu)\n", seq, lastSeq);
return false;
}
uint32_t seq = ((uint32_t)packet[0] << 24) |
((uint32_t)packet[1] << 16) |
((uint32_t)packet[2] << 8) |
(uint32_t)packet[3];
uint8_t hmacData[5];
hmacData[0] = cmd;
hmacData[1] = (seq >> 24) & 0xFF;
hmacData[2] = (seq >> 16) & 0xFF;
hmacData[3] = (seq >> 8) & 0xFF;
hmacData[4] = seq & 0xFF;
if (seq <= lastSeq[idx]) {
ESP_LOGW(TAG, "Replay rejected (seq=%lu, last=%lu, slot=%d)", seq, lastSeq[idx], idx);
uint8_t computed[32];
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(mdInfo, hmacKey, HMAC_KEY_LEN, hmacData, 5, computed);
if (memcmp(computed, packet + 5, 32) != 0) {
Serial.println("BLE: HMAC mismatch");
return false;
}
uint8_t nonce[12];
memcpy(nonce, packet, 4);
memcpy(nonce + 4, packet + 4, 8);
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, clients[idx].aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "AES: setkey failed (slot=%d, ret=%d)", idx, ret);
mbedtls_gcm_free(&gcm);
return false;
}
ret = mbedtls_gcm_auth_decrypt(&gcm,
1, // ciphertext length
nonce, 12, // iv + iv_len
NULL, 0, // aad + aad_len
packet + 13, 16, // tag + tag_len
packet + 12, // ciphertext
outCmd); // plaintext
mbedtls_gcm_free(&gcm);
if (ret != 0) {
ESP_LOGE(TAG, "AES-GCM decrypt failed (slot=%d, ret=%d)", idx, ret);
return false;
}
lastSeq[idx] = seq;
lastSeq = seq;
return true;
}
static void otaReset() {
otaInProgress = false;
otaHandle = 0;
otaPartition = nullptr;
otaExpectedSize = 0;
otaWritten = 0;
otaReady = false;
otaEnded = false;
otaConfirmed = false;
otaEarlyConfirm = false;
if (otaShaStarted) {
mbedtls_sha256_free(&otaShaCtx);
otaShaStarted = false;
static bool isHexKey(const String &hex) {
for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false;
}
return true;
}
static void parseHexKey(const String &hex, uint8_t *out) {
for (int i = 0; i < HMAC_KEY_LEN; i++) {
out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
}
static void sendOtaNotify(NimBLECharacteristic *otaChar, uint8_t status, uint32_t seq) {
uint8_t buf[5];
buf[0] = status;
buf[1] = (seq >> 24) & 0xFF;
buf[2] = (seq >> 16) & 0xFF;
buf[3] = (seq >> 8) & 0xFF;
buf[4] = seq & 0xFF;
otaChar->setValue(buf, 5);
bool ok = otaChar->notify();
if (!ok) {
ESP_LOGW(TAG, "OTA notify() failed (status=0x%02X seq=%lu)", status, seq);
}
}
static void abortOta(NimBLECharacteristic *otaChar, const char *reason) {
ESP_LOGE(TAG, "OTA: abort — %s", reason);
if (otaHandle != 0) {
esp_ota_end(otaHandle);
}
otaReset();
sendOtaNotify(otaChar, OTA_STATUS_ERROR, 0);
}
class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
class MyCharacteristicCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pChar) override {
std::string value = pChar->getValue();
if (value.empty()) return;
const uint8_t *data = (const uint8_t *)value.data();
size_t len = value.length();
if (len == 5 && data[0] == OTA_CMD_START) {
if (otaInProgress) {
esp_ota_end(otaHandle);
otaReset();
}
otaExpectedSize = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) |
((uint32_t)data[3] << 8) | (uint32_t)data[4];
otaPartition = esp_ota_get_next_update_partition(nullptr);
if (otaPartition == nullptr) {
ESP_LOGE(TAG, "OTA: no update partition found");
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
esp_err_t err = esp_ota_begin(otaPartition, otaExpectedSize, &otaHandle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: begin failed (%d)", err);
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
otaInProgress = true;
otaWritten = 0;
mbedtls_sha256_init(&otaShaCtx);
mbedtls_sha256_starts_ret(&otaShaCtx, 0);
otaShaStarted = true;
digitalWrite(RELAY1_PIN, LOW);
digitalWrite(RELAY2_PIN, LOW);
relay1State = false;
relay2State = false;
for (int i = 0; i < MAX_CLIENTS; i++) {
bleRelay1[i] = false;
bleRelay2[i] = false;
}
ESP_LOGI(TAG, "OTA: started, expecting %lu bytes", otaExpectedSize);
return;
}
if (len == 32 && otaInProgress) {
memcpy(otaExpectedSha, data, 32);
uint8_t computedSha[32];
mbedtls_sha256_finish_ret(&otaShaCtx, computedSha);
mbedtls_sha256_free(&otaShaCtx);
otaShaStarted = false;
ESP_LOGD(TAG, "OTA: SHA256 verify (written=%lu expected=%lu)", otaWritten, otaExpectedSize);
if (memcmp(computedSha, otaExpectedSha, 32) != 0) {
ESP_LOGE(TAG, "OTA: SHA256 mismatch — aborting");
esp_ota_abort(otaHandle);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
ESP_LOGI(TAG, "OTA: SHA256 OK, waiting for 0xFF confirm");
otaReady = true;
sendOtaNotify(pChar, OTA_STATUS_COMPLETE, 0);
return;
}
if (!otaInProgress) {
ESP_LOGD(TAG, "OTA: write ignored (not in progress, len=%d data0=0x%02X)", len, data[0]);
return;
}
if (len == 1 && data[0] == 0xFF) {
ESP_LOGD(TAG, "OTA: 0xFF received (ready=%d ended=%d confirmed=%d inProgress=%d)",
otaReady, otaEnded, otaConfirmed, otaInProgress);
if (!otaEnded) {
ESP_LOGD(TAG, "OTA: 0xFF arrived early, deferring");
otaEarlyConfirm = true;
return;
}
ESP_LOGI(TAG, "OTA: 0xFF confirm accepted");
otaConfirmed = true;
return;
}
if (data[0] < 0x10) {
uint32_t seq = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) |
((uint32_t)data[2] << 8) | (uint32_t)data[3];
size_t dataLen = len - 4;
mbedtls_sha256_update_ret(&otaShaCtx, data + 4, dataLen);
esp_err_t err = esp_ota_write(otaHandle, data + 4, dataLen);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: write error at seq %lu (%d)", seq, err);
sendOtaNotify(pChar, OTA_STATUS_NAK, seq);
abortOta(pChar, "write error");
return;
}
otaWritten += dataLen;
sendOtaNotify(pChar, OTA_STATUS_ACK, seq);
}
}
};
class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
int idx = findClientIdx(connInfo.getConnHandle());
if (idx < 0) return;
if (!pskProvisioned) {
ESP_LOGW(TAG, "ECDH: write rejected — no PSK (slot=%d)", idx);
return;
}
std::string value = pChar->getValue();
if (value.length() != ECDH_PKT_SIZE) {
ESP_LOGW(TAG, "ECDH: bad packet length %d (expected %d)", (int)value.length(), ECDH_PKT_SIZE);
if (value.length() != HMAC_PKT_SIZE) {
Serial.printf("BLE: bad packet length %d (expected %d)\n",
(int)value.length(), HMAC_PKT_SIZE);
return;
}
const uint8_t *pkt = (const uint8_t *)value.data();
if (!CryptoECDH::verifyPubkey(psk, pkt, pkt + ECDH_PUBKEY_LEN)) {
ESP_LOGW(TAG, "ECDH: pubkey HMAC mismatch (slot=%d)", idx);
if (!verifyHmac(pkt)) {
return;
}
uint8_t peerPubkey[ECDH_PUBKEY_LEN];
memcpy(peerPubkey, pkt, ECDH_PUBKEY_LEN);
clients[idx].ecdh.free();
if (!clients[idx].ecdh.init() || !clients[idx].ecdh.generateKeys()) {
ESP_LOGE(TAG, "ECDH: keygen failed (slot=%d)", idx);
return;
}
if (!clients[idx].ecdh.computeSharedSecret(peerPubkey, ECDH_PUBKEY_LEN)) {
ESP_LOGE(TAG, "ECDH: shared secret failed (slot=%d)", idx);
clients[idx].ecdh.free();
return;
}
memcpy(clients[idx].aesKey, clients[idx].ecdh.aes_key, AES_KEY_LEN);
uint8_t resp[ECDH_PKT_SIZE];
memcpy(resp, clients[idx].ecdh.public_key, ECDH_PUBKEY_LEN);
CryptoECDH::signPubkey(psk, clients[idx].ecdh.public_key, resp + ECDH_PUBKEY_LEN);
pChar->setValue(resp, ECDH_PKT_SIZE);
if (!pChar->notify()) {
ESP_LOGW(TAG, "ECDH: notify failed (slot=%d)", idx);
}
clients[idx].ecdhReady = true;
ESP_LOGI(TAG, "ECDH: handshake complete (slot=%d)", idx);
}
};
class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
int idx = findClientIdx(connInfo.getConnHandle());
if (idx < 0) {
ESP_LOGW(TAG, "BLE: write from unknown conn handle %d", connInfo.getConnHandle());
return;
}
std::string value = pChar->getValue();
if (value.length() != AES_PKT_SIZE) {
ESP_LOGW(TAG, "BLE: bad packet length %d (expected %d), slot=%d",
(int)value.length(), AES_PKT_SIZE, idx);
return;
}
const uint8_t *pkt = (const uint8_t *)value.data();
uint8_t cmd;
if (!decryptAesGcm(pkt, idx, &cmd)) {
return;
}
uint32_t seq = lastSeq[idx];
uint8_t cmd = pkt[0];
uint32_t seq = lastSeq;
if (cmd == 0x01) {
bleRelay1[idx] = true;
ESP_LOGI(TAG, "BLE: relay1 ON (seq=%lu, slot=%d)", seq, idx);
} else if (cmd == 0x00) {
bleRelay1[idx] = false;
ESP_LOGI(TAG, "BLE: relay1 OFF (seq=%lu, slot=%d)", seq, idx);
} else if (cmd == 0x02) {
bleRelay2[idx] = true;
ESP_LOGI(TAG, "BLE: relay2 ON (seq=%lu, slot=%d)", seq, idx);
} else if (cmd == 0x03) {
bleRelay2[idx] = false;
ESP_LOGI(TAG, "BLE: relay2 OFF (seq=%lu, slot=%d)", seq, idx);
bleCommand = true;
Serial.printf("BLE: relay ON (seq=%lu)\n", seq);
} else {
bleCommand = false;
Serial.printf("BLE: relay OFF (seq=%lu)\n", seq);
}
}
};
void setup() {
Serial.begin(115200);
delay(2000);
ESP_LOGI(TAG, "=== AtomS3 Lite BLE Relay v%s ===", FIRMWARE_VERSION);
FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
Serial.begin(115200);
delay(500);
Serial.println("=== Atom VoiceS3R BLE Relay ===");
preferences.begin(NVS_NAMESPACE, true);
size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN);
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
preferences.end();
if (pskLen == PSK_LEN) {
pskProvisioned = true;
ESP_LOGI(TAG, "PSK loaded from NVS");
if (len == HMAC_KEY_LEN) {
keyProvisioned = true;
Serial.println("HMAC key loaded from NVS");
} else {
pskProvisioned = false;
ESP_LOGW(TAG, "No PSK — connect USB and run SET_KEY");
keyProvisioned = false;
Serial.println("No HMAC key provisioned — connect USB and run SET_KEY");
}
if (uuidLen == ESP_UUID_LEN) {
uuidProvisioned = true;
ESP_LOGD(TAG, "UUID loaded from NVS");
} else {
uuidProvisioned = false;
for (int i = 0; i < ESP_UUID_LEN; i++) deviceUuid[i] = (uint8_t)(esp_random() & 0xFF);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
ESP_LOGD(TAG, "Generated new UUID");
pinMode(SENSOR_PIN, INPUT_PULLUP);
pinMode(RELAY_PIN, OUTPUT);
pinMode(LED_PIN, OUTPUT);
for (int i = 0; i < 3; i++) {
digitalWrite(LED_PIN, HIGH);
delay(200);
digitalWrite(LED_PIN, LOW);
delay(200);
}
Serial.println("LED blink done, starting BLE...");
for (int i = 0; i < MAX_CLIENTS; i++) {
clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN);
}
BLEDevice::init("AtomS3R-Relay");
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT);
pinMode(RELAY1_PIN, OUTPUT);
pinMode(RELAY2_PIN, OUTPUT);
BLESecurity *pSecurity = new BLESecurity();
pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_BOND);
pSecurity->setCapability(ESP_IO_CAP_NONE);
pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
leds[0] = CRGB::Red;
FastLED.show();
NimBLEDevice::init("SylDa_Server");
NimBLEDevice::setMTU(248);
NimBLEServer *pServer = NimBLEDevice::createServer();
BLEServer *pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks());
pServer->advertiseOnDisconnect(true);
NimBLEService *pService = pServer->createService(SERVICE_UUID);
BLEService *pService = pServer->createService(SERVICE_UUID);
pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
NIMBLE_PROPERTY::WRITE_NR
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY
);
pCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED);
pCharacteristic->addDescriptor(new BLE2902());
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
pEcdhChar = pService->createCharacteristic(
ECDH_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pEcdhChar->setCallbacks(new EcdhCharacteristicCallbacks());
pUuidChar = pService->createCharacteristic(
UUID_CHAR_UUID,
NIMBLE_PROPERTY::READ
);
pUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
NimBLECharacteristic *pVersionChar = pService->createCharacteristic(
VERSION_UUID,
NIMBLE_PROPERTY::READ
);
pVersionChar->setValue(FIRMWARE_VERSION);
NimBLECharacteristic *pOtaChar = pService->createCharacteristic(
OTA_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
pCharacteristic->setValue("0");
pService->start();
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("SylDa_Server");
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06);
pAdvertising->setMinPreferred(0x12);
BLEDevice::startAdvertising();
NimBLEAdvertisementData scanResp;
scanResp.setCompleteServices(NimBLEUUID(SERVICE_UUID));
Serial.println("BLE: advertising started as 'AtomS3R-Relay'");
uint8_t mfgPayload[6];
mfgPayload[0] = 0xFF;
mfgPayload[1] = 0xFF;
memcpy(mfgPayload + 2, deviceUuid, 4);
scanResp.setManufacturerData(std::string((char*)mfgPayload, 6));
pAdvertising->setScanResponseData(scanResp);
pAdvertising->enableScanResponse(true);
pAdvertising->setMinInterval(32);
pAdvertising->setMaxInterval(64);
bool advOk = pAdvertising->start();
ESP_LOGI(TAG, "BLE: advertising started");
if (pskProvisioned) {
ESP_LOGD(TAG, "PSK present");
if (keyProvisioned) {
Serial.print("HMAC Key: ");
for (int i = 0; i < HMAC_KEY_LEN; i++) {
Serial.printf("%02X", hmacKey[i]);
}
Serial.println();
} else {
ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision");
Serial.println("No HMAC key — use USB SET_KEY to provision");
}
ESP_LOGI(TAG, "Setup done.");
Serial.println("Setup done.");
}
void loop() {
if (!otaInProgress && Serial.available()) {
if (Serial.available()) {
static String serialBuf;
char c = Serial.read();
serialBuf += c;
if (c == '\n') {
serialBuf.trim();
if (serialBuf == "KEY?") {
if (pskProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
Serial.print("PSK:");
for (int i = 0; i < PSK_LEN; i++) {
Serial.printf("%02X", psk[i]);
if (keyProvisioned) {
Serial.print("HMAC_KEY:");
for (int i = 0; i < HMAC_KEY_LEN; i++) {
Serial.printf("%02X", hmacKey[i]);
}
Serial.println();
} else {
Serial.println("NO_KEY");
}
} else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Server");
delay(50);
unmuteSerial();
} else if (serialBuf == "VERSION?") {
Serial.flush();
Serial.print("BUILD_NR:");
Serial.println(FIRMWARE_VERSION);
} else if (serialBuf.startsWith("SET_KEY:")) {
String hex = serialBuf.substring(8);
hex.toUpperCase();
if (hex.length() == 64 && isHex(hex)) {
parseHex(hex, psk, PSK_LEN);
if (hex.length() == 64 && isHexKey(hex)) {
parseHexKey(hex, hmacKey);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN);
preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
preferences.end();
pskProvisioned = true;
keyProvisioned = true;
Serial.println("OK");
Serial.println("PSK stored in NVS");
Serial.println("HMAC key stored in NVS");
} else {
Serial.println("ERROR: invalid key format");
}
} else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7);
hex.toUpperCase();
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, deviceUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
pUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
uint8_t mfgPayload[6];
mfgPayload[0] = 0xFF;
mfgPayload[1] = 0xFF;
memcpy(mfgPayload + 2, deviceUuid, 4);
NimBLEAdvertisementData scanResp;
scanResp.setCompleteServices(NimBLEUUID(SERVICE_UUID));
scanResp.setManufacturerData(std::string((char*)mfgPayload, 6));
NimBLEDevice::getAdvertising()->setScanResponseData(scanResp);
Serial.println("OK");
Serial.println("UUID stored in NVS");
} else {
Serial.println("ERROR: invalid UUID format");
}
}
serialBuf = "";
}
}
if (otaReady) {
ESP_LOGD(TAG, "OTA: processing otaReady (written=%lu expected=%lu)",
otaWritten, otaExpectedSize);
esp_err_t err = esp_ota_end(otaHandle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: esp_ota_end failed (%d) — aborting", err);
otaReset();
} else {
ESP_LOGI(TAG, "OTA: esp_ota_end OK, waiting for 0xFF confirm");
otaEnded = true;
otaEndedAt = millis();
}
otaReady = false;
}
int sensorState = digitalRead(SENSOR_PIN);
if (otaEnded && otaEarlyConfirm) {
ESP_LOGD(TAG, "OTA: processing deferred 0xFF confirm");
otaConfirmed = true;
otaEarlyConfirm = false;
}
bool touchActive = (sensorState == LOW);
bool bleActive = (deviceConnected && bleCommand);
if (otaEnded && !otaConfirmed && (millis() - otaEndedAt > OTA_CONFIRM_TIMEOUT_MS)) {
ESP_LOGW(TAG, "OTA: 0xFF confirm timeout (%lu ms) — discarding update",
millis() - otaEndedAt);
otaReset();
}
relayState = touchActive || bleActive;
if (otaConfirmed) {
ESP_LOGI(TAG, "OTA: confirmed, setting boot partition");
esp_err_t err = esp_ota_set_boot_partition(otaPartition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: set boot partition failed (%d)", err);
} else {
leds[0] = CRGB::Green;
FastLED.show();
ESP_LOGI(TAG, "OTA: done, rebooting...");
delay(500);
esp_restart();
}
otaReset();
}
digitalWrite(RELAY_PIN, relayState ? HIGH : LOW);
static bool t1Stable = false;
static bool t2Stable = false;
static bool btnStable = false;
static uint32_t t1Change = 0;
static uint32_t t2Change = 0;
static uint32_t btnChange = 0;
#define DEBOUNCE_MS 50
bool t1Raw = (digitalRead(TOUCH1_PIN) == HIGH);
bool t2Raw = (digitalRead(TOUCH2_PIN) == HIGH);
bool btnRaw = (digitalRead(BUTTON_PIN) == LOW);
if (t1Raw != t1Stable) { t1Change = millis(); t1Stable = t1Raw; }
if (t2Raw != t2Stable) { t2Change = millis(); t2Stable = t2Raw; }
if (btnRaw != btnStable) { btnChange = millis(); btnStable = btnRaw; }
bool t1Active = (millis() - t1Change > DEBOUNCE_MS) && t1Stable;
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
if (!otaInProgress) {
relay1State = !t1Active || btnActive ||
(connActive[0] && bleRelay1[0]) || (connActive[1] && bleRelay1[1]);
relay2State = !t2Active ||
(connActive[0] && bleRelay2[0]) || (connActive[1] && bleRelay2[1]);
} else {
relay1State = false;
relay2State = false;
}
CRGB ledColor;
if (otaInProgress) {
ledColor = otaEnded
? CRGB::Yellow
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);
} else {
if (connectedCount > 0) {
if (connectionStableMs == 0) connectionStableMs = millis();
} else {
connectionStableMs = 0;
}
if (connectedCount > 0) {
ledColor = (millis() - connectionStableMs >= STABLE_LED_TIMEOUT_MS)
? CRGB::Black
: CRGB::Yellow;
} else {
ledColor = CRGB::Red;
}
static bool lastRelayActive = false;
bool relayActive = relay1State || relay2State;
if (relayActive != lastRelayActive) {
relayFlashUntil = millis() + 150;
}
lastRelayActive = relayActive;
if (millis() < relayFlashUntil) {
ledColor = CRGB::Green;
}
}
leds[0] = ledColor;
digitalWrite(RELAY1_PIN, relay1State ? HIGH : LOW);
digitalWrite(RELAY2_PIN, relay2State ? HIGH : LOW);
FastLED.show();
static bool lastRelay1 = false;
static bool lastRelay2 = false;
if (relay1State != lastRelay1 || relay2State != lastRelay2) {
bool anyBle1 = (connActive[0] && bleRelay1[0]) || (connActive[1] && bleRelay1[1]);
bool anyBle2 = (connActive[0] && bleRelay2[0]) || (connActive[1] && bleRelay2[1]);
ESP_LOGI(TAG, "Relays: R1=%s R2=%s (t1=%s t2=%s btn=%s ble1=%s ble2=%s)",
relay1State ? "ON" : "OFF",
relay2State ? "ON" : "OFF",
!t1Active ? "YES" : "no",
!t2Active ? "YES" : "no",
btnActive ? "YES" : "no",
anyBle1 ? "ON" : "OFF",
anyBle2 ? "ON" : "OFF");
lastRelay1 = relay1State;
lastRelay2 = relay2State;
static bool lastRelay = false;
if (relayState != lastRelay) {
Serial.printf("Relay %s (touch=%s, ble=%s)\n",
relayState ? "ON" : "OFF",
sensorState == LOW ? "YES" : "no",
deviceConnected ? "connected" : "disconnected");
lastRelay = relayState;
}
static uint32_t heartbeat = 0;
if (!otaInProgress && millis() - heartbeat > 60000) {
bool anyBle1 = (connActive[0] && bleRelay1[0]) || (connActive[1] && bleRelay1[1]);
bool anyBle2 = (connActive[0] && bleRelay2[0]) || (connActive[1] && bleRelay2[1]);
ESP_LOGD(TAG, "[heartbeat] R1=%s R2=%s t1=%s t2=%s btn=%s ble1=%s ble2=%s clients=%d",
relay1State ? "ON" : "OFF",
relay2State ? "ON" : "OFF",
!t1Active ? "YES" : "no",
!t2Active ? "YES" : "no",
btnActive ? "YES" : "no",
anyBle1 ? "ON" : "OFF",
anyBle2 ? "ON" : "OFF",
connectedCount);
if (millis() - heartbeat > 5000) {
Serial.printf("[heartbeat] relay=%s touch=%s ble=%s\n",
relayState ? "ON" : "OFF",
sensorState == LOW ? "YES" : "no",
deviceConnected ? "connected" : "disconnected");
heartbeat = millis();
}

View file

@ -1,18 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <Arduino.h>
static inline bool isHex(const String &hex) {
for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false;
}
return true;
}
static inline void parseHex(const String &hex, uint8_t *out, int len) {
for (int i = 0; i < len; i++) {
out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
}

View file

@ -1,75 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
pass() { echo -e " ${GREEN}OK${NC} $1"; }
fail() { echo -e " ${RED}MISMATCH${NC} $1"; }
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
VERSION_FILE="$PROJECT_DIR/version.txt"
HEADER_FILE="$PROJECT_DIR/src/version.h"
KOTLIN_FILE="$PROJECT_DIR/android/app/src/main/java/nl/eddydevink/sylda/SylDaVersions.kt"
errors=0
echo "=== Version consistency check ==="
# 1. Parse version.txt (source of truth)
if [ ! -f "$VERSION_FILE" ]; then
fail "version.txt not found at $VERSION_FILE"
exit 1
fi
src_server=$(grep -oP '^server=\K\d+' "$VERSION_FILE" || true)
src_client=$(grep -oP '^client=\K\d+' "$VERSION_FILE" || true)
if [ -z "$src_server" ] || [ -z "$src_client" ]; then
fail "version.txt missing server= or client="
exit 1
fi
pass "version.txt server=$src_server client=$src_client"
# 2. Parse src/version.h (generated by pio run)
if [ ! -f "$HEADER_FILE" ]; then
fail "src/version.h not found — run 'pio run' first"
((errors++))
else
hdr_server=$(grep -oP 'FIRMWARE_VERSION_SERVER\s+"\K\d+' "$HEADER_FILE" || true)
hdr_client=$(grep -oP 'FIRMWARE_VERSION_CLIENT\s+"\K\d+' "$HEADER_FILE" || true)
if [ "$hdr_server" = "$src_server" ] && [ "$hdr_client" = "$src_client" ]; then
pass "src/version.h server=$hdr_server client=$hdr_client"
else
fail "src/version.h server=${hdr_server:-MISSING} client=${hdr_client:-MISSING} (expected server=$src_server client=$src_client)"
((errors++))
fi
fi
# 3. Parse SylDaVersions.kt (generated by Gradle build)
if [ ! -f "$KOTLIN_FILE" ]; then
fail "SylDaVersions.kt not found — run './gradlew build' first"
((errors++))
else
kt_server=$(grep -oP 'const val SERVER = "\K\d+' "$KOTLIN_FILE" || true)
kt_client=$(grep -oP 'const val CLIENT = "\K\d+' "$KOTLIN_FILE" || true)
if [ "$kt_server" = "$src_server" ] && [ "$kt_client" = "$src_client" ]; then
pass "SylDaVersions.kt server=$kt_server client=$kt_client"
else
fail "SylDaVersions.kt server=${kt_server:-MISSING} client=${kt_client:-MISSING} (expected server=$src_server client=$src_client)"
((errors++))
fi
fi
echo ""
if [ "$errors" -eq 0 ]; then
echo -e "${GREEN}All version files in sync.${NC}"
exit 0
else
echo -e "${RED}$errors version file(s) out of sync with version.txt.${NC}"
echo "Run 'pio run' and './gradlew build' to regenerate."
exit 1
fi

View file

@ -1,29 +0,0 @@
#!/bin/bash
set -e
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ASSETS_BASE="$PROJECT_DIR/android/app/src/main/assets/firmware"
copy_binaries() {
local BINARY_DIR="$1"
local ASSETS_DIR="$2"
mkdir -p "$ASSETS_DIR"
cp "$BINARY_DIR/bootloader.bin" "$ASSETS_DIR/"
cp "$BINARY_DIR/partitions.bin" "$ASSETS_DIR/"
cp "$BINARY_DIR/firmware.bin" "$ASSETS_DIR/"
BOOT_APP0=$(find ~/.platformio/packages/framework-arduinoespressif32 -name "boot_app0.bin" 2>/dev/null | head -1)
if [ -n "$BOOT_APP0" ]; then
cp "$BOOT_APP0" "$ASSETS_DIR/"
else
echo "ERROR: boot_app0.bin not found"
exit 1
fi
echo "Copied to $ASSETS_DIR:"
ls -la "$ASSETS_DIR"
}
copy_binaries "$PROJECT_DIR/.pio/build/s3lite-server" "$ASSETS_BASE/server"
copy_binaries "$PROJECT_DIR/.pio/build/s3lite-client" "$ASSETS_BASE/client"

View file

@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""POST-script: auto-detect upload_port by querying connected ESP32s.
Each ESP32 running SylDa firmware responds to the serial command "ID?"
with either "SylDa_Server" or "SylDa_Client". This script scans
/dev/serial/by-id/ for Espressif devices, queries each one, and sets
UPLOAD_PORT for the matching environment.
If no device responds (e.g. first flash), the user is prompted to
select a port manually.
"""
Import("env")
if env.IsIntegrationDump():
Return()
ENV_TO_ID = {
"s3lite-server": "SylDa_Server",
"s3lite-client": "SylDa_Client",
}
import glob
import os
import sys
import time
try:
import serial
except ImportError:
print(" WARNING: pyserial not installed, skipping auto-detect")
print(" Install: pip install pyserial")
Return()
def find_esp32_ports():
by_id = "/dev/serial/by-id"
if not os.path.isdir(by_id):
return []
ports = []
for entry in sorted(os.listdir(by_id)):
if "Espressif" in entry or "espressif" in entry:
ports.append(os.path.join(by_id, entry))
return ports
def query_id(port, timeout=5):
try:
ser = serial.Serial(port, 115200, timeout=0.5)
time.sleep(0.1)
ser.reset_input_buffer()
ser.write(b"ID?\n")
deadline = time.time() + timeout
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if "SylDa_Server" in line or "SylDa_Client" in line:
ser.close()
return line
ser.close()
except (serial.SerialException, OSError):
pass
return None
def detect_port(source, target, env):
if env.subst("$UPLOAD_PORT"):
print(f" Upload port already set to {env.subst('$UPLOAD_PORT')}, skipping auto-detect")
return
env_name = env.subst("$PIOENV")
expected_id = ENV_TO_ID.get(env_name)
if not expected_id:
print(f" Unknown environment '{env_name}', skipping auto-detect")
return
ports = find_esp32_ports()
if not ports:
print(f" No ESP32 serial ports found in /dev/serial/by-id/")
print(f" Connect an ESP32 and try again")
sys.exit(1)
if len(ports) == 1:
port = ports[0]
resp = query_id(port, timeout=5)
if resp and expected_id in resp:
print(f" Auto-detected {expected_id} on {port}")
else:
if resp:
print(f" Found '{resp}' on {port} (expected {expected_id})")
print(f" Only one ESP32 connected, using {port}")
env["UPLOAD_PORT"] = port
return
for port in ports:
resp = query_id(port, timeout=5)
if resp and expected_id in resp:
print(f" Found {expected_id} on {port}")
env["UPLOAD_PORT"] = port
return
print(f"\n Cannot auto-detect {expected_id} (no ID? response matched)")
print(f" Available ESP32 ports:")
for i, port in enumerate(ports, 1):
resp = query_id(port, timeout=3)
label = f" ({resp})" if resp else ""
print(f" {i}: {port}{label}")
try:
choice = input(f" Select port for {env_name} [{expected_id}] (1-{len(ports)}): ")
idx = int(choice) - 1
if 0 <= idx < len(ports):
env["UPLOAD_PORT"] = ports[idx]
return
except (ValueError, EOFError):
pass
print(" Invalid choice, aborting upload")
sys.exit(1)
env.AddPreAction("upload", detect_port)

View file

@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""Pre-build: generate src/version.h from version.txt so the firmware
uses the single source of truth for version numbers."""
Import("env")
import os
project_dir = env.subst("$PROJECT_DIR")
ver_file = os.path.join(project_dir, "version.txt")
out_file = os.path.join(project_dir, "src", "version.h")
versions = {}
with open(ver_file) as f:
for line in f:
line = line.strip()
if "=" in line:
k, v = line.split("=", 1)
versions[k.strip()] = v.strip()
header = f"""// Auto-generated from version.txt — DO NOT EDIT
#define FIRMWARE_VERSION_SERVER "{versions['server']}"
#define FIRMWARE_VERSION_CLIENT "{versions['client']}"
"""
with open(out_file, "w") as f:
f.write(header)
print(f" versions: server={versions['server']} client={versions['client']} -> src/version.h")

View file

@ -1,123 +0,0 @@
#!/usr/bin/env python3
"""Test OTA notify flow: writes 0x02 to server's CLIENT_OTA_STATUS via BLE,
then checks client serial for peripheral-mode entry log."""
import sys, time, asyncio, serial, threading, os
from bleak import BleakScanner, BleakClient
SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
STATUS_UUID = "beb5483e-36e1-4688-b7f5-ea07361f26df"
SERVER_NAME = "SylDa_Server"
CLIENT_PORT = os.environ.get("SYLDA_CLIENT_PORT", "/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_44:1B:F6:F5:D1:E0-if00")
TIMEOUT_S = 25
class TestResult:
def __init__(self):
self.pass_marker = False
self.fail_marker = False
self.lines = []
def monitor_serial(stop_event, result):
try:
ser = serial.Serial(CLIENT_PORT, 115200, timeout=0.5)
print(f"[TEST] Serial opened: {CLIENT_PORT}")
while not stop_event.is_set():
try:
data = ser.read(1024)
if data:
text = data.decode("ascii", errors="replace")
for line in text.splitlines():
if line.strip():
result.lines.append(line)
if "peripheral" in line.lower():
result.pass_marker = True
stop_event.set()
print(f"[TEST] PASS: {line}")
if "OTA: received trigger" in line or "entering peripheral" in line:
result.pass_marker = True
stop_event.set()
print(f"[TEST] PASS: {line}")
except serial.SerialException:
result.fail_marker = True
stop_event.set()
print("[TEST] FAIL: serial exception")
except Exception as e:
result.fail_marker = True
stop_event.set()
print(f"[TEST] FAIL: {e}")
finally:
try: ser.close()
except: pass
async def ble_write_02(result):
print("[TEST] Scanning for SylDa_Server...")
try:
device = await BleakScanner.find_device_by_name(SERVER_NAME, timeout=10)
except Exception as e:
print(f"[TEST] FAIL: BLE scan error: {e}")
result.fail_marker = True
return
if device is None:
print("[TEST] FAIL: SylDa_Server not found via BLE scan")
result.fail_marker = True
return
print(f"[TEST] Found: {device.name} ({device.address}), connecting...")
try:
async with BleakClient(device, timeout=10) as client:
print(f"[TEST] Connected, writing 0x02 to {STATUS_UUID}...")
svc = client.services.get_service(SERVICE_UUID)
if svc is None:
print("[TEST] FAIL: service not found")
result.fail_marker = True
return
char = svc.get_characteristic(STATUS_UUID)
if char is None:
print("[TEST] FAIL: CLIENT_OTA_STATUS characteristic not found")
result.fail_marker = True
return
await client.write_gatt_char(char, b'\x02', response=True)
print("[TEST] Wrote 0x02 OK, waiting for client to enter peripheral mode...")
except Exception as e:
print(f"[TEST] FAIL: BLE write error: {e}")
result.fail_marker = True
return
async def main():
result = TestResult()
stop = threading.Event()
serial_thread = threading.Thread(target=monitor_serial, args=(stop, result), daemon=True)
serial_thread.start()
await asyncio.sleep(2)
await ble_write_02(result)
if not stop.is_set():
print(f"[TEST] Waiting up to {TIMEOUT_S}s for client response...")
stop.wait(TIMEOUT_S)
stop.set()
serial_thread.join(timeout=3)
if result.pass_marker:
print("[TEST] ===== PASS =====")
return 0
elif result.fail_marker:
print("[TEST] ===== FAIL =====")
print("[TEST] Serial lines captured:")
for line in result.lines[-20:]:
print(f" {line}")
return 1
else:
print("[TEST] ===== INDETERMINATE (timeout, no pass/fail marker) =====")
print("[TEST] Serial lines captured:")
for line in result.lines[-20:]:
print(f" {line}")
return 2
if __name__ == "__main__":
rc = asyncio.run(main())
sys.exit(rc)

View file

@ -1,2 +0,0 @@
server=20
client=5