From 45f8f38e812d81f3c0217758f3fbe6a3c0bf00d7 Mon Sep 17 00:00:00 2001 From: eddy Date: Sat, 1 Aug 2026 00:52:03 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20cleanup=20=E2=80=94=20.gitignore,=20tr?= =?UTF-8?q?ack=20design=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 15 + .../2025-07-31-sylda-nimble-hmac-plan.md | 1075 +++++++++++++++++ .../2025-07-31-sylda-nimble-hmac-design.md | 269 +++++ 3 files changed, 1359 insertions(+) create mode 100644 docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md create mode 100644 docs/superpowers/specs/2025-07-31-sylda-nimble-hmac-design.md diff --git a/.gitignore b/.gitignore index e669370..9338082 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Generated from version.txt src/version.h +android/app/src/main/java/nl/eddydevink/sylda/SylDaVersions.kt # PlatformIO .pio/ @@ -46,4 +47,18 @@ 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__/ diff --git a/docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md b/docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md new file mode 100644 index 0000000..87bca70 --- /dev/null +++ b/docs/superpowers/plans/2025-07-31-sylda-nimble-hmac-plan.md @@ -0,0 +1,1075 @@ +# 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 S3R 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 +``` diff --git a/docs/superpowers/specs/2025-07-31-sylda-nimble-hmac-design.md b/docs/superpowers/specs/2025-07-31-sylda-nimble-hmac-design.md new file mode 100644 index 0000000..e8190f4 --- /dev/null +++ b/docs/superpowers/specs/2025-07-31-sylda-nimble-hmac-design.md @@ -0,0 +1,269 @@ +# 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: +``` + +### 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=` 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: +``` + +### 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.