Project_SylDa/docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md

1075 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SylDa NimBLE + HMAC — 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:** Stable firmware basis: 1× AtomS3 Lite server + 1× AtomS3 Lite client + Android app, NimBLE + UUID + HMAC-SHA256.
**Architecture:** New branch from `main`, modify `src/main.cpp` for per-connection tracking and AtomS3 Lite pinmap, create `src/client.cpp` for touch-sensor client, update Android app with device/transport radio buttons.
**Tech Stack:** PlatformIO espressif32@6.7.0, NimBLE-Arduino@^2, mbedtls (HMAC-SHA256), FastLED@^3, Kotlin/Android
---
## File Map
| File | Action | Responsibility |
|------|--------|---------------|
| `version.txt` | Create | Single source of truth: server=6, client=1 |
| `tools/pio_version_pre.py` | Copy from HEAD | Pre-build: version.txt → src/version.h |
| `src/version.h` | Auto-generated | FIRMWARE_VERSION_SERVER, FIRMWARE_VERSION_CLIENT |
| `platformio.ini` | Modify | s3lite-server + s3lite-client envs, qio_qspi |
| `src/main.cpp` | Modify from main | Server: per-connection, G39/G38 relays, G35 LED |
| `src/client.cpp` | Create | Client: touch/button, BLE scan, HMAC commands |
| `android/.../SettingsActivity.kt` | Modify | Radio buttons [Server\|Client] [BLE\|USB] |
| `android/.../EspFlasher.kt` | Modify | Split assets server/client/ |
| `android/.../PrefsHelper.kt` | Modify | Extra keys: deviceMode, serverUuid, clientUuid |
| `android/.../MainActivity.kt` | Modify | deviceMode routing (controls vs OTA) |
---
### Task 1: Create branch and version infrastructure
**Files:**
- Create: `version.txt`
- Modify: `platformio.ini`
- [ ] **Step 1: Create new branch from main**
```bash
git checkout main && git checkout -b sylda-nimble-hmac
```
- [ ] **Step 2: Create version.txt**
```bash
cat > version.txt << 'EOF'
server=6
client=1
EOF
```
- [ ] **Step 3: Verify pio_version_pre.py exists and works**
```bash
ls -la tools/pio_version_pre.py
# Expected: file exists
```
- [ ] **Step 4: Add version.txt + auto-generated file to .gitignore**
Check that `.gitignore` already contains `src/version.h`. If not, add it:
```bash
echo "src/version.h" >> .gitignore
```
- [ ] **Step 5: Rewrite platformio.ini**
Replace the entire content of `platformio.ini`:
```ini
[env:s3lite-server]
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=5
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
lib_deps =
fastled/FastLED@^3
h2zero/NimBLE-Arduino@^2
upload_port = /dev/ttyACM1
upload_speed = 115200
upload_flags = --no-stub
build_src_filter = +<main.cpp> -<client.cpp>
extra_scripts = pre:tools/pio_version_pre.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=5
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DDEVICE_ID=1
lib_deps =
fastled/FastLED@^3
h2zero/NimBLE-Arduino@^2
upload_port = /dev/ttyACM2
upload_speed = 115200
upload_flags = --no-stub
build_src_filter = +<client.cpp> -<main.cpp>
extra_scripts = pre:tools/pio_version_pre.py
```
- [ ] **Step 6: Compile both envs to verify build infrastructure**
```bash
pio run -e s3lite-server 2>&1 | tail -3
# Expected: FAILED (src/main.cpp still has old pinmap + 1-client code — needs modifications)
pio run -e s3lite-client 2>&1 | tail -3
# Expected: FAILED (src/client.cpp doesn't exist yet — needs creation)
```
- [ ] **Step 7: Commit**
```bash
git add version.txt platformio.ini tools/pio_version_pre.py .gitignore
git commit -m "chore: branch setup — version.txt, platformio.ini envs, qio_qspi"
```
---
### Task 2: Server firmware — hardware + per-connection tracking
**Files:**
- Modify: `src/main.cpp`
- [ ] **Step 1: Read the current server code from main branch for reference**
No action — the file already exists from `git checkout main`. Moving on.
- [ ] **Step 2: Replace includes and defines at the top**
Replace the top section (includes through `#define FIRMWARE_VERSION`) with:
```cpp
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <NimBLEAdvertising.h>
#include <mbedtls/md.h>
#include <mbedtls/sha256.h>
#include <Preferences.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <nvs_flash.h>
#include <FastLED.h>
#include "version.h"
#define RELAY1_PIN 39
#define RELAY2_PIN 38
#define TOUCH1_PIN 8
#define TOUCH2_PIN 6
#define BUTTON_PIN 41
#define LED_PIN 35
#define NUM_LEDS 1
#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 HMAC_PKT_SIZE 37
#define OTA_CHUNK_SIZE 240
#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 HMAC_KEY_LEN 32
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac"
#define NVS_KEY_NAME "key"
#define NVS_UUID_NAME "uuid"
#define MAX_CLIENTS 2
```
- [ ] **Step 3: Replace global state variables**
Replace `NimBLECharacteristic *pCharacteristic;` through `Preferences preferences;` (the old global state section) with:
```cpp
NimBLECharacteristic *pCharacteristic;
CRGB leds[NUM_LEDS];
bool bleRelay1[MAX_CLIENTS] = {false, false};
bool bleRelay2[MAX_CLIENTS] = {false, false};
bool connActive[MAX_CLIENTS] = {false, false};
int connectedCount = 0;
uint32_t lastSeq[MAX_CLIENTS] = {0, 0};
bool relay1State = false;
bool relay2State = false;
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;
uint8_t hmacKey[HMAC_KEY_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
bool keyProvisioned = false;
bool uuidProvisioned = false;
Preferences preferences;
```
- [ ] **Step 4: Replace server callbacks with per-connection version**
Replace `class MyServerCallbacks` entirely with:
```cpp
class MyServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) override {
uint16_t handle = connInfo.getConnHandle();
int idx = -1;
for (int i = 0; i < MAX_CLIENTS; i++) {
if (!connActive[i]) { idx = i; break; }
}
if (idx < 0) return;
connActive[idx] = true;
connectedCount++;
lastSeq[idx] = 0;
leds[0] = CRGB::Blue;
FastLED.show();
Serial.printf("SRV: connected idx=%d handle=%d (%d/%d)\n", idx, handle, connectedCount, MAX_CLIENTS);
if (connectedCount < MAX_CLIENTS) NimBLEDevice::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]) {
connActive[i] = false;
bleRelay1[i] = false;
bleRelay2[i] = false;
connectedCount--;
Serial.printf("SRV: disconnected idx=%d handle=%d (%d/%d)\n", i, handle, connectedCount, MAX_CLIENTS);
break;
}
}
if (connectedCount <= 0) {
connectedCount = 0;
leds[0] = CRGB::Red;
FastLED.show();
}
NimBLEDevice::startAdvertising();
}
};
```
- [ ] **Step 5: Update HMAC verify to be per-connection**
Replace `static bool verifyHmac` with:
```cpp
static bool verifyHmac(const uint8_t *packet, int connIdx) {
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[connIdx]) {
Serial.printf("SRV: replay idx=%d seq=%lu last=%lu\n", connIdx, seq, lastSeq[connIdx]);
return false;
}
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;
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("SRV: HMAC mismatch");
return false;
}
lastSeq[connIdx] = seq;
return true;
}
```
- [ ] **Step 6: Update characteristic callback to resolve connIdx**
Replace `class MyCharacteristicCallbacks` with:
```cpp
class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
std::string value = pChar->getValue();
if (value.length() != HMAC_PKT_SIZE) {
Serial.printf("SRV: bad pkt len %d\n", (int)value.length());
return;
}
uint16_t handle = connInfo.getConnHandle();
int connIdx = -1;
for (int i = 0; i < MAX_CLIENTS; i++) {
if (connActive[i]) { connIdx = i; break; }
}
if (connIdx < 0) { Serial.println("SRV: write from unknown conn"); return; }
const uint8_t *pkt = (const uint8_t *)value.data();
if (!verifyHmac(pkt, connIdx)) return;
uint8_t cmd = pkt[0];
uint32_t seq = lastSeq[connIdx];
if (cmd == 0x01) { bleRelay1[connIdx] = true; Serial.printf("SRV: R1 ON idx=%d seq=%lu\n", connIdx, seq); }
else if (cmd == 0x00) { bleRelay1[connIdx] = false; Serial.printf("SRV: R1 OFF idx=%d seq=%lu\n", connIdx, seq); }
else if (cmd == 0x02) { bleRelay2[connIdx] = true; Serial.printf("SRV: R2 ON idx=%d seq=%lu\n", connIdx, seq); }
else if (cmd == 0x03) { bleRelay2[connIdx] = false; Serial.printf("SRV: R2 OFF idx=%d seq=%lu\n", connIdx, seq); }
}
};
```
- [ ] **Step 7: Update setup() — replace LED init with FastLED**
Replace the LED pin mode and blink section in `setup()` (the part with `pinMode(LED_PIN, OUTPUT)` through the 3-blink loop) with:
```cpp
FastLED.addLeds<NEOPIXEL, LED_PIN>(leds, NUM_LEDS);
for (int i = 0; i < 3; i++) {
leds[0] = CRGB::Blue;
FastLED.show();
delay(200);
leds[0] = CRGB::Black;
FastLED.show();
delay(200);
}
leds[0] = CRGB::Red;
FastLED.show();
Serial.println("LED blink done, starting BLE...");
```
- [ ] **Step 8: Replace FIRMWARE_VERSION reference in version characteristic**
Replace `pVersionChar->setValue(FIRMWARE_VERSION)` with:
```cpp
pVersionChar->setValue(FIRMWARE_VERSION_SERVER);
```
- [ ] **Step 9: Add advertiseOnDisconnect in setup**
After `pServer->setCallbacks(new MyServerCallbacks());`, add:
```cpp
pServer->advertiseOnDisconnect(true);
```
- [ ] **Step 10: Add advertiseOnConnect restart in setup**
No additional code — the restart is in `onConnect` callback. The `advertiseOnDisconnect(true)` handles the disconnect case.
- [ ] **Step 11: Update relay logic in loop() to OR across connections**
Replace the relay logic block (the whole `if (!otaInProgress) { relay1State = ...` block) 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;
ledOtaBusy();
}
```
- [ ] **Step 12: Replace LED digitalWrite with FastLED**
Replace `digitalWrite(LED_PIN, (relay1State || relay2State) ? HIGH : LOW);` with:
```cpp
leds[0] = (relay1State || relay2State) ? CRGB::Green : CRGB::Red;
FastLED.show();
```
Also remove all `digitalWrite(LED_PIN, ...)` calls elsewhere and replace with FastLED. In `ledOtaBusy()`:
```cpp
static void ledOtaBusy() {
leds[0] = (millis() / 100) % 2 ? CRGB::Blue : CRGB::Black;
FastLED.show();
}
```
Also replace `digitalWrite(LED_PIN, HIGH);` in OTA complete section with:
```cpp
leds[0] = CRGB::Green;
FastLED.show();
```
- [ ] **Step 13: Update relay pinmode in setup (RELAY1_PIN=39, RELAY2_PIN=38)**
The `pinMode(RELAY1_PIN, OUTPUT)` and `pinMode(RELAY2_PIN, OUTPUT)` already use the defines — these update automatically from Step 2.
- [ ] **Step 14: Update heartbeat to show per-connection state**
Replace the heartbeat `Serial.printf` at the end of `loop()` with:
```cpp
Serial.printf("[srv] R1=%s R2=%s conn=%d t1=%s t2=%s btn=%s ble1=%s/%s ble2=%s/%s\n",
relay1State ? "ON" : "OFF", relay2State ? "ON" : "OFF", connectedCount,
t1Active ? "YES" : "no", t2Active ? "YES" : "no", btnActive ? "YES" : "no",
connActive[0] ? (bleRelay1[0] ? "R1" : "--") : "off",
connActive[0] ? (bleRelay2[0] ? "R2" : "--") : " ",
connActive[1] ? (bleRelay1[1] ? "R1" : "--") : "off",
connActive[1] ? (bleRelay2[1] ? "R2" : "--") : " ");
```
- [ ] **Step 15: Compile server**
```bash
pio run -e s3lite-server 2>&1 | tail -3
# Expected: SUCCESS
```
- [ ] **Step 16: Commit**
```bash
git add src/main.cpp
git commit -m "feat: server per-connection tracking, AtomS3 Lite pinmap, FastLED"
```
---
### Task 3: Client firmware
**Files:**
- Create: `src/client.cpp`
- [ ] **Step 1: Create src/client.cpp**
Write the complete client firmware:
```cpp
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <FastLED.h>
#include <mbedtls/md.h>
#include <mbedtls/sha256.h>
#include <Preferences.h>
#include "version.h"
#define LED_PIN 35
#define NUM_LEDS 1
#define TOUCH1_PIN 8
#define TOUCH2_PIN 6
#define BUTTON_PIN 41
#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 HMAC_PKT_SIZE 37
#define HMAC_KEY_LEN 32
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac"
#define NVS_KEY_NAME "key"
#define NVS_UUID_NAME "uuid"
#define NVS_SERVER_NAME "server"
uint8_t hmacKey[HMAC_KEY_LEN];
uint8_t myUuid[ESP_UUID_LEN];
uint8_t serverUuid[ESP_UUID_LEN];
bool keyProvisioned = false;
bool uuidProvisioned = false;
bool serverProvisioned = false;
Preferences preferences;
CRGB leds[NUM_LEDS];
NimBLEClient *pClient = nullptr;
NimBLERemoteCharacteristic *pColorChar = nullptr;
NimBLERemoteCharacteristic *pUuidChar = nullptr;
bool connected = false;
bool connectRequested = false;
bool bleScanning = false;
NimBLEAddress serverAddress;
uint32_t seq = 0;
uint32_t lastScanMs = 0;
static void buildHmacPacket(uint8_t cmd, uint8_t *packet) {
uint32_t s = ++seq;
packet[0] = cmd;
packet[1] = (s >> 24) & 0xFF;
packet[2] = (s >> 16) & 0xFF;
packet[3] = (s >> 8) & 0xFF;
packet[4] = s & 0xFF;
uint8_t hmacData[5];
hmacData[0] = cmd;
memcpy(hmacData + 1, packet + 1, 4);
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(mdInfo, hmacKey, HMAC_KEY_LEN, hmacData, 5, packet + 5);
}
class ScanCallbacks : public NimBLEAdvertisedDeviceCallbacks {
void onResult(NimBLEAdvertisedDevice *device) override {
if (connected) return;
if (device->getName() == "SylDa_Server") {
Serial.printf("CLI: found %s\n", device->getAddress().toString().c_str());
serverAddress = device->getAddress();
connectRequested = true;
bleScanning = false;
NimBLEDevice::getScan()->stop();
}
}
};
class CliCallbacks : public NimBLEClientCallbacks {
void onConnect(NimBLEClient *client) override {
leds[0] = CRGB::Blue;
FastLED.show();
Serial.println("CLI: connected");
}
void onDisconnect(NimBLEClient *client, int reason) override {
connected = false;
pColorChar = nullptr;
pUuidChar = nullptr;
leds[0] = CRGB::Red;
FastLED.show();
Serial.println("CLI: disconnected");
}
};
static bool connectToServer() {
pClient = NimBLEDevice::createClient();
pClient->setClientCallbacks(new CliCallbacks());
if (!pClient->connect(serverAddress)) {
Serial.println("CLI: connect failed");
return false;
}
connected = true;
NimBLERemoteService *pSvc = pClient->getService(SERVICE_UUID);
if (!pSvc) {
Serial.println("CLI: service not found");
pClient->disconnect();
return false;
}
pUuidChar = pSvc->getCharacteristic(UUID_CHAR_UUID);
pColorChar = pSvc->getCharacteristic(CHARACTERISTIC_UUID);
if (!pUuidChar || !pColorChar) {
Serial.println("CLI: characteristics not found");
pClient->disconnect();
return false;
}
std::string svrUuid = pUuidChar->readValue();
if (svrUuid.length() != ESP_UUID_LEN || memcmp(svrUuid.data(), serverUuid, ESP_UUID_LEN) != 0) {
Serial.println("CLI: server UUID mismatch — disconnecting");
pClient->disconnect();
return false;
}
Serial.println("CLI: server UUID verified");
return true;
}
void setup() {
Serial.begin(115200);
delay(2000);
Serial.println("=== SylDa Client (NimBLE HMAC) ===");
FastLED.addLeds<NEOPIXEL, LED_PIN>(leds, NUM_LEDS);
leds[0] = CRGB::Red;
FastLED.show();
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT);
preferences.begin(NVS_NAMESPACE, true);
size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, myUuid, ESP_UUID_LEN);
size_t serverLen = preferences.getBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
if (len == HMAC_KEY_LEN) {
keyProvisioned = true;
Serial.println("HMAC key loaded from NVS");
} else {
Serial.println("No HMAC key — provision via USB");
}
if (uuidLen == ESP_UUID_LEN) {
uuidProvisioned = true;
Serial.print("UUID: ");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", myUuid[i]);
Serial.println();
} else {
Serial.println("No UUID — provision via USB");
}
if (serverLen == ESP_UUID_LEN) {
serverProvisioned = true;
Serial.print("Server UUID: ");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", serverUuid[i]);
Serial.println();
} else {
Serial.println("No server UUID — provision via USB");
}
NimBLEDevice::init("SylDa_Client");
NimBLEDevice::setScanFilterMode(CONFIG_BTDM_SCAN_DUPL_TYPE_DATA);
Serial.println("Setup done.");
}
void loop() {
if (bleScanning && millis() - lastScanMs > 5000) {
bleScanning = false;
}
if (!connected && !connectRequested && keyProvisioned && serverProvisioned) {
static uint32_t lastScanCheck = 0;
if (millis() - lastScanCheck > 6000) {
if (!bleScanning) {
NimBLEScan *pScan = NimBLEDevice::getScan();
pScan->setAdvertisedDeviceCallbacks(new ScanCallbacks());
pScan->setActiveScan(true);
pScan->setInterval(100);
pScan->setWindow(99);
pScan->start(5, nullptr, false);
bleScanning = true;
lastScanMs = millis();
Serial.println("CLI: scanning...");
}
lastScanCheck = millis();
}
}
if (connectRequested) {
connectRequested = false;
delay(500);
if (connectToServer()) {
} else {
delay(2000);
}
}
if (connected && pColorChar) {
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) == LOW);
bool t2Raw = (digitalRead(TOUCH2_PIN) == LOW);
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;
static bool lastR1 = false;
static bool lastR2 = false;
bool relay1 = t1Active || btnActive;
bool relay2 = t2Active;
if (relay1 != lastR1 || relay2 != lastR2) {
static uint32_t lastCmd = 0;
if (millis() - lastCmd > 200) {
uint8_t packet[HMAC_PKT_SIZE];
buildHmacPacket(relay1 ? 0x01 : 0x00, packet);
pColorChar->writeValue(packet, HMAC_PKT_SIZE, false);
lastR1 = relay1;
lastR2 = relay2;
Serial.printf("CLI: sent R1=%d R2=%d\n", relay1, relay2);
leds[0] = relay1 || relay2 ? CRGB::Green : CRGB::Blue;
FastLED.show();
}
}
}
if (Serial.available()) {
static String serialBuf;
char c = Serial.read();
serialBuf += c;
if (c == '\n') {
serialBuf.trim();
if (serialBuf == "KEY?") {
if (keyProvisioned) {
Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", myUuid[i]);
Serial.println();
Serial.print("HMAC_KEY:");
for (int i = 0; i < HMAC_KEY_LEN; i++) Serial.printf("%02X", hmacKey[i]);
Serial.println();
Serial.print("SERVER_UUID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", serverUuid[i]);
Serial.println();
} else {
Serial.println("NO_KEY");
}
} 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) {
for (int i = 0; i < HMAC_KEY_LEN; i++) {
hmacKey[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
preferences.end();
keyProvisioned = true;
Serial.println("OK");
} else {
Serial.println("ERROR");
}
} else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7);
hex.toUpperCase();
if (hex.length() == 32) {
for (int i = 0; i < ESP_UUID_LEN; i++) {
myUuid[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, myUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
Serial.println("OK");
} else {
Serial.println("ERROR");
}
} else if (serialBuf.startsWith("SET_SERVER:")) {
String hex = serialBuf.substring(11);
hex.toUpperCase();
if (hex.length() == 32) {
for (int i = 0; i < ESP_UUID_LEN; i++) {
serverUuid[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
serverProvisioned = true;
Serial.println("OK");
} else {
Serial.println("ERROR");
}
}
serialBuf = "";
}
}
static uint32_t heartbeat = 0;
if (millis() - heartbeat > 5000) {
Serial.printf("[cli] conn=%d key=%d uuid=%d server=%d\n",
connected, keyProvisioned, uuidProvisioned, serverProvisioned);
heartbeat = millis();
}
delay(20);
}
```
- [ ] **Step 2: Compile client**
```bash
pio run -e s3lite-client 2>&1 | tail -3
# Expected: SUCCESS
```
- [ ] **Step 3: Commit**
```bash
git add src/client.cpp
git commit -m "feat: client firmware — touch/button, BLE scan, HMAC, server UUID verify, USB CDC provisioning"
```
---
### Task 4: Android app — SettingsActivity radio buttons
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt`
- Modify: `android/app/src/main/res/layout/activity_settings.xml`
- [ ] **Step 1: Add radio buttons to layout**
Add device mode (Server/Client) and transport (BLE/USB) RadioGroups to `activity_settings.xml`, placed above the existing HMAC key field:
```xml
<RadioGroup
android:id="@+id/radioDeviceMode"
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>
```
Place this XML inside the existing LinearLayout, above the HMAC key section.
- [ ] **Step 2: Update PrefsHelper with new keys**
Add to `PrefsHelper.kt`:
```kotlin
const val KEY_DEVICE_MODE = "deviceMode" // "server" or "client"
const val KEY_SERVER_UUID = "serverUuid"
const val KEY_CLIENT_UUID = "clientUuid"
fun getDeviceMode(): String = prefs.getString(KEY_DEVICE_MODE, "server") ?: "server"
fun setDeviceMode(mode: String) = prefs.edit().putString(KEY_DEVICE_MODE, mode).apply()
fun getTransportMode(): String = prefs.getString("transportMode", "ble") ?: "ble"
fun setTransportMode(mode: String) = prefs.edit().putString("transportMode", mode).apply()
```
- [ ] **Step 3: Update SettingsActivity to use radio buttons**
In `SettingsActivity.kt`, add in `onCreate` after the existing view bindings:
```kotlin
val radioDeviceMode = findViewById<RadioGroup>(R.id.radioDeviceMode)
val radioTransport = findViewById<RadioGroup>(R.id.radioTransport)
radioDeviceMode.check(
if (PrefsHelper.getDeviceMode() == "client") R.id.radioClient else R.id.radioServer
)
radioTransport.check(
if (PrefsHelper.getTransportMode() == "usb") R.id.radioUsb else R.id.radioBle
)
radioDeviceMode.setOnCheckedChangeListener { _, checkedId ->
val mode = if (checkedId == R.id.radioClient) "client" else "server"
PrefsHelper.setDeviceMode(mode)
}
radioTransport.setOnCheckedChangeListener { _, checkedId ->
val mode = if (checkedId == R.id.radioUsb) "usb" else "ble"
PrefsHelper.setTransportMode(mode)
}
```
- [ ] **Step 4: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt \
android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt \
android/app/src/main/res/layout/activity_settings.xml
git commit -m "feat: Android — device/transport radio buttons, PrefsHelper keys"
```
---
### Task 5: Android app — EspFlasher split assets
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/EspFlasher.kt`
- [ ] **Step 1: Update EspFlasher asset paths**
In `EspFlasher.kt`, add a method to resolve the asset path based on device mode:
```kotlin
private fun assetPath(baseName: String): String {
val prefix = if (PrefsHelper.getDeviceMode() == "client") "client/" else "server/"
return "firmware/$prefix$baseName"
}
```
Then replace all hardcoded `"firmware/bootloader.bin"` with `assetPath("bootloader.bin")` etc.
- [ ] **Step 2: Ensure firmware assets are organized**
No code change — the build script (`copy_firmware.sh`) and CI will populate:
```
assets/firmware/server/{bootloader,partitions,boot_app0,firmware}.bin
assets/firmware/client/{bootloader,partitions,boot_app0,firmware}.bin
```
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/EspFlasher.kt
git commit -m "feat: EspFlasher split assets server/client based on deviceMode"
```
---
### Task 6: Android app — MainActivity device mode routing
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt`
- [ ] **Step 1: Add device mode routing in MainActivity**
In `MainActivity.kt`, when BLE transport is selected:
```kotlin
// In the BLE relay controls section:
// If deviceMode == "client", hide relay controls and show OTA-only UI
// If deviceMode == "server", show relay controls as before
val isServer = PrefsHelper.getDeviceMode() == "server"
relay1Button.isEnabled = isServer
relay2Button.isEnabled = isServer
```
- [ ] **Step 2: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt
git commit -m "feat: MainActivity device mode — disable relay controls for client mode"
```
---
### Task 7: Compile and verify both firmwares
**Files:** None (verification only)
- [ ] **Step 1: Full compile**
```bash
pio run -e s3lite-server -e s3lite-client 2>&1 | tail -5
# Expected: both SUCCESS
```
- [ ] **Step 2: Verify sizes**
```bash
ls -la .pio/build/s3lite-server/firmware.bin .pio/build/s3lite-client/firmware.bin
```
- [ ] **Step 3: Commit if any changes needed**
(Only if fixes were needed)
---
### Task 8: Flash and integration test
**Files:** None (testing only)
- [ ] **Step 1: Flash server**
```bash
pio run -e s3lite-server -t upload
```
- [ ] **Step 2: Flash client**
```bash
pio run -e s3lite-client -t upload
```
- [ ] **Step 3: Test server solo — touch/button controls relays locally**
```bash
timeout 10 cat /dev/ttyACM1 | grep 'R1=\|R2=\|btn='
# Expected: touch G8 → R1=ON, button G41 → R1=ON, touch G6 → R2=ON
```
- [ ] **Step 4: Test server + client — client touch controls server relays**
```bash
# Check both serial outputs simultaneously:
timeout 10 cat /dev/ttyACM1 | grep 'R1=\|R2=\|idx='
# Expected: relay state changes from BLE client
```
- [ ] **Step 5: Test disconnect + reconnect**
Disconnect client (power cycle), verify server advertises for new connection, client reconnects.
- [ ] **Step 6: Test NVS persistence**
Power cycle both devices, verify HMAC key + UUIDs survive via serial output.
- [ ] **Step 7: Save binaries**
```bash
mkdir -p releases/sylda-nimble-hmac
cp .pio/build/s3lite-server/firmware.bin releases/sylda-nimble-hmac/server.bin
cp .pio/build/s3lite-client/firmware.bin releases/sylda-nimble-hmac/client.bin
```
- [ ] **Step 8: Commit**
```bash
git add releases/sylda-nimble-hmac/
git commit -m "release: NimBLE HMAC binaries — server + client"
```
---
### Task 9: Cleanup — .gitignore + final status
**Note:** Client BLE OTA (dual-role NimBLE: central + peripheral) is out-of-scope for this iteration. Client firmware kan via USB CDC geflasht worden (radio Client+USB in app). BLE OTA op de client volgt in een follow-up.
- [ ] **Step 1: Verify .gitignore has src/version.h**
```bash
grep 'version.h' .gitignore
# Expected: src/version.h
```
- [ ] **Step 2: Verify git status is clean**
```bash
git status
# Expected: clean working tree
```