Project_SylDa/src/client.cpp

1042 lines
35 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <FastLED.h>
#include <mbedtls/gcm.h>
#include <mbedtls/sha256.h>
#include <Preferences.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_log.h>
#include "version.h"
#include "ecdh_helper.h"
static const char *TAG = "client";
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", (esp_log_level_t)CONFIG_LOG_DEFAULT_LEVEL);
}
#define TOUCH1_PIN 8
#define TOUCH2_PIN 6
#define BUTTON_PIN 41
#define LED_PIN 35
#define NUM_LEDS 1
CRGB leds[NUM_LEDS];
#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 ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc"
#define CLIENT_REGISTRY_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dd"
#define CLIENT_CONTROL_UUID "beb5483e-36e1-4688-b7f5-ea07361f26de"
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#define ESP_UUID_LEN 16
#define BROKER_PLAIN_SIZE 20
#define BROKER_PKT_SIZE (4 + 8 + BROKER_PLAIN_SIZE + 16)
#define BROKER_CMD_UPDATE_AVAILABLE 0x10
#define BROKER_CMD_CLIENT_READY 0x20
#define NVS_NAMESPACE "syl"
#define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid"
#define NVS_SERVER_NAME "server"
#define NVS_ADDR_NAME "addr"
#define DEBOUNCE_MS 50
#define SCAN_DURATION_MS 5000
#define SCAN_INTERVAL_MS 8000
#define RECONNECT_DELAY_MS 1000
uint8_t psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
uint8_t serverUuid[ESP_UUID_LEN];
bool pskProvisioned = false;
bool uuidProvisioned = false;
bool serverProvisioned = false;
bool allProvisioned = false;
bool serverAddrStored = false;
NimBLEAddress storedServerAddr;
Preferences preferences;
static bool connected = false;
NimBLEClient *pClient = nullptr;
NimBLERemoteCharacteristic *pCmdChar = nullptr;
static bool relay1SendState = false;
static bool relay2SendState = false;
static uint32_t seq = 0;
static uint8_t aesKey[AES_KEY_LEN];
static bool ecdhReady = false;
#define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0;
bool otaInProgress = false;
bool otaEnded = false;
bool otaServerMode = false;
static NimBLEServer *pOtaServer = nullptr;
static NimBLECharacteristic *pOtaChar = nullptr;
static NimBLECharacteristic *pOtaUuidChar = nullptr;
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;
bool otaReady = false;
bool otaConfirmed = false;
bool otaEarlyConfirm = false;
uint32_t otaEndedAt = 0;
#define OTA_CONFIRM_TIMEOUT_MS 10000
#define OTA_CMD_START 0x10
#define OTA_STATUS_ACK 0x00
#define OTA_STATUS_NAK 0x01
#define OTA_STATUS_COMPLETE 0x10
#define OTA_STATUS_ERROR 0x11
static bool bleReady = false;
static bool serverFound = false;
static NimBLEAddress serverAddress;
static NimBLEAddress foundAddr;
static bool scanning = false;
static uint32_t scanStarted = 0;
static uint32_t disconnectTime = 0;
static bool ecdhNotifyReceived = false;
static uint8_t ecdhNotifyBuf[ECDH_PKT_SIZE];
static size_t ecdhNotifyLen = 0;
class MyScanCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override {
if (scanning && advertisedDevice->isAdvertisingService(NimBLEUUID(SERVICE_UUID))) {
std::string mfgData = advertisedDevice->getManufacturerData();
if (mfgData.length() >= 6) {
if (memcmp(mfgData.data() + 2, serverUuid, 4) == 0) {
foundAddr = advertisedDevice->getAddress();
serverFound = true;
ESP_LOGI(TAG, "BLE: server found at %s", foundAddr.toString().c_str());
}
}
}
}
void onScanEnd(const NimBLEScanResults &results, int reason) override {
scanning = false;
ESP_LOGI(TAG, "BLE: scan ended (reason=%d, found=%d)", reason, serverFound);
}
};
class MyClientCallbacks : public NimBLEClientCallbacks {
void onConnect(NimBLEClient *client) override {
connected = true;
ESP_LOGI(TAG, "BLE: connected to server");
}
void onDisconnect(NimBLEClient *client, int reason) override {
connected = false;
disconnectTime = millis();
relay1SendState = false;
relay2SendState = false;
pCmdChar = nullptr;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
ESP_LOGW(TAG, "BLE: disconnected (reason=%d)", reason);
}
};
static MyClientCallbacks clientCallbacks;
static bool isHex(const String &hex) {
for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false;
}
return true;
}
static void parseHex(const String &hex, uint8_t *out, int len) {
for (int i = 0; i < len; i++) {
out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
}
static void buildAesPacket(uint8_t cmd, uint8_t *packet) {
uint32_t s = ++seq;
packet[0] = (s >> 24) & 0xFF;
packet[1] = (s >> 16) & 0xFF;
packet[2] = (s >> 8) & 0xFF;
packet[3] = s & 0xFF;
uint8_t nonceRandom[8];
for (int i = 0; i < 8; i++) nonceRandom[i] = (uint8_t)(esp_random() & 0xFF);
memcpy(packet + 4, nonceRandom, 8);
uint8_t nonce[12];
memcpy(nonce, packet, 4);
memcpy(nonce + 4, nonceRandom, 8);
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "AES: setkey failed (ret=%d)", ret);
mbedtls_gcm_free(&gcm);
return;
}
uint8_t pt[1] = { cmd };
mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT,
1, nonce, 12, NULL, 0,
pt, packet + 12, 16, packet + 13);
mbedtls_gcm_free(&gcm);
}
static void disconnectClient() {
if (pClient != nullptr) {
if (pClient->isConnected()) {
pClient->disconnect();
delay(100);
}
}
pCmdChar = nullptr;
connected = false;
relay1SendState = false;
relay2SendState = false;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
}
static void sendOtaNotify(uint8_t status, uint32_t seq) {
if (pOtaChar == nullptr) return;
uint8_t buf[5];
buf[0] = status;
buf[1] = (seq >> 24) & 0xFF;
buf[2] = (seq >> 16) & 0xFF;
buf[3] = (seq >> 8) & 0xFF;
buf[4] = seq & 0xFF;
pOtaChar->setValue(buf, 5);
bool ok = pOtaChar->notify();
if (!ok) {
ESP_LOGW(TAG, "OTA: notify() failed (status=0x%02X seq=%lu)", status, seq);
}
}
static void otaReset() {
otaInProgress = false;
otaHandle = 0;
otaPartition = nullptr;
otaExpectedSize = 0;
otaWritten = 0;
otaReady = false;
otaConfirmed = false;
otaEarlyConfirm = false;
if (otaShaStarted) {
mbedtls_sha256_free(&otaShaCtx);
otaShaStarted = false;
}
}
static void abortOta(const char *reason) {
ESP_LOGE(TAG, "OTA: abort — %s", reason);
if (otaHandle != 0) {
esp_ota_end(otaHandle);
}
otaReset();
sendOtaNotify(OTA_STATUS_ERROR, 0);
}
class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
std::string value = pChar->getValue();
if (value.empty()) return;
const uint8_t *data = (const uint8_t *)value.data();
size_t len = value.length();
if (len == 5 && data[0] == OTA_CMD_START) {
if (otaInProgress) {
esp_ota_end(otaHandle);
otaReset();
}
otaExpectedSize = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) |
((uint32_t)data[3] << 8) | (uint32_t)data[4];
otaPartition = esp_ota_get_next_update_partition(nullptr);
if (otaPartition == nullptr) {
ESP_LOGE(TAG, "OTA: no update partition found");
sendOtaNotify(OTA_STATUS_ERROR, 0);
return;
}
esp_err_t err = esp_ota_begin(otaPartition, otaExpectedSize, &otaHandle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: begin failed (%d)", err);
sendOtaNotify(OTA_STATUS_ERROR, 0);
return;
}
otaInProgress = true;
otaWritten = 0;
mbedtls_sha256_init(&otaShaCtx);
mbedtls_sha256_starts_ret(&otaShaCtx, 0);
otaShaStarted = true;
ESP_LOGI(TAG, "OTA: started, expecting %lu bytes", otaExpectedSize);
return;
}
if (len == 32 && otaInProgress) {
memcpy(otaExpectedSha, data, 32);
uint8_t computedSha[32];
mbedtls_sha256_finish_ret(&otaShaCtx, computedSha);
mbedtls_sha256_free(&otaShaCtx);
otaShaStarted = false;
ESP_LOGD(TAG, "OTA: SHA256 verify (written=%lu expected=%lu)", otaWritten, otaExpectedSize);
if (memcmp(computedSha, otaExpectedSha, 32) != 0) {
ESP_LOGE(TAG, "OTA: SHA256 mismatch — aborting");
esp_ota_abort(otaHandle);
otaReset();
sendOtaNotify(OTA_STATUS_ERROR, 0);
return;
}
ESP_LOGI(TAG, "OTA: SHA256 OK, waiting for 0xFF confirm");
otaReady = true;
sendOtaNotify(OTA_STATUS_COMPLETE, 0);
return;
}
if (!otaInProgress) {
ESP_LOGD(TAG, "OTA: write ignored (not in progress, len=%d data0=0x%02X)", len, data[0]);
return;
}
if (len == 1 && data[0] == 0xFF) {
ESP_LOGD(TAG, "OTA: 0xFF received (ready=%d confirmed=%d inProgress=%d)",
otaReady, otaConfirmed, otaInProgress);
if (otaReady) {
ESP_LOGI(TAG, "OTA: 0xFF confirm accepted");
otaConfirmed = true;
} else {
ESP_LOGD(TAG, "OTA: 0xFF arrived early, deferring");
otaEarlyConfirm = true;
}
return;
}
if (data[0] < 0x10) {
uint32_t seq = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) |
((uint32_t)data[2] << 8) | (uint32_t)data[3];
size_t dataLen = len - 4;
mbedtls_sha256_update_ret(&otaShaCtx, data + 4, dataLen);
esp_err_t err = esp_ota_write(otaHandle, data + 4, dataLen);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: write error at seq %lu (%d)", seq, err);
sendOtaNotify(OTA_STATUS_NAK, seq);
abortOta("write error");
return;
}
otaWritten += dataLen;
sendOtaNotify(OTA_STATUS_ACK, seq);
}
}
};
static bool startOtaServer() {
if (otaServerMode) return true;
char otaName[32];
snprintf(otaName, sizeof(otaName), "SylDa_Client_OTA_%02X%02X", deviceUuid[0], deviceUuid[1]);
NimBLEDevice::init(otaName);
NimBLEDevice::setMTU(248);
pOtaServer = NimBLEDevice::createServer();
NimBLEService *pOtaService = pOtaServer->createService(SERVICE_UUID);
pOtaChar = pOtaService->createCharacteristic(
OTA_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
pOtaUuidChar = pOtaService->createCharacteristic(
UUID_CHAR_UUID,
NIMBLE_PROPERTY::READ
);
pOtaUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
NimBLECharacteristic *pVerChar = pOtaService->createCharacteristic(
VERSION_UUID,
NIMBLE_PROPERTY::READ
);
pVerChar->setValue(FIRMWARE_VERSION_CLIENT);
pOtaService->start();
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName(otaName);
pAdvertising->setMinInterval(32);
pAdvertising->setMaxInterval(64);
pAdvertising->start();
otaServerMode = true;
ESP_LOGI(TAG, "OTA: server started as %s", otaName);
return true;
}
static bool performEcdhHandshake(NimBLERemoteService *pSvc) {
if (!pskProvisioned) {
ESP_LOGE(TAG, "ECDH: no PSK");
return false;
}
CryptoECDH ecdh;
if (!ecdh.init()) {
ESP_LOGE(TAG, "ECDH: init failed");
return false;
}
if (!ecdh.generateKeys()) {
ESP_LOGE(TAG, "ECDH: keygen failed");
ecdh.free();
return false;
}
NimBLERemoteCharacteristic *pEcdhChar = pSvc->getCharacteristic(ECDH_UUID);
if (pEcdhChar == nullptr) {
ESP_LOGW(TAG, "ECDH: characteristic not found");
ecdh.free();
return false;
}
if (!pEcdhChar->subscribe(true, [](NimBLERemoteCharacteristic *pChar, const uint8_t *data, size_t len, bool isNotify) {
if (ecdhReady) return;
if (len != ECDH_PKT_SIZE) {
ESP_LOGW(TAG, "ECDH: bad notify length %d", len);
return;
}
memcpy(ecdhNotifyBuf, data, len);
ecdhNotifyLen = len;
ecdhNotifyReceived = true;
ESP_LOGI(TAG, "ECDH: notify received (%d bytes)", len);
}, true)) {
ESP_LOGW(TAG, "ECDH: subscribe failed");
ecdh.free();
return false;
}
uint8_t handshake[ECDH_PKT_SIZE];
memcpy(handshake, ecdh.public_key, ECDH_PUBKEY_LEN);
CryptoECDH::signPubkey(psk, ecdh.public_key, handshake + ECDH_PUBKEY_LEN);
ecdhNotifyReceived = false;
if (!pEcdhChar->writeValue(handshake, ECDH_PKT_SIZE, false)) {
ESP_LOGW(TAG, "ECDH: write pubkey failed");
ecdh.free();
return false;
}
uint32_t start = millis();
while (!ecdhNotifyReceived && (millis() - start < 5000)) {
delay(10);
}
if (!ecdhNotifyReceived) {
ESP_LOGW(TAG, "ECDH: notify timeout");
ecdh.free();
return false;
}
if (!CryptoECDH::verifyPubkey(psk, ecdhNotifyBuf, ecdhNotifyBuf + ECDH_PUBKEY_LEN)) {
ESP_LOGW(TAG, "ECDH: server pubkey HMAC mismatch");
ecdh.free();
return false;
}
if (!ecdh.computeSharedSecret(ecdhNotifyBuf, ECDH_PUBKEY_LEN)) {
ESP_LOGE(TAG, "ECDH: shared secret failed");
ecdh.free();
return false;
}
memcpy(aesKey, ecdh.aes_key, AES_KEY_LEN);
ecdh.free();
ecdhReady = true;
ESP_LOGI(TAG, "ECDH: handshake complete");
return true;
}
static bool connectToServer() {
if (pClient == nullptr) {
pClient = NimBLEDevice::createClient();
pClient->setConnectTimeout(5000);
pClient->setClientCallbacks(&clientCallbacks);
}
if (!pClient->connect(serverAddress)) {
ESP_LOGE(TAG, "BLE: connect failed, retry later");
return false;
}
NimBLERemoteService *pSvc = pClient->getService(SERVICE_UUID);
if (pSvc == nullptr) {
ESP_LOGW(TAG, "BLE: service not found");
disconnectClient();
return false;
}
NimBLERemoteCharacteristic *pUuidChar = pSvc->getCharacteristic(UUID_CHAR_UUID);
if (pUuidChar == nullptr) {
ESP_LOGW(TAG, "BLE: UUID characteristic not found");
disconnectClient();
return false;
}
std::string uuidVal = pUuidChar->readValue();
if (uuidVal.length() != ESP_UUID_LEN ||
memcmp(uuidVal.data(), serverUuid, ESP_UUID_LEN) != 0) {
ESP_LOGW(TAG, "BLE: server UUID mismatch — disconnecting");
disconnectClient();
return false;
}
pCmdChar = pSvc->getCharacteristic(CHARACTERISTIC_UUID);
if (pCmdChar == nullptr) {
ESP_LOGW(TAG, "BLE: command characteristic not found");
disconnectClient();
return false;
}
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
if (!performEcdhHandshake(pSvc)) {
ESP_LOGE(TAG, "BLE: ECDH handshake failed");
disconnectClient();
return false;
}
seq = 0;
relay1SendState = false;
relay2SendState = false;
ESP_LOGI(TAG, "BLE: server UUID verified, ECDH done, ready");
NimBLERemoteCharacteristic *pRegChar = pSvc->getCharacteristic(CLIENT_REGISTRY_UUID);
if (pRegChar == nullptr) {
ESP_LOGW(TAG, "BLE: CLIENT_REGISTRY characteristic not found");
} else {
uint8_t plainPayload[BROKER_PLAIN_SIZE];
memset(plainPayload, 0, BROKER_PLAIN_SIZE);
plainPayload[0] = 0x00;
memcpy(plainPayload + 1, deviceUuid, ESP_UUID_LEN);
uint16_t clientVersion = (uint16_t)atoi(FIRMWARE_VERSION_CLIENT);
plainPayload[1 + ESP_UUID_LEN] = (clientVersion >> 8) & 0xFF;
plainPayload[1 + ESP_UUID_LEN + 1] = clientVersion & 0xFF;
uint8_t pkt[BROKER_PKT_SIZE];
memset(pkt, 0, BROKER_PKT_SIZE);
uint32_t s = ++seq;
pkt[0] = (s >> 24) & 0xFF;
pkt[1] = (s >> 16) & 0xFF;
pkt[2] = (s >> 8) & 0xFF;
pkt[3] = s & 0xFF;
for (int i = 0; i < 8; i++) pkt[4 + i] = (uint8_t)(esp_random() & 0xFF);
uint8_t nonce[12];
memcpy(nonce, pkt, 4);
memcpy(nonce + 4, pkt + 4, 8);
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "REGISTRY: setkey failed (ret=%d)", ret);
mbedtls_gcm_free(&gcm);
} else {
mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT,
BROKER_PLAIN_SIZE, nonce, 12, NULL, 0,
plainPayload, pkt + 12, 16, pkt + 12 + BROKER_PLAIN_SIZE);
mbedtls_gcm_free(&gcm);
if (pRegChar->writeValue(pkt, BROKER_PKT_SIZE, false)) {
ESP_LOGI(TAG, "REGISTRY: reported uuid + version %d to server", FIRMWARE_VERSION_CLIENT);
} else {
ESP_LOGW(TAG, "REGISTRY: write failed");
}
}
}
NimBLERemoteCharacteristic *pControlChar = pSvc->getCharacteristic(CLIENT_CONTROL_UUID);
if (pControlChar == nullptr) {
ESP_LOGW(TAG, "BLE: CLIENT_CONTROL characteristic not found");
} else {
if (!pControlChar->subscribe(true, [pControlChar](NimBLERemoteCharacteristic *pChar, const uint8_t *data, size_t len, bool isNotify) {
if (len != BROKER_PKT_SIZE) {
ESP_LOGW(TAG, "BROKER: bad notify length %d", (int)len);
return;
}
uint8_t nonce[12];
memcpy(nonce, data, 4);
memcpy(nonce + 4, data + 4, 8);
uint8_t plainPayload[BROKER_PLAIN_SIZE];
uint8_t ciphertext[BROKER_PLAIN_SIZE];
memcpy(ciphertext, data + 12, BROKER_PLAIN_SIZE);
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "BROKER: setkey failed (ret=%d)", ret);
mbedtls_gcm_free(&gcm);
return;
}
ret = mbedtls_gcm_auth_decrypt(&gcm,
BROKER_PLAIN_SIZE, nonce, 12, NULL, 0,
data + 12 + BROKER_PLAIN_SIZE, 16, ciphertext, plainPayload);
mbedtls_gcm_free(&gcm);
if (ret != 0) {
ESP_LOGE(TAG, "BROKER: AES-GCM decrypt failed (ret=%d)", ret);
return;
}
uint8_t cmd = plainPayload[0];
ESP_LOGI(TAG, "BROKER: received cmd 0x%02X", cmd);
if (cmd == BROKER_CMD_UPDATE_AVAILABLE) {
ESP_LOGI(TAG, "BROKER: update available, starting OTA server");
startOtaServer();
uint8_t clientUuid[ESP_UUID_LEN];
memcpy(clientUuid, plainPayload + 1, ESP_UUID_LEN);
uint8_t plainResp[BROKER_PLAIN_SIZE];
memset(plainResp, 0, BROKER_PLAIN_SIZE);
plainResp[0] = BROKER_CMD_CLIENT_READY;
memcpy(plainResp + 1, clientUuid, ESP_UUID_LEN);
uint8_t pkt[BROKER_PKT_SIZE];
memset(pkt, 0, BROKER_PKT_SIZE);
uint32_t s = ++seq;
pkt[0] = (s >> 24) & 0xFF;
pkt[1] = (s >> 16) & 0xFF;
pkt[2] = (s >> 8) & 0xFF;
pkt[3] = s & 0xFF;
for (int i = 0; i < 8; i++) pkt[4 + i] = (uint8_t)(esp_random() & 0xFF);
uint8_t nonceResp[12];
memcpy(nonceResp, pkt, 4);
memcpy(nonceResp + 4, pkt + 4, 8);
mbedtls_gcm_context gcmResp;
mbedtls_gcm_init(&gcmResp);
ret = mbedtls_gcm_setkey(&gcmResp, MBEDTLS_CIPHER_ID_AES, aesKey, AES_KEY_LEN * 8);
if (ret == 0) {
mbedtls_gcm_crypt_and_tag(&gcmResp, MBEDTLS_GCM_ENCRYPT,
BROKER_PLAIN_SIZE, nonceResp, 12, NULL, 0,
plainResp, pkt + 12, 16, pkt + 12 + BROKER_PLAIN_SIZE);
mbedtls_gcm_free(&gcmResp);
pControlChar->writeValue(pkt, BROKER_PKT_SIZE, false);
ESP_LOGI(TAG, "BROKER: sent CLIENT_READY to server");
}
}
}, true)) {
ESP_LOGW(TAG, "BROKER: subscribe to CLIENT_CONTROL failed");
}
}
return true;
}
void setup() {
Serial.begin(115200);
delay(2000);
ESP_LOGI(TAG, "=== SylDa Client v%s ===", FIRMWARE_VERSION_CLIENT);
FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
leds[0] = CRGB::Red;
FastLED.show();
preferences.begin(NVS_NAMESPACE, true);
size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN);
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
size_t serverLen = preferences.getBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
if (pskLen == PSK_LEN) {
pskProvisioned = true;
ESP_LOGI(TAG, "PSK loaded from NVS");
} else {
pskProvisioned = false;
ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision");
}
if (uuidLen == ESP_UUID_LEN) {
uuidProvisioned = true;
ESP_LOGD(TAG, "UUID loaded from NVS");
} else {
uuidProvisioned = false;
for (int i = 0; i < ESP_UUID_LEN; i++) deviceUuid[i] = (uint8_t)(esp_random() & 0xFF);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
ESP_LOGD(TAG, "Generated new UUID");
}
if (serverLen == ESP_UUID_LEN) {
serverProvisioned = true;
ESP_LOGD(TAG, "Server UUID loaded from NVS");
} else {
serverProvisioned = false;
ESP_LOGW(TAG, "No server UUID — use USB SET_SERVER to provision");
}
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT);
if (allProvisioned) {
NimBLEDevice::init("SylDa_Client");
bleReady = true;
preferences.begin(NVS_NAMESPACE, true);
uint8_t addrBuf[6];
size_t addrLen = preferences.getBytes(NVS_ADDR_NAME, addrBuf, 6);
preferences.end();
if (addrLen == 6) {
ble_addr_t addr;
addr.type = BLE_ADDR_PUBLIC;
memcpy(addr.val, addrBuf, 6);
storedServerAddr = NimBLEAddress(addr);
serverAddrStored = true;
ESP_LOGI(TAG, "BLE: stored server address %s", storedServerAddr.toString().c_str());
}
ESP_LOGI(TAG, "BLE: initialized, will scan for server");
}
ESP_LOGI(TAG, "Setup done.");
}
static void startScan() {
if (scanning) return;
NimBLEScan *pScan = NimBLEDevice::getScan();
pScan->setScanCallbacks(new MyScanCallbacks());
pScan->setActiveScan(true);
pScan->setInterval(32);
pScan->setWindow(16);
serverFound = false;
scanning = true;
scanStarted = millis();
pScan->start(SCAN_DURATION_MS / 100, false);
ESP_LOGI(TAG, "BLE: scanning started");
}
void loop() {
if (Serial.available()) {
static String serialBuf;
char c = Serial.read();
serialBuf += c;
if (c == '\n') {
serialBuf.trim();
if (serialBuf == "KEY?") {
if (pskProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
Serial.print("PSK:");
for (int i = 0; i < PSK_LEN; i++) Serial.printf("%02X", psk[i]);
Serial.println();
Serial.print("SERVER_UUID:");
if (serverProvisioned) {
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", serverUuid[i]);
}
Serial.println();
} else {
Serial.println("NO_KEY");
}
} else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Client");
delay(50);
unmuteSerial();
} 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 && isHex(hex)) {
parseHex(hex, psk, PSK_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN);
preferences.end();
pskProvisioned = true;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("PSK stored in NVS");
if (allProvisioned && !bleReady) {
NimBLEDevice::init("SylDa_Client");
bleReady = true;
ESP_LOGI(TAG, "BLE: initialized after key provisioning");
}
} else {
ESP_LOGE(TAG, "Invalid key format");
Serial.println("ERROR");
}
} else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7);
hex.toUpperCase();
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, deviceUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("UUID stored in NVS");
} else {
ESP_LOGE(TAG, "Invalid UUID format");
Serial.println("ERROR");
}
} else if (serialBuf.startsWith("SET_SERVER:")) {
String hex = serialBuf.substring(11);
hex.toUpperCase();
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, serverUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
serverProvisioned = true;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("Server UUID stored in NVS");
if (allProvisioned && !bleReady) {
NimBLEDevice::init("SylDa_Client");
bleReady = true;
ESP_LOGI(TAG, "BLE: initialized after server provisioning");
}
} else {
ESP_LOGE(TAG, "Invalid server UUID format");
Serial.println("ERROR");
}
}
serialBuf = "";
}
}
if (!allProvisioned) {
delay(20);
return;
}
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;
bool t1Raw = (digitalRead(TOUCH1_PIN) == HIGH);
bool t2Raw = (digitalRead(TOUCH2_PIN) == HIGH);
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;
bool relay1State = !t1Active || btnActive;
bool relay2State = !t2Active;
if (otaServerMode && otaReady) {
esp_err_t err = esp_ota_end(otaHandle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: esp_ota_end failed (%d) — aborting", err);
otaReset();
} else {
ESP_LOGI(TAG, "OTA: esp_ota_end OK, waiting for 0xFF confirm");
otaEnded = true;
otaEndedAt = millis();
}
otaReady = false;
}
if (otaServerMode && otaEnded && otaEarlyConfirm) {
ESP_LOGD(TAG, "OTA: processing deferred 0xFF confirm");
otaConfirmed = true;
otaEarlyConfirm = false;
}
if (otaServerMode && otaEnded && !otaConfirmed && (millis() - otaEndedAt > OTA_CONFIRM_TIMEOUT_MS)) {
ESP_LOGW(TAG, "OTA: 0xFF confirm timeout (%lu ms) — discarding update", millis() - otaEndedAt);
otaReset();
}
if (otaServerMode && otaConfirmed) {
ESP_LOGI(TAG, "OTA: confirmed, setting boot partition");
esp_err_t err = esp_ota_set_boot_partition(otaPartition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA: set boot partition failed (%d)", err);
} else {
ESP_LOGI(TAG, "OTA: done, rebooting...");
delay(500);
esp_restart();
}
otaReset();
}
CRGB ledColor;
if (otaServerMode || otaInProgress) {
ledColor = otaEnded
? CRGB::Yellow
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);
} else {
if (connected) {
if (connectionStableMs == 0) connectionStableMs = millis();
} else {
connectionStableMs = 0;
}
if (connected) {
ledColor = (millis() - connectionStableMs >= STABLE_LED_TIMEOUT_MS)
? CRGB::Black
: CRGB::Yellow;
} else {
ledColor = CRGB::Red;
}
static bool lastRelayActive = false;
bool relayActive = relay1State || relay2State;
if (relayActive != lastRelayActive) {
relayFlashUntil = millis() + 150;
}
lastRelayActive = relayActive;
if (millis() < relayFlashUntil) {
ledColor = CRGB::Green;
}
}
leds[0] = ledColor;
FastLED.show();
if (connected && pCmdChar != nullptr && ecdhReady) {
static uint32_t lastCmd = 0;
if (millis() - lastCmd > 200) {
if (relay1State != relay1SendState) {
uint8_t pkt[AES_PKT_SIZE];
buildAesPacket(relay1State ? 0x01 : 0x00, pkt);
pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay1SendState = relay1State;
ESP_LOGD(TAG, "TX: relay1 %s (seq=%lu)", relay1State ? "ON" : "OFF", seq);
lastCmd = millis();
}
if (relay2State != relay2SendState) {
uint8_t pkt[AES_PKT_SIZE];
buildAesPacket(relay2State ? 0x02 : 0x03, pkt);
pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay2SendState = relay2State;
ESP_LOGD(TAG, "TX: relay2 %s (seq=%lu)", relay2State ? "ON" : "OFF", seq);
lastCmd = millis();
}
}
}
static uint32_t lastScanAttempt = 0;
static uint32_t statusLog = 0;
if (bleReady && millis() - statusLog > 2000) {
ESP_LOGI(TAG, "BLE: connected=%d scanning=%d ecdh=%d serverFound=%d",
connected, scanning, ecdhReady, serverFound);
statusLog = millis();
}
if (!connected && bleReady) {
if (serverFound && !scanning) {
serverFound = false;
serverAddress = foundAddr;
ESP_LOGI(TAG, "BLE: connecting to %s type=%d", serverAddress.toString().c_str(), serverAddress.getType());
if (connectToServer()) {
connected = true;
uint8_t addrBuf[6];
memcpy(addrBuf, serverAddress.getVal(), 6);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_ADDR_NAME, addrBuf, 6);
preferences.end();
storedServerAddr = serverAddress;
serverAddrStored = true;
ESP_LOGI(TAG, "BLE: server address stored in NVS");
} else {
ESP_LOGW(TAG, "BLE: connectToServer failed");
}
} else if (!scanning) {
if (millis() - lastScanAttempt > SCAN_INTERVAL_MS) {
if (serverAddrStored) {
ESP_LOGI(TAG, "BLE: trying stored address %s", storedServerAddr.toString().c_str());
serverAddress = storedServerAddr;
if (connectToServer()) {
connected = true;
ESP_LOGI(TAG, "BLE: connected via stored address");
} else {
ESP_LOGI(TAG, "BLE: stored address failed, starting scan");
serverAddrStored = false;
startScan();
}
} else {
startScan();
}
lastScanAttempt = millis();
}
} else {
if (millis() - scanStarted > SCAN_DURATION_MS) {
NimBLEDevice::getScan()->stop();
scanning = false;
ESP_LOGI(TAG, "BLE: scan timeout, no server found");
}
}
}
if (connected && pClient != nullptr && !pClient->isConnected()) {
ESP_LOGW(TAG, "BLE: lost connection");
disconnectClient();
}
static uint32_t heartbeat = 0;
if (millis() - heartbeat > 60000) {
ESP_LOGD(TAG, "[hb] t1=%s t2=%s btn=%s r1=%s r2=%s conn=%s ecdh=%s",
!t1Active ? "YES" : "no",
!t2Active ? "YES" : "no",
btnActive ? "YES" : "no",
relay1State ? "ON" : "OFF",
relay2State ? "ON" : "OFF",
connected ? "yes" : "no",
ecdhReady ? "yes" : "no");
heartbeat = millis();
}
delay(20);
}