Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa2600bde | ||
|
|
290a87ee65 | ||
|
|
5bfdd3f29d | ||
|
|
e1282acd29 |
8 changed files with 31 additions and 878 deletions
32
README.md
32
README.md
|
|
@ -123,6 +123,7 @@ 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)
|
||||
|
|
@ -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 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)** | **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 |
|
||||
| **Afluisteren** | AES-256-GCM biedt confidentiality — commando's zijn onleesbaar zonder AES-sleutel |
|
||||
| **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.
|
||||
|
||||
> **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, broker voor OTA-distributie |
|
||||
| `s3lite-client` | `src/client.cpp` | Verbindt als BLE-central met server, stuurt relais-commando's, ontvangt OTA |
|
||||
| `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 |
|
||||
|
||||
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 |
|
||||
| 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
|
||||
|
||||
|
|
@ -261,7 +249,7 @@ Setup done.
|
|||
|
||||
**Client firmware log (geprovisioneerd):**
|
||||
```
|
||||
=== AtomS3 Lite SylDa Client v6 ===
|
||||
=== SylDa Client v5 ===
|
||||
PSK loaded from NVS
|
||||
UUID loaded from NVS
|
||||
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
|
||||
- **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
|
||||
|
||||
|
|
@ -346,7 +333,7 @@ De PSK en UUIDs worden opgeslagen in SharedPreferences en overleven app-herstart
|
|||
|
||||
Vereisten:
|
||||
- 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)
|
||||
- AGP 8.5.2, Kotlin 2.0.21 (K2 compiler)
|
||||
- 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
|
||||
├── icon.png / icon.svg # Project icon
|
||||
├── src/
|
||||
│ ├── 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)
|
||||
│ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA)
|
||||
│ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais)
|
||||
│ ├── ecdh_helper.h # ECDH crypto helper (secp256r1, AES-sleutel afleiding, pubkey HMAC)
|
||||
│ └── version.h # Auto-gegenereerd uit version.txt — niet handmatig bewerken
|
||||
├── 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.
|
||||
- `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 - 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 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
|
||||
|
||||
|
|
|
|||
|
|
@ -47,13 +47,7 @@ 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
|
||||
|
|
@ -89,13 +83,6 @@ 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)
|
||||
|
|
@ -292,95 +279,6 @@ 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) }
|
||||
|
|
@ -438,16 +336,11 @@ 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) }
|
||||
|
|
@ -514,23 +407,6 @@ 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,5 +19,4 @@ enum class BleStatus {
|
|||
interface BleCallbacks {
|
||||
fun onStatusChanged(status: BleStatus)
|
||||
fun onFirmwareVersion(espVersion: String, appVersion: String)
|
||||
fun onClientReady(uuid: ByteArray)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
395
src/client.cpp
395
src/client.cpp
|
|
@ -6,12 +6,11 @@
|
|||
#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";
|
||||
|
||||
|
|
@ -39,15 +38,7 @@ 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"
|
||||
|
|
@ -85,30 +76,6 @@ 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;
|
||||
|
|
@ -160,20 +127,6 @@ 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;
|
||||
|
|
@ -220,182 +173,6 @@ 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");
|
||||
|
|
@ -531,137 +308,6 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -871,46 +517,9 @@ 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 (otaServerMode || otaInProgress) {
|
||||
if (otaInProgress) {
|
||||
ledColor = otaEnded
|
||||
? CRGB::Yellow
|
||||
: (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black);
|
||||
|
|
|
|||
243
src/main.cpp
243
src/main.cpp
|
|
@ -13,6 +13,7 @@
|
|||
#include <esp_log.h>
|
||||
#include "version.h"
|
||||
#include "ecdh_helper.h"
|
||||
#include "serial_utils.h"
|
||||
|
||||
static const char *TAG = "server";
|
||||
|
||||
|
|
@ -46,13 +47,6 @@ 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
|
||||
|
|
@ -87,20 +81,6 @@ 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;
|
||||
|
|
@ -173,10 +153,6 @@ 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)",
|
||||
|
|
@ -236,20 +212,6 @@ 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;
|
||||
|
|
@ -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 {
|
||||
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
|
||||
if (otaInProgress) return;
|
||||
|
|
@ -712,10 +487,6 @@ 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);
|
||||
|
|
@ -765,17 +536,7 @@ void setup() {
|
|||
);
|
||||
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
|
||||
|
||||
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());
|
||||
pService->start();
|
||||
|
||||
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
|
||||
pAdvertising->setName("SylDa_Server");
|
||||
|
|
|
|||
18
src/serial_utils.h
Normal file
18
src/serial_utils.h
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue