# 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 = + - 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 = + - 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 #include #include #include #include #include #include #include #include #include #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(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 #include #include #include #include #include #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(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 ``` 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(R.id.radioDeviceMode) val radioTransport = findViewById(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 ```