33 KiB
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 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
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