Compare commits

..
8 changed files with 878 additions and 31 deletions

View file

@ -123,7 +123,6 @@ module → App: SylDa_Server\n of SylDa_Client\n (device type)
App → module: KEY?\n
module → App: DEVICE_ID:<hex>\n (module UUID, 16 bytes = 32 hex)
module → App: PSK:<hex>\n (PSK, 32 bytes = 64 hex)
module → App: SERVER_UUID:<hex>\n (alleen client: opgeslagen server UUID, 32 hex)
module → App: NO_KEY\n (als module nog geen key heeft)
App → module: SET_KEY:<hex>\n (nieuwe PSK schrijven, 64 hex)
@ -140,13 +139,22 @@ module → App: OK\n
De server ondersteunt tot **2 gelijktijdige BLE-clients** (MAX_CLIENTS 2). Elke client krijgt een onafhankelijke ECDH-handshake, AES-sleutel en sequence counter. Relais-commando's van alle clients worden gecombineerd: een relais gaat aan als minimaal één client het aan-commando stuurt (OR-logica).
De server kan OTA-updates distribueren naar verbonden clients via het **broker protocol**:
```
BROKER_CMD_UPDATE_AVAILABLE (0x10) — server vertelt client dat er een update klaarstaat
BROKER_CMD_CLIENT_READY (0x20) — client meldt zich klaar voor OTA
```
Broker packets zijn versleuteld met AES-256-GCM (48 bytes: 4 seq + 8 nonce + 20 plain + 16 tag), onafhankelijk van relay-commando encryptie.
### Beveiligingsanalyse
| Aanval | Hoe geweerd |
|---|---|
| **Replay** | Sequence counter — zelfde packet werkt niet 2× (`seq <= lastSeq` geweerd) |
| **Ongeautoriseerd device** | ECDH pubkey HMAC vereist PSK — verkeerde PSK = afgewezen public key |
| **BLE spoofing (client)** | Client gebruikt service UUID + manufacturer data voor scanning, full 16-byte UUID + ECDH PSK-HMAC voor verificatie na connect. 4-byte manufacturer data scan-filter (2^32 combinaties) kan tot onnodige connectiepogingen leiden — deze falen alsnog bij 16-byte UUID check of ECDH handshake. Voorheen (issue #10) werd alleen op naam "SylDa_Server" gescant zonder verdere verificatie. |
| **BLE spoofing (client)** | **Bekend lek (issue #10):** ESP32 client scant op naam "SylDa_Server" i.p.v. UUID — geen server-verificatie. De Android app scant wél op service UUID én verifieert device UUID na connect. |
| **Commando vervalsing** | AES-GCM versleutelt en authenticeert command byte + nonce — wijzigen maakt tag ongeldig |
| **Afluisteren** | AES-256-GCM biedt confidentiality — commando's zijn onleesbaar zonder AES-sleutel |
| **Brute-force PSK** | 32-byte PSK = 2^256 mogelijkheden — onhaalbaar |
@ -169,14 +177,16 @@ De firmware kan **over-the-air** worden geüpdatet via BLE (zonder USB):
Tijdens OTA zijn relais geforceerd UIT en knippert de LED groen. Na afloop (wachtend op confirm) is de LED geel.
> **Client OTA:** Clients kunnen ook OTA ontvangen. De server stuurt `BROKER_CMD_UPDATE_AVAILABLE`, de client antwoordt `BROKER_CMD_CLIENT_READY`, en start vervolgens een eigen BLE OTA-server (`SylDa_Client_OTA_XX`). De app verbindt met de client direct voor de OTA transfer.
## Firmware
De firmware is geschreven in C++ met PlatformIO en de Arduino-framework. BLE: ESP32 NimBLE library, crypto: mbedtls (ingebouwd in ESP-IDF).
| Environment | Bronbestand | Rol |
|---|---|---|
| `s3lite-server` | `src/main.cpp` | Ontvangt BLE-verbindingen, bedient relais |
| `s3lite-client` | `src/client.cpp` | Verbindt als BLE-central met server, stuurt relais-commando's |
| `s3lite-server` | `src/main.cpp` | Ontvangt BLE-verbindingen, bedient relais, broker voor OTA-distributie |
| `s3lite-client` | `src/client.cpp` | Verbindt als BLE-central met server, stuurt relais-commando's, ontvangt OTA |
Firmwareversies: `version.txt` (single source of truth) → `src/version.h` (gegenereerd door `tools/pio_version_pre.py`) + `SylDaVersions.kt` (Android). Versiebeheer via `tools/check_versions.sh`.
@ -207,6 +217,8 @@ De touch sensoren, BLE, en interne knop werken onafhankelijk (OR-logica). De int
| VERSION | `beb5483e-36e1-4688-b7f5-ea07361f26db` | READ | Firmware versie string |
| OTA | `beb5483e-36e1-4688-b7f5-ea07361f26da` | WRITE_NR, NOTIFY | BLE OTA firmware update |
| ECDH | `beb5483e-36e1-4688-b7f5-ea07361f26dc` | WRITE_NR, NOTIFY | ECDH sleuteluitwisseling |
| CLIENT_REGISTRY | `beb5483e-36e1-4688-b7f5-ea07361f26dd` | READ, WRITE_NR, NOTIFY | Client registratie (UUID + versie) |
| CLIENT_CONTROL | `beb5483e-36e1-4688-b7f5-ea07361f26de` | WRITE_NR, NOTIFY | Broker protocol (OTA notificaties) |
### Key provisioning
@ -249,7 +261,7 @@ Setup done.
**Client firmware log (geprovisioneerd):**
```
=== SylDa Client v5 ===
=== AtomS3 Lite SylDa Client v6 ===
PSK loaded from NVS
UUID loaded from NVS
BLE: scanning...
@ -311,6 +323,7 @@ Firmware binaries: `tools/copy_firmware.sh` kopieert `.bin` bestanden naar `asse
- **Key exchange geïntegreerd:** Module bijwerken = versie check → flashen (indien nodig) → PSK-uitwisseling — volledig automatisch
- **Thema-selector:** Instellingen → Weergave met Systeem, Licht of Donker
- **Permissies:** Vraagt BLUETOOTH_SCAN en BLUETOOTH_CONNECT. Bij weigeren wordt verwezen naar Instellingen
- **Broker protocol:** Ontvangt `CLIENT_CONTROL` notificaties van de server (OTA beschikbaar, client ready)
### Instellingenscherm
@ -333,7 +346,7 @@ De PSK en UUIDs worden opgeslagen in SharedPreferences en overleven app-herstart
Vereisten:
- Android SDK (API 34+)
- Java 17 target (JDK 21 voor Gradle — Java 26 werkt niet)
- Java 21 (Java 26 werkt niet met Gradle)
- Gradle 8.14 (via wrapper)
- AGP 8.5.2, Kotlin 2.0.21 (K2 compiler)
- USB host support (raw Android USB API, geen externe serial library)
@ -388,8 +401,8 @@ Project_SylDa/
├── version.txt # Single source of truth voor firmwareversies
├── icon.png / icon.svg # Project icon
├── src/
│ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA)
│ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais)
│ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA + broker)
│ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais + OTA server mode)
│ ├── ecdh_helper.h # ECDH crypto helper (secp256r1, AES-sleutel afleiding, pubkey HMAC)
│ └── version.h # Auto-gegenereerd uit version.txt — niet handmatig bewerken
├── tools/
@ -479,10 +492,11 @@ HMAC-SHA256 (vroegere versies) bood authenticatie maar geen confidentiality. ECD
- De app gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) — geen externe serial library.
- `minSdk` is 26 (Android 8.0). `compileSdk`/`targetSdk` blijft op 34.
- Sequence counters starten bij 1 (niet 0), wrappen bij 1.000.000.
- BLE MTU: server stelt 248 in (ESP32 NimBLE limiet), app vraagt 512. OTA/ECDH chunks passen zich aan aan `negotiatedMtu - 7`.
- BLE MTU: server stelt 248 in (ESP32 NimBLE limiet), app vraagt 512. OTA/ECDH chunks passen zich aan aan `negotiatedMtu - 3`.
- Server krijgt maximaal 2 gelijktijdige BLE-clients. Client slaat server BLE-adres op in NVS voor snelle reconnect (valt terug op scan als adres niet werkt).
- Server advertising bevat manufacturer data (0xFFFF) met eerste 4 bytes van device UUID voor snelle identificatie.
- OTA: 500-byte chunks (aangepast aan MTU), 3 retries per chunk, SHA-256 hash verificatie, 10s 0xFF confirm timeout.
- Client in OTA server modus advertiseert als `SylDa_Client_OTA_XX` (XX = laatste 2 hex van device UUID).
## Release

View file

@ -47,7 +47,13 @@ class BleRelayManager(
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db")
private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
private val ECDH_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dc")
private val CLIENT_REGISTRY_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dd")
private val CLIENT_CONTROL_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26de")
private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val BROKER_PLAIN_SIZE = 20
private const val BROKER_PKT_SIZE = 4 + 8 + BROKER_PLAIN_SIZE + 16
private const val BROKER_CMD_UPDATE_AVAILABLE: Byte = 0x10
private const val BROKER_CMD_CLIENT_READY: Byte = 0x20
private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L
@ -83,6 +89,13 @@ class BleRelayManager(
@Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null
private var ecdhNotifyBuf: ByteArray = ByteArray(0)
private var ecdhCccdPending = false
private var registryCharacteristic: BluetoothGattCharacteristic? = null
private var controlCharacteristic: BluetoothGattCharacteristic? = null
private var brokerNotifyBuf: ByteArray = ByteArray(0)
private var brokerDeferred: CompletableDeferred<Boolean>? = null
private var clientUuid: ByteArray? = null
private var clientVersion: Int = 0
private var clientStatus: Int = 0
interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int)
@ -279,6 +292,95 @@ class BleRelayManager(
fun getNegotiatedMtu(): Int = negotiatedMtu
fun getClientUuid(): ByteArray? = clientUuid
fun getClientVersion(): Int = clientVersion
fun getClientStatus(): Int = clientStatus
@SuppressLint("MissingPermission")
fun sendUpdateAvailable(targetUuid: ByteArray): Boolean {
val gatt = bluetoothGatt ?: return false
val key = aesKey ?: return false
val ctrlChar = controlCharacteristic ?: return false
val plainPayload = ByteArray(BROKER_PLAIN_SIZE)
plainPayload[0] = BROKER_CMD_UPDATE_AVAILABLE
System.arraycopy(targetUuid, 0, plainPayload, 1, targetUuid.size)
val pkt = buildBrokerPacket(plainPayload, key)
ctrlChar.value = pkt
ctrlChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return gatt.writeCharacteristic(ctrlChar)
}
@SuppressLint("MissingPermission")
fun subscribeToBrokerNotifications(): Boolean {
val gatt = bluetoothGatt ?: return false
val ctrlChar = controlCharacteristic ?: return false
if (!gatt.setCharacteristicNotification(ctrlChar, true)) {
Log.e(TAG, "Broker: setCharacteristicNotification returned false")
return false
}
val desc = ctrlChar.getDescriptor(OTA_CCCD_UUID)
if (desc != null) {
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (!gatt.writeDescriptor(desc)) {
Log.w(TAG, "Broker: writeDescriptor for CCCD returned false")
}
}
return true
}
private fun buildBrokerPacket(plainPayload: ByteArray, key: ByteArray): ByteArray {
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
val nonceRandom = ByteArray(8)
SecureRandom().nextBytes(nonceRandom)
val nonce = ByteArray(GCM_NONCE_LEN)
System.arraycopy(seqBytes, 0, nonce, 0, 4)
System.arraycopy(nonceRandom, 0, nonce, 4, 8)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE,
SecretKeySpec(key, "AES"),
GCMParameterSpec(GCM_TAG_LEN * 8, nonce))
val cipherOutput = cipher.doFinal(plainPayload)
val packet = ByteArray(BROKER_PKT_SIZE)
System.arraycopy(seqBytes, 0, packet, 0, 4)
System.arraycopy(nonceRandom, 0, packet, 4, 8)
System.arraycopy(cipherOutput, 0, packet, 12, cipherOutput.size)
return packet
}
private fun decryptBrokerPacket(data: ByteArray, key: ByteArray): ByteArray? {
if (data.size != BROKER_PKT_SIZE) return null
val nonce = ByteArray(GCM_NONCE_LEN)
System.arraycopy(data, 0, nonce, 0, 4)
System.arraycopy(data, 4, nonce, 4, 8)
val ciphertext = ByteArray(BROKER_PLAIN_SIZE)
System.arraycopy(data, 12, ciphertext, 0, BROKER_PLAIN_SIZE)
val tag = ByteArray(GCM_TAG_LEN)
System.arraycopy(data, 12 + BROKER_PLAIN_SIZE, tag, 0, GCM_TAG_LEN)
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val gcmSpec = GCMParameterSpec(GCM_TAG_LEN * 8, nonce)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), gcmSpec)
cipher.update(ciphertext)
val result = cipher.doFinal(tag)
result
} catch (e: Exception) {
Log.e(TAG, "Broker: decrypt failed", e)
null
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
@ -336,11 +438,16 @@ class BleRelayManager(
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
otaCharacteristic = service.getCharacteristic(OTA_UUID)
ecdhCharacteristic = service.getCharacteristic(ECDH_UUID)
registryCharacteristic = service.getCharacteristic(CLIENT_REGISTRY_UUID)
controlCharacteristic = service.getCharacteristic(CLIENT_CONTROL_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
val versionChar = service.getCharacteristic(VERSION_UUID)
if (relayCharacteristic != null && uuidChar != null) {
Log.d(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
if (controlCharacteristic != null) {
subscribeToBrokerNotifications()
}
} else {
Log.e(TAG, "Service or characteristics not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
@ -407,6 +514,23 @@ class BleRelayManager(
ecdhNotifyBuf = value.copyOf()
ecdhDeferred?.complete(true)
Log.d(TAG, "ECDH notify received (${value.size} bytes)")
} else if (characteristic.uuid == CLIENT_CONTROL_UUID) {
Log.d(TAG, "Broker: control notification received (${value.size} bytes)")
val key = aesKey
if (key != null) {
val plainPayload = decryptBrokerPacket(value, key)
if (plainPayload != null && plainPayload.size >= BROKER_PLAIN_SIZE) {
val cmd = plainPayload[0].toInt() and 0xFF
Log.d(TAG, "Broker: decrypted cmd=0x${cmd.toString(16)}")
if (cmd == BROKER_CMD_CLIENT_READY.toInt()) {
val uuid = plainPayload.copyOfRange(1, 1 + 16)
clientUuid = uuid
clientStatus = 1
Log.d(TAG, "Broker: client ready for OTA, uuid=${uuid.joinToString("") { "%02X".format(it) }}")
handler.post { callbacks.onClientReady(uuid) }
}
}
}
}
}

View file

@ -19,4 +19,5 @@ enum class BleStatus {
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
fun onFirmwareVersion(espVersion: String, appVersion: String)
fun onClientReady(uuid: ByteArray)
}

View file

@ -226,4 +226,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
}
}
}
override fun onClientReady(uuid: ByteArray) {
runOnUiThread {
if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread
Toast.makeText(this, "Client ready for OTA update", Toast.LENGTH_SHORT).show()
val intent = Intent(this, SettingsActivity::class.java)
intent.putExtra("client_ota_uuid", uuid)
settingsLauncher.launch(intent)
}
}
}

View file

@ -0,0 +1,86 @@
# Spec: Client BLE OTA via Broker Server
**Status:** Draft
**Datum:** 2026-08-04
**Auteur:** opencore (AI Agent)
## 1. Introductie
Dit document beschrijft de architectuur en het protocol voor het uitvoeren van een Firmware Over-The-Air (OTA) update op de SylDa_Client (ESP32) via de SylDa_Server (ESP32) als broker.
## 2. Architectuur
Het systeem bestaat uit drie componenten:
1. **Android App:** Initiator van de update, bevat de nieuwe firmware binary.
2. **SylDa_Server (Central/Server):** Fungeert als informatiepunt (broker) tussen App en Client.
3. **SylDa_Client (Central/Client):** Ontvanger van de update. Gaat in een "Dual-Role" (Central + Server) tijdens de update procedure.
## 3. BLE Interface (SylDa_Server)
De Server breidt zijn bestaande service (`4fafc201-1fb5-459e-8fcc-c5c9c331914b`) uit met de volgende characteristics:
### 3.1 CLIENT_REGISTRY (`beb5483e-36e1-4688-b7f5-ea07361f26dd`)
* **Eigenschappen:** Read, Notify
* **Beveiliging:** AES/GCM encrypted
* **Payload (37 bytes totaal):**
* `[0:3]` Seq (4 bytes)
* `[4:11]` Nonce Random (8 bytes)
* `[12]` Ciphertext: `[UUID (16b) | Version (2b) | Status (1b)]`
* `[13:28]` Auth Tag (16 bytes)
* **Status codes:**
* `0x00`: Normaal / Idle
* `0x01`: Ready for OTA (Advertising)
* `0x02`: Updating (Transfer bezig)
### 3.2 CLIENT_CONTROL (`beb5483e-36e1-4688-b7f5-ea07361f26de`)
* **Eigenschappen:** Write, Notify
* **Beveiliging:** AES/GCM encrypted
* **Payload (37 bytes totaal):**
* `[0:3]` Seq (4 bytes)
* `[4:11]` Nonce Random (8 bytes)
* `[12]` Ciphertext: `[TargetUUID (16b) | Action (1b)]`
* `[13:28]` Auth Tag (16 bytes)
* **Action codes:**
* `0x10`: `NOTIFY_UPDATE_AVAILABLE` (Server -> Client)
* `0x20`: `CLIENT_READY_FOR_OTA` (Client -> Server -> App)
## 4. BLE Interface (SylDa_Client OTA Mode)
Wanneer de Client in OTA-modus gaat, start hij een eigen BLE Server met de volgende gegevens:
* **Device Name:** `SylDa_Client_OTA_[Last-4-chars-UUID]`
* **Service UUID:** `4fafc201-1fb5-459e-8fcc-c5c9c331914b` (Gelijk aan Server)
* **OTA Characteristic:** `beb5483e-36e1-4688-b7f5-ea07361f26da` (Gelijk aan Server)
* **UUID Characteristic:** `beb5483e-36e1-4688-b7f5-ea07361f26d9` (Bevat eigen UUID ter verificatie)
## 5. Sequence Diagram
1. **Bootstrapping:**
* Client verbindt met Server.
* Client schrijft zijn UUID en Versie naar `CLIENT_REGISTRY` op de Server.
2. **Update Detectie:**
* App verbindt met Server.
* App leest `CLIENT_REGISTRY` en ziet dat Client v5 draait (App heeft v6).
3. **Update Trigger:**
* App stuurt `0x10` (Update Available) naar `CLIENT_CONTROL` (Target: Client UUID).
* Server stuurt Notify naar Client.
4. **Client Voorbereiding:**
* Client ontvangt notificatie.
* Client start `NimBLEServer` en begint te adverteren.
* Client schrijft `0x20` (Ready) naar `CLIENT_CONTROL` op de Server.
5. **App Verbinding naar Client:**
* App ontvangt `0x20` notificatie van de Server.
* App (na gebruikersbevestiging) scant naar `SylDa_Client_OTA` met de juiste UUID.
* App verbindt direct met de Client.
6. **OTA Transfer:**
* App voert de standaard OTA-procedure uit (Start command, chunks, SHA256 verify, End confirm).
* Client verwerkt de OTA en voert zijn relais-taken uit zolang de transfer niet is gestart.
7. **Finalisatie:**
* Client herstart na succesvolle OTA.
* Client verbindt opnieuw met Server en rapporteert de nieuwe versie.
## 6. Beveiliging en Foutafhandeling
* **Encryptie:** Alle "Broker" communicatie op de Server verloopt via de bestaande AES/GCM laag met de gedeelde PSK.
* **UUID Pinning:** De App MOET de UUID van de Client verifiëren via de `UUID_CHAR_UUID` voordat de OTA start.
* **Timeouts:** De Client stopt de OTA-modus (Server-rol) na een succesvolle update of een handmatige reset. (Gebruiker behoudt controle via de App).
* **Relais:** De Client blijft operationeel tot het moment dat de OTA-transfer daadwerkelijk begint.
## 7. Rollen en Verantwoordelijkheden
* **Server:** Passief doorgeefluik van status en notificaties. Slaat geen firmware op.
* **Client:** Verantwoordelijk voor het veilig flashen van de eigen firmware en het rapporteren van de status.
* **App:** Verantwoordelijk voor het orkestreren van het proces en het leveren van de firmware.

View file

@ -6,11 +6,12 @@
#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"
#include "serial_utils.h"
static const char *TAG = "client";
@ -38,7 +39,15 @@ CRGB leds[NUM_LEDS];
#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"
@ -76,6 +85,30 @@ 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;
@ -127,6 +160,20 @@ class MyClientCallbacks : public NimBLEClientCallbacks {
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;
@ -173,6 +220,182 @@ static void disconnectClient() {
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");
@ -308,6 +531,137 @@ static bool connectToServer() {
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;
}
@ -517,9 +871,46 @@ void loop() {
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 (otaInProgress) {
if (otaServerMode || otaInProgress) {
ledColor = otaEnded
? CRGB::Yellow
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);

View file

@ -13,7 +13,6 @@
#include <esp_log.h>
#include "version.h"
#include "ecdh_helper.h"
#include "serial_utils.h"
static const char *TAG = "server";
@ -47,6 +46,13 @@ CRGB leds[NUM_LEDS];
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#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 BROKER_CMD_UPDATE_AVAILABLE 0x10
#define BROKER_CMD_CLIENT_READY 0x20
#define BROKER_PLAIN_SIZE 20
#define BROKER_PKT_SIZE (4 + 8 + BROKER_PLAIN_SIZE + 16)
#define OTA_CHUNK_SIZE 500
#define OTA_CMD_START 0x10
@ -81,6 +87,20 @@ struct ClientState {
};
ClientState clients[MAX_CLIENTS];
#define BROKER_STATUS_IDLE 0x00
#define BROKER_STATUS_READY 0x01
#define BROKER_STATUS_UPDATING 0x02
struct RemoteClientInfo {
uint8_t uuid[ESP_UUID_LEN];
uint16_t version;
uint8_t status;
bool active;
};
RemoteClientInfo remoteClients[MAX_CLIENTS];
NimBLECharacteristic *pRegistryChar = nullptr;
NimBLECharacteristic *pControlChar = nullptr;
uint8_t psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
bool pskProvisioned = false;
@ -153,6 +173,10 @@ class MyServerCallbacks : public NimBLEServerCallbacks {
clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN);
clients[i].ecdh.free();
remoteClients[i].active = false;
remoteClients[i].version = 0;
remoteClients[i].status = BROKER_STATUS_IDLE;
memset(remoteClients[i].uuid, 0, ESP_UUID_LEN);
connHandles[i] = 0xFFFF;
connectedCount--;
ESP_LOGI(TAG, "BLE: disconnected (slot=%d, clients=%d/%d, mac=%s)",
@ -212,6 +236,20 @@ static bool decryptAesGcm(const uint8_t *packet, int idx, uint8_t *outCmd) {
return true;
}
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 otaReset() {
otaInProgress = false;
otaHandle = 0;
@ -352,6 +390,193 @@ class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
}
};
static void sendBrokerNotify(int clientIdx, uint8_t cmd, const uint8_t *data, size_t dataLen) {
if (clientIdx < 0 || clientIdx >= MAX_CLIENTS || !connActive[clientIdx]) return;
uint8_t plainPayload[BROKER_PLAIN_SIZE];
memset(plainPayload, 0, BROKER_PLAIN_SIZE);
plainPayload[0] = cmd;
memcpy(plainPayload + 1, data, dataLen);
uint8_t pkt[BROKER_PKT_SIZE];
memset(pkt, 0, BROKER_PKT_SIZE);
uint32_t s = 0;
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, clients[clientIdx].aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "BROKER: send notify setkey failed");
mbedtls_gcm_free(&gcm);
return;
}
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);
pControlChar->setValue(pkt, BROKER_PKT_SIZE);
pControlChar->notify(connHandles[clientIdx], true);
}
class RegistryCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
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 != BROKER_PKT_SIZE) {
ESP_LOGW(TAG, "REGISTRY: bad packet length %d (expected %d)", (int)len, BROKER_PKT_SIZE);
return;
}
int idx = findClientIdx(connInfo.getConnHandle());
if (idx < 0 || !clients[idx].ecdhReady) {
ESP_LOGW(TAG, "REGISTRY: write rejected — no ECDH (handle=%d)", connInfo.getConnHandle());
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, clients[idx].aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "REGISTRY: 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, "REGISTRY: AES-GCM decrypt failed (ret=%d)", ret);
return;
}
uint8_t cmd = plainPayload[0];
if (cmd != 0x00) {
ESP_LOGW(TAG, "REGISTRY: unexpected cmd 0x%02X (expected 0x00)", cmd);
return;
}
memcpy(remoteClients[idx].uuid, plainPayload + 1, ESP_UUID_LEN);
remoteClients[idx].version = ((uint16_t)plainPayload[1 + ESP_UUID_LEN] << 8) |
(uint16_t)plainPayload[1 + ESP_UUID_LEN + 1];
remoteClients[idx].status = BROKER_STATUS_IDLE;
remoteClients[idx].active = true;
ESP_LOGI(TAG, "REGISTRY: client registered (slot=%d, uuid=%02X%02X%02X%02X, ver=%d)",
idx,
remoteClients[idx].uuid[0], remoteClients[idx].uuid[1],
remoteClients[idx].uuid[2], remoteClients[idx].uuid[3],
remoteClients[idx].version);
}
};
class ControlCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
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 != BROKER_PKT_SIZE) {
ESP_LOGW(TAG, "BROKER: bad packet length %d (expected %d)", (int)len, BROKER_PKT_SIZE);
return;
}
int idx = findClientIdx(connInfo.getConnHandle());
if (idx < 0 || !clients[idx].ecdhReady) {
ESP_LOGW(TAG, "BROKER: write rejected — no ECDH (handle=%d)", connInfo.getConnHandle());
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, clients[idx].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 from slot %d", cmd, idx);
if (cmd == BROKER_CMD_UPDATE_AVAILABLE) {
uint8_t targetUuid[ESP_UUID_LEN];
memcpy(targetUuid, plainPayload + 1, ESP_UUID_LEN);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (remoteClients[i].active &&
memcmp(remoteClients[i].uuid, targetUuid, ESP_UUID_LEN) == 0) {
ESP_LOGI(TAG, "BROKER: forwarding update notification to client slot %d", i);
sendBrokerNotify(i, BROKER_CMD_UPDATE_AVAILABLE, targetUuid, ESP_UUID_LEN);
break;
}
}
} else if (cmd == BROKER_CMD_CLIENT_READY) {
uint8_t clientUuid[ESP_UUID_LEN];
memcpy(clientUuid, plainPayload + 1, ESP_UUID_LEN);
ESP_LOGI(TAG, "BROKER: client ready for OTA");
for (int i = 0; i < MAX_CLIENTS; i++) {
if (remoteClients[i].active &&
memcmp(remoteClients[i].uuid, clientUuid, ESP_UUID_LEN) == 0) {
remoteClients[i].status = BROKER_STATUS_READY;
break;
}
}
} else {
ESP_LOGW(TAG, "BROKER: unknown cmd 0x%02X", cmd);
}
}
};
class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
@ -487,6 +712,10 @@ void setup() {
for (int i = 0; i < MAX_CLIENTS; i++) {
clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN);
remoteClients[i].active = false;
remoteClients[i].version = 0;
remoteClients[i].status = BROKER_STATUS_IDLE;
memset(remoteClients[i].uuid, 0, ESP_UUID_LEN);
}
pinMode(TOUCH1_PIN, INPUT_PULLUP);
@ -536,7 +765,17 @@ void setup() {
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
pService->start();
pRegistryChar = pService->createCharacteristic(
CLIENT_REGISTRY_UUID,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pRegistryChar->setCallbacks(new RegistryCharacteristicCallbacks());
pControlChar = pService->createCharacteristic(
CLIENT_CONTROL_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pControlChar->setCallbacks(new ControlCharacteristicCallbacks());
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("SylDa_Server");

View file

@ -1,18 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <Arduino.h>
static inline 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 inline 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);
}
}