Compare commits

..
Author SHA1 Message Date
eddy
daa2600bde refactor: extract duplicate isHex/parseHex into shared serial_utils.h
All checks were successful
CI / build (push) Successful in 1m7s
Fixes #19
2026-08-05 08:23:34 +02:00
eddy
290a87ee65 docs: remove non-existent features from README (broker, client OTA server mode)
All checks were successful
CI / build (push) Successful in 1m12s
2026-08-05 07:28:46 +02:00
eddy
5bfdd3f29d docs: fix BLE spoofing analysis — client now uses UUID+ECDH, not name-only scan
All checks were successful
CI / build (push) Successful in 1m8s
2026-08-04 23:26:04 +02:00
eddy
e1282acd29 docs: update README to reflect ECDH+AES-GCM, BLE OTA, multi-client, and broker features
All checks were successful
CI / build (push) Successful in 57s
2026-08-04 23:21:20 +02:00
8 changed files with 31 additions and 878 deletions

View file

@ -123,6 +123,7 @@ module → App: SylDa_Server\n of SylDa_Client\n (device type)
App → module: KEY?\n App → module: KEY?\n
module → App: DEVICE_ID:<hex>\n (module UUID, 16 bytes = 32 hex) module → App: DEVICE_ID:<hex>\n (module UUID, 16 bytes = 32 hex)
module → App: PSK:<hex>\n (PSK, 32 bytes = 64 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) module → App: NO_KEY\n (als module nog geen key heeft)
App → module: SET_KEY:<hex>\n (nieuwe PSK schrijven, 64 hex) App → module: SET_KEY:<hex>\n (nieuwe PSK schrijven, 64 hex)
@ -139,22 +140,13 @@ 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 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 ### Beveiligingsanalyse
| Aanval | Hoe geweerd | | Aanval | Hoe geweerd |
|---|---| |---|---|
| **Replay** | Sequence counter — zelfde packet werkt niet 2× (`seq <= lastSeq` geweerd) | | **Replay** | Sequence counter — zelfde packet werkt niet 2× (`seq <= lastSeq` geweerd) |
| **Ongeautoriseerd device** | ECDH pubkey HMAC vereist PSK — verkeerde PSK = afgewezen public key | | **Ongeautoriseerd device** | ECDH pubkey HMAC vereist PSK — verkeerde PSK = afgewezen public key |
| **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. | | **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. |
| **Commando vervalsing** | AES-GCM versleutelt en authenticeert command byte + nonce — wijzigen maakt tag ongeldig | | **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 | | **Afluisteren** | AES-256-GCM biedt confidentiality — commando's zijn onleesbaar zonder AES-sleutel |
| **Brute-force PSK** | 32-byte PSK = 2^256 mogelijkheden — onhaalbaar | | **Brute-force PSK** | 32-byte PSK = 2^256 mogelijkheden — onhaalbaar |
@ -177,16 +169,14 @@ 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. 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 ## Firmware
De firmware is geschreven in C++ met PlatformIO en de Arduino-framework. BLE: ESP32 NimBLE library, crypto: mbedtls (ingebouwd in ESP-IDF). 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 | | Environment | Bronbestand | Rol |
|---|---|---| |---|---|---|
| `s3lite-server` | `src/main.cpp` | Ontvangt BLE-verbindingen, bedient relais, broker voor OTA-distributie | | `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, ontvangt OTA | | `s3lite-client` | `src/client.cpp` | Verbindt als BLE-central met server, stuurt relais-commando's |
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`. 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`.
@ -217,8 +207,6 @@ De touch sensoren, BLE, en interne knop werken onafhankelijk (OR-logica). De int
| VERSION | `beb5483e-36e1-4688-b7f5-ea07361f26db` | READ | Firmware versie string | | VERSION | `beb5483e-36e1-4688-b7f5-ea07361f26db` | READ | Firmware versie string |
| OTA | `beb5483e-36e1-4688-b7f5-ea07361f26da` | WRITE_NR, NOTIFY | BLE OTA firmware update | | OTA | `beb5483e-36e1-4688-b7f5-ea07361f26da` | WRITE_NR, NOTIFY | BLE OTA firmware update |
| ECDH | `beb5483e-36e1-4688-b7f5-ea07361f26dc` | WRITE_NR, NOTIFY | ECDH sleuteluitwisseling | | 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 ### Key provisioning
@ -261,7 +249,7 @@ Setup done.
**Client firmware log (geprovisioneerd):** **Client firmware log (geprovisioneerd):**
``` ```
=== AtomS3 Lite SylDa Client v6 === === SylDa Client v5 ===
PSK loaded from NVS PSK loaded from NVS
UUID loaded from NVS UUID loaded from NVS
BLE: scanning... BLE: scanning...
@ -323,7 +311,6 @@ 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 - **Key exchange geïntegreerd:** Module bijwerken = versie check → flashen (indien nodig) → PSK-uitwisseling — volledig automatisch
- **Thema-selector:** Instellingen → Weergave met Systeem, Licht of Donker - **Thema-selector:** Instellingen → Weergave met Systeem, Licht of Donker
- **Permissies:** Vraagt BLUETOOTH_SCAN en BLUETOOTH_CONNECT. Bij weigeren wordt verwezen naar Instellingen - **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 ### Instellingenscherm
@ -346,7 +333,7 @@ De PSK en UUIDs worden opgeslagen in SharedPreferences en overleven app-herstart
Vereisten: Vereisten:
- Android SDK (API 34+) - Android SDK (API 34+)
- Java 21 (Java 26 werkt niet met Gradle) - Java 17 target (JDK 21 voor Gradle — Java 26 werkt niet)
- Gradle 8.14 (via wrapper) - Gradle 8.14 (via wrapper)
- AGP 8.5.2, Kotlin 2.0.21 (K2 compiler) - AGP 8.5.2, Kotlin 2.0.21 (K2 compiler)
- USB host support (raw Android USB API, geen externe serial library) - USB host support (raw Android USB API, geen externe serial library)
@ -401,8 +388,8 @@ Project_SylDa/
├── version.txt # Single source of truth voor firmwareversies ├── version.txt # Single source of truth voor firmwareversies
├── icon.png / icon.svg # Project icon ├── icon.png / icon.svg # Project icon
├── src/ ├── src/
│ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA + broker) │ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA)
│ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais + OTA server mode) │ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais)
│ ├── ecdh_helper.h # ECDH crypto helper (secp256r1, AES-sleutel afleiding, pubkey HMAC) │ ├── ecdh_helper.h # ECDH crypto helper (secp256r1, AES-sleutel afleiding, pubkey HMAC)
│ └── version.h # Auto-gegenereerd uit version.txt — niet handmatig bewerken │ └── version.h # Auto-gegenereerd uit version.txt — niet handmatig bewerken
├── tools/ ├── tools/
@ -492,11 +479,10 @@ HMAC-SHA256 (vroegere versies) bood authenticatie maar geen confidentiality. ECD
- De app gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) — geen externe serial library. - De app gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) — geen externe serial library.
- `minSdk` is 26 (Android 8.0). `compileSdk`/`targetSdk` blijft op 34. - `minSdk` is 26 (Android 8.0). `compileSdk`/`targetSdk` blijft op 34.
- Sequence counters starten bij 1 (niet 0), wrappen bij 1.000.000. - 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 - 3`. - BLE MTU: server stelt 248 in (ESP32 NimBLE limiet), app vraagt 512. OTA/ECDH chunks passen zich aan aan `negotiatedMtu - 7`.
- 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 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. - 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. - 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 ## Release

View file

@ -47,13 +47,7 @@ class BleRelayManager(
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db") 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 OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
private val ECDH_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dc") 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 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 SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L private const val RECONNECT_DELAY_MS = 2000L
@ -89,13 +83,6 @@ class BleRelayManager(
@Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null @Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null
private var ecdhNotifyBuf: ByteArray = ByteArray(0) private var ecdhNotifyBuf: ByteArray = ByteArray(0)
private var ecdhCccdPending = false 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 { interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int) fun onOtaNotify(status: Int, seq: Int)
@ -292,95 +279,6 @@ class BleRelayManager(
fun getNegotiatedMtu(): Int = negotiatedMtu 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") @SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) { private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) } handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
@ -438,16 +336,11 @@ class BleRelayManager(
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
otaCharacteristic = service.getCharacteristic(OTA_UUID) otaCharacteristic = service.getCharacteristic(OTA_UUID)
ecdhCharacteristic = service.getCharacteristic(ECDH_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 uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
val versionChar = service.getCharacteristic(VERSION_UUID) val versionChar = service.getCharacteristic(VERSION_UUID)
if (relayCharacteristic != null && uuidChar != null) { if (relayCharacteristic != null && uuidChar != null) {
Log.d(TAG, "Reading device UUID characteristic...") Log.d(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar) gatt.readCharacteristic(uuidChar)
if (controlCharacteristic != null) {
subscribeToBrokerNotifications()
}
} else { } else {
Log.e(TAG, "Service or characteristics not found") Log.e(TAG, "Service or characteristics not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) } handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
@ -514,23 +407,6 @@ class BleRelayManager(
ecdhNotifyBuf = value.copyOf() ecdhNotifyBuf = value.copyOf()
ecdhDeferred?.complete(true) ecdhDeferred?.complete(true)
Log.d(TAG, "ECDH notify received (${value.size} bytes)") 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,5 +19,4 @@ enum class BleStatus {
interface BleCallbacks { interface BleCallbacks {
fun onStatusChanged(status: BleStatus) fun onStatusChanged(status: BleStatus)
fun onFirmwareVersion(espVersion: String, appVersion: String) fun onFirmwareVersion(espVersion: String, appVersion: String)
fun onClientReady(uuid: ByteArray)
} }

View file

@ -226,14 +226,4 @@ 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

@ -1,86 +0,0 @@
# 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,12 +6,11 @@
#include <mbedtls/gcm.h> #include <mbedtls/gcm.h>
#include <mbedtls/sha256.h> #include <mbedtls/sha256.h>
#include <Preferences.h> #include <Preferences.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_log.h> #include <esp_log.h>
#include "version.h" #include "version.h"
#include "ecdh_helper.h" #include "ecdh_helper.h"
#include "serial_utils.h"
static const char *TAG = "client"; static const char *TAG = "client";
@ -39,15 +38,7 @@ CRGB leds[NUM_LEDS];
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8" #define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9" #define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc" #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 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_NAMESPACE "syl"
#define NVS_PSK_NAME "psk" #define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid" #define NVS_UUID_NAME "uuid"
@ -85,30 +76,6 @@ uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0; uint32_t relayFlashUntil = 0;
bool otaInProgress = false; bool otaInProgress = false;
bool otaEnded = 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 bleReady = false;
static bool serverFound = false; static bool serverFound = false;
@ -160,20 +127,6 @@ class MyClientCallbacks : public NimBLEClientCallbacks {
static MyClientCallbacks clientCallbacks; 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) { static void buildAesPacket(uint8_t cmd, uint8_t *packet) {
uint32_t s = ++seq; uint32_t s = ++seq;
packet[0] = (s >> 24) & 0xFF; packet[0] = (s >> 24) & 0xFF;
@ -220,182 +173,6 @@ static void disconnectClient() {
memset(aesKey, 0, AES_KEY_LEN); 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) { static bool performEcdhHandshake(NimBLERemoteService *pSvc) {
if (!pskProvisioned) { if (!pskProvisioned) {
ESP_LOGE(TAG, "ECDH: no PSK"); ESP_LOGE(TAG, "ECDH: no PSK");
@ -531,137 +308,6 @@ static bool connectToServer() {
relay1SendState = false; relay1SendState = false;
relay2SendState = false; relay2SendState = false;
ESP_LOGI(TAG, "BLE: server UUID verified, ECDH done, ready"); 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; return true;
} }
@ -871,46 +517,9 @@ void loop() {
bool relay1State = !t1Active || btnActive; bool relay1State = !t1Active || btnActive;
bool relay2State = !t2Active; 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; CRGB ledColor;
if (otaServerMode || otaInProgress) { if (otaInProgress) {
ledColor = otaEnded ledColor = otaEnded
? CRGB::Yellow ? CRGB::Yellow
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black); : (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);

View file

@ -13,6 +13,7 @@
#include <esp_log.h> #include <esp_log.h>
#include "version.h" #include "version.h"
#include "ecdh_helper.h" #include "ecdh_helper.h"
#include "serial_utils.h"
static const char *TAG = "server"; static const char *TAG = "server";
@ -46,13 +47,6 @@ CRGB leds[NUM_LEDS];
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db" #define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da" #define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc" #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_CHUNK_SIZE 500
#define OTA_CMD_START 0x10 #define OTA_CMD_START 0x10
@ -87,20 +81,6 @@ struct ClientState {
}; };
ClientState clients[MAX_CLIENTS]; 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 psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN]; uint8_t deviceUuid[ESP_UUID_LEN];
bool pskProvisioned = false; bool pskProvisioned = false;
@ -173,10 +153,6 @@ class MyServerCallbacks : public NimBLEServerCallbacks {
clients[i].ecdhReady = false; clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN); memset(clients[i].aesKey, 0, AES_KEY_LEN);
clients[i].ecdh.free(); 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; connHandles[i] = 0xFFFF;
connectedCount--; connectedCount--;
ESP_LOGI(TAG, "BLE: disconnected (slot=%d, clients=%d/%d, mac=%s)", ESP_LOGI(TAG, "BLE: disconnected (slot=%d, clients=%d/%d, mac=%s)",
@ -236,20 +212,6 @@ static bool decryptAesGcm(const uint8_t *packet, int idx, uint8_t *outCmd) {
return true; 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() { static void otaReset() {
otaInProgress = false; otaInProgress = false;
otaHandle = 0; otaHandle = 0;
@ -390,193 +352,6 @@ 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 { class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override { void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return; if (otaInProgress) return;
@ -712,10 +487,6 @@ void setup() {
for (int i = 0; i < MAX_CLIENTS; i++) { for (int i = 0; i < MAX_CLIENTS; i++) {
clients[i].ecdhReady = false; clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN); 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); pinMode(TOUCH1_PIN, INPUT_PULLUP);
@ -765,17 +536,7 @@ void setup() {
); );
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks()); pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
pRegistryChar = pService->createCharacteristic( pService->start();
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(); NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("SylDa_Server"); pAdvertising->setName("SylDa_Server");

18
src/serial_utils.h Normal file
View file

@ -0,0 +1,18 @@
// 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);
}
}