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