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

33 KiB
Raw Permalink Blame History

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

git checkout main && git checkout -b sylda-nimble-hmac
  • Step 2: Create version.txt
cat > version.txt << 'EOF'
server=6
client=1
EOF
  • Step 3: Verify pio_version_pre.py exists and works
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:

echo "src/version.h" >> .gitignore
  • Step 5: Rewrite platformio.ini

Replace the entire content of platformio.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
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
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:

#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:

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:

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:

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:

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:

    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:

    pVersionChar->setValue(FIRMWARE_VERSION_SERVER);
  • Step 9: Add advertiseOnDisconnect in setup

After pServer->setCallbacks(new MyServerCallbacks());, add:

    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:

    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:

    leds[0] = (relay1State || relay2State) ? CRGB::Green : CRGB::Red;
    FastLED.show();

Also remove all digitalWrite(LED_PIN, ...) calls elsewhere and replace with FastLED. In ledOtaBusy():

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:

            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:

        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
pio run -e s3lite-server 2>&1 | tail -3
# Expected: SUCCESS
  • Step 16: Commit
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:

#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
pio run -e s3lite-client 2>&1 | tail -3
# Expected: SUCCESS
  • Step 3: Commit
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:

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

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:

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

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

// 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
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
pio run -e s3lite-server -e s3lite-client 2>&1 | tail -5
# Expected: both SUCCESS
  • Step 2: Verify sizes
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
pio run -e s3lite-server -t upload
  • Step 2: Flash client
pio run -e s3lite-client -t upload
  • Step 3: Test server solo — touch/button controls relays locally
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
# 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
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
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
grep 'version.h' .gitignore
# Expected: src/version.h
  • Step 2: Verify git status is clean
git status
# Expected: clean working tree