feat: add device UUID pairing — unique identity per ESP-phone pair

- Firmware: auto-generate 16-byte UUID on first boot (NVS), new READ characteristic
- BLE name: AtomS3R-Relay → SylDa_Server
- KEY? now returns DEVICE_ID + HMAC_KEY, SET_ID command for UUID provisioning
- App: UUID+HMAC loaded from prefs, scan by service UUID, verify UUID after connect
- UUID mismatch → disconnect + skip address + rescan
- Key conflict dialog: keep ESP keys, overwrite ESP (with checkbox confirm), or cancel
- NO_KEY with existing app keys: send existing pair instead of generating new
- Added design spec doc
This commit is contained in:
eddy 2026-07-29 18:12:13 +02:00
commit 348c55d830
9 changed files with 527 additions and 106 deletions

View file

@ -78,6 +78,12 @@ class EspFlasher(private val context: Context) {
val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes() val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes()
val firmware = assets.open("firmware/firmware.bin").readBytes() val firmware = assets.open("firmware/firmware.bin").readBytes()
// Flash sectie layout (partition table check):
// NVS 0x9000-0xDFFF — NIET overschreven (key/UUID blijft behouden)
// otadata 0xE000-0xFFFF — overschreven door boot_app0
// app0 0x10000-... — firmware overschrijft
// Alleen schrijven naar benodigde adressen, geen full erase
val steps = listOf( val steps = listOf(
FlashStep("Bootloader", 0x0, bootloader, 30, 10, false), FlashStep("Bootloader", 0x0, bootloader, 30, 10, false),
FlashStep("Partitie tabel", 0x8000, partitions, 40, 10, false), FlashStep("Partitie tabel", 0x8000, partitions, 40, 10, false),
@ -105,9 +111,6 @@ class EspFlasher(private val context: Context) {
callback.onProgress(20, "Stub laden...") callback.onProgress(20, "Stub laden...")
val stub = detected.loadStub() val stub = detected.loadStub()
callback.onProgress(25, "Flash wissen...")
stub.eraseFlash()
for (step in steps) { for (step in steps) {
coroutineContext.ensureActive() coroutineContext.ensureActive()
val sizeStr = if (step.data.size > 1024) "${step.data.size / 1024}KB" else "${step.data.size} bytes" val sizeStr = if (step.data.size > 1024) "${step.data.size / 1024}KB" else "${step.data.size} bytes"

View file

@ -11,13 +11,16 @@ import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.ParcelUuid
import android.util.Log import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.Button import android.widget.Button
@ -37,42 +40,52 @@ class MainActivity : AppCompatActivity() {
private const val TAG = "AtomRelay" private const val TAG = "AtomRelay"
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val DEVICE_NAME = "AtomS3R-Relay" private const val DEVICE_NAME = "SylDa_Server"
private const val HMAC_PKT_SIZE = 37 private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32 private const val HMAC_LEN = 32
private const val REQUEST_SETTINGS = 1 private const val REQUEST_SETTINGS = 1
private const val SCAN_TIMEOUT_MS = 10000L private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L private const val RECONNECT_DELAY_MS = 2000L
private const val POST_SETTINGS_RESCAN_MS = 500L private const val POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L private const val ON_RESUME_SCAN_DELAY_MS = 300L
private const val MAX_SCAN_RETRIES = 3
} }
private var bluetoothGatt: BluetoothGatt? = null private var bluetoothGatt: BluetoothGatt? = null
private var relayCharacteristic: BluetoothGattCharacteristic? = null private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false private var isScanning = false
private var scanTimedOut = false private var scanTimedOut = false
private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val scanRunnable = Runnable { startScan() } private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
private var seq = 1 private var seq = 1
private var hmacKey: ByteArray? = null private var hmacKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
private var lastTheme: String? = null private var lastTheme: String? = null
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var relayButton: Button private lateinit var relay1Button: Button
private lateinit var relay2Button: Button
private val scanCallback = object : ScanCallback() { private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device val device = result.device
val address = device?.address ?: return
val name = device?.name ?: "" val name = device?.name ?: ""
Log.d(TAG, "Found device: $name (${device?.address})") Log.d(TAG, "Found device: $name ($address)")
if (name == DEVICE_NAME) { if (triedAddresses.contains(address)) {
Log.d(TAG, "Skipping already-tried device $address")
return
}
stopScan() stopScan()
connectToDevice(device) connectToDevice(device)
} }
} }
}
private val gattCallback = object : BluetoothGattCallback() { private val gattCallback = object : BluetoothGattCallback() {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@ -81,6 +94,12 @@ class MainActivity : AppCompatActivity() {
Log.w(TAG, "GATT connection state change error: status=$status newState=$newState") Log.w(TAG, "GATT connection state change error: status=$status newState=$newState")
} }
if (newState == BluetoothProfile.STATE_CONNECTED) { if (newState == BluetoothProfile.STATE_CONNECTED) {
if (hmacKey == null) {
Log.w(TAG, "GATT connected but no key — disconnecting")
runOnUiThread { statusText.text = getString(R.string.status_no_key) }
gatt.disconnect()
return
}
Log.d(TAG, "GATT connected") Log.d(TAG, "GATT connected")
bluetoothGatt = gatt bluetoothGatt = gatt
runOnUiThread { statusText.text = getString(R.string.status_discovering_services) } runOnUiThread { statusText.text = getString(R.string.status_discovering_services) }
@ -89,7 +108,8 @@ class MainActivity : AppCompatActivity() {
Log.d(TAG, "GATT disconnected") Log.d(TAG, "GATT disconnected")
runOnUiThread { runOnUiThread {
statusText.text = getString(R.string.status_connecting) statusText.text = getString(R.string.status_connecting)
relayButton.isEnabled = false relay1Button.isEnabled = false
relay2Button.isEnabled = false
} }
gatt.close() gatt.close()
bluetoothGatt = null bluetoothGatt = null
@ -119,16 +139,13 @@ class MainActivity : AppCompatActivity() {
val service = gatt.getService(SERVICE_UUID) val service = gatt.getService(SERVICE_UUID)
if (service != null) { if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
if (relayCharacteristic != null) { val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
gatt.setCharacteristicNotification(relayCharacteristic, true) if (relayCharacteristic != null && uuidChar != null) {
val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID) Log.d(TAG, "Reading device UUID characteristic...")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.readCharacteristic(uuidChar)
gatt.writeDescriptor(descriptor) } else {
Log.d(TAG, "Ready, relay characteristic found") Log.e(TAG, "Service or characteristics not found")
runOnUiThread { runOnUiThread { statusText.text = getString(R.string.status_service_not_found) }
statusText.text = getString(R.string.status_connected)
relayButton.isEnabled = true
}
} }
} else { } else {
Log.e(TAG, "Service not found") Log.e(TAG, "Service not found")
@ -136,6 +153,34 @@ class MainActivity : AppCompatActivity() {
} }
} }
@SuppressLint("MissingPermission")
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val storedUuid = deviceUuid
if (storedUuid != null && value.contentEquals(storedUuid)) {
Log.d(TAG, "UUID verified, setting up relay")
relayCharacteristic?.let { rc ->
gatt.setCharacteristicNotification(rc, true)
val descriptor = rc.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
runOnUiThread {
setupRelayButton()
setupRelay2Button()
statusText.text = getString(R.string.status_connected)
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
} else {
Log.w(TAG, "UUID mismatch — disconnecting")
triedAddresses.add(gatt.device.address)
runOnUiThread { statusText.text = getString(R.string.status_wrong_device) }
gatt.disconnect()
}
}
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
@ -154,13 +199,15 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
statusText = findViewById(R.id.statusText) statusText = findViewById(R.id.statusText)
relayButton = findViewById(R.id.relayButton) relay1Button = findViewById(R.id.relay1Button)
relay2Button = findViewById(R.id.relay2Button)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton) val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
settingsButton.setOnClickListener { settingsButton.setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
} }
setupRelayButton() setupRelayButton()
setupRelay2Button()
} }
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
@ -172,6 +219,7 @@ class MainActivity : AppCompatActivity() {
bluetoothGatt = null bluetoothGatt = null
relayCharacteristic = null relayCharacteristic = null
seq = 1 seq = 1
triedAddresses.clear()
loadState() loadState()
handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS) handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS)
} }
@ -186,9 +234,10 @@ class MainActivity : AppCompatActivity() {
return return
} }
loadState() loadState()
if (hmacKey == null) { if (hmacKey == null || deviceUuid == null) {
statusText.text = getString(R.string.status_no_key) statusText.text = getString(R.string.status_no_key)
} else if (!isScanning && bluetoothGatt == null) { } else if (!isScanning && bluetoothGatt == null) {
triedAddresses.clear()
handler.postDelayed(scanRunnable, ON_RESUME_SCAN_DELAY_MS) handler.postDelayed(scanRunnable, ON_RESUME_SCAN_DELAY_MS)
} }
} }
@ -203,8 +252,10 @@ class MainActivity : AppCompatActivity() {
val prefs = PrefsHelper.getPrefs(this) val prefs = PrefsHelper.getPrefs(this)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
hmacKey = hexKey?.let { hexStringToBytes(it) } hmacKey = hexKey?.let { hexStringToBytes(it) }
val hexUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX) seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}, seq=$seq") Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}, UUID: ${if (deviceUuid != null) "${deviceUuid!!.size} bytes" else "none"}, seq=$seq")
} }
private fun hexStringToBytes(hex: String): ByteArray? { private fun hexStringToBytes(hex: String): ByteArray? {
@ -240,24 +291,45 @@ class MainActivity : AppCompatActivity() {
startScan() startScan()
} else { } else {
statusText.text = getString(R.string.status_permissions_denied) statusText.text = getString(R.string.status_permissions_denied)
relayButton.isEnabled = false relay1Button.isEnabled = false
relay2Button.isEnabled = false
} }
} }
} }
private fun setupRelayButton() { private fun setupRelayButton() {
relayButton.setOnClickListener(null) relay1Button.setOnClickListener(null)
relayButton.text = getString(R.string.button_hold) relay1Button.text = getString(R.string.button_hold)
relayButton.setOnTouchListener { _, event -> relay1Button.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
sendRelayCommand(true) sendRelayCommand(0x01.toByte())
relayButton.text = getString(R.string.button_on) relay1Button.text = getString(R.string.button_on)
true true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(false) sendRelayCommand(0x00.toByte())
relayButton.text = getString(R.string.button_hold) relay1Button.text = getString(R.string.button_hold)
true
}
else -> false
}
}
}
private fun setupRelay2Button() {
relay2Button.setOnClickListener(null)
relay2Button.text = getString(R.string.button_hold)
relay2Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
sendRelayCommand(0x02.toByte())
relay2Button.text = getString(R.string.button_on)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(0x03.toByte())
relay2Button.text = getString(R.string.button_hold)
true true
} }
else -> false else -> false
@ -279,7 +351,7 @@ class MainActivity : AppCompatActivity() {
return return
} }
if (hmacKey == null) { if (hmacKey == null || deviceUuid == null) {
statusText.text = getString(R.string.status_no_key) statusText.text = getString(R.string.status_no_key)
return return
} }
@ -287,31 +359,49 @@ class MainActivity : AppCompatActivity() {
isScanning = true isScanning = true
scanTimedOut = false scanTimedOut = false
statusText.text = getString(R.string.status_scanning) statusText.text = getString(R.string.status_scanning)
Log.d(TAG, "Starting BLE scan for $DEVICE_NAME") Log.d(TAG, "Starting BLE scan with service UUID filter")
adapter.bluetoothLeScanner.startScan(scanCallback) val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
adapter.bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
handler.postDelayed({ scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
scanTimeoutRunnable = Runnable {
if (isScanning) { if (isScanning) {
scanTimedOut = true scanTimedOut = true
stopScan() stopScan()
} }
}, SCAN_TIMEOUT_MS) }
handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS)
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun stopScan() { private fun stopScan() {
if (!isScanning) return if (!isScanning) return
isScanning = false isScanning = false
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
val adapter = BluetoothAdapter.getDefaultAdapter() val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback) adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (scanTimedOut) { if (scanTimedOut) {
if (scanTimeoutRetries < MAX_SCAN_RETRIES) {
scanTimeoutRetries++
Log.d(TAG, "Scan timeout, retry $scanTimeoutRetries/$MAX_SCAN_RETRIES")
statusText.text = getString(R.string.status_scanning)
handler.postDelayed(scanRunnable, 2000L)
return
}
statusText.text = getString(R.string.status_device_not_found) statusText.text = getString(R.string.status_device_not_found)
relayButton.setOnTouchListener(null) relay1Button.setOnTouchListener(null)
relayButton.text = getString(R.string.button_retry_scan) relay2Button.setOnTouchListener(null)
relayButton.isEnabled = true relay1Button.setOnClickListener(null)
relayButton.setOnClickListener { relay2Button.setOnClickListener(null)
relay1Button.text = getString(R.string.button_retry_scan)
relay2Button.text = getString(R.string.button_hold)
relay2Button.isEnabled = false
relay1Button.isEnabled = true
relay1Button.setOnClickListener {
setupRelayButton() setupRelayButton()
setupRelay2Button()
startScan() startScan()
} }
} }
@ -325,12 +415,11 @@ class MainActivity : AppCompatActivity() {
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun sendRelayCommand(on: Boolean) { private fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return val key = hmacKey ?: return
val cmd: Byte = if (on) 0x01 else 0x00
val packet = buildPacket(cmd, key) val packet = buildPacket(cmd, key)
char.value = packet char.value = packet
// WRITE_TYPE_NO_RESPONSE is intentional. Previously, WRITE_TYPE_DEFAULT (with ACK) was // WRITE_TYPE_NO_RESPONSE is intentional. Previously, WRITE_TYPE_DEFAULT (with ACK) was
@ -345,7 +434,7 @@ class MainActivity : AppCompatActivity() {
seq++ seq++
if (seq > PrefsHelper.SEQ_MAX) seq = 1 if (seq > PrefsHelper.SEQ_MAX) seq = 1
PrefsHelper.getPrefs(this).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply() PrefsHelper.getPrefs(this).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply()
Log.d(TAG, "Sent relay ${if (on) "ON" else "OFF"} (seq=${seq - 1})") Log.d(TAG, "Sent relay cmd 0x${cmd.toString(16)} (seq=${seq - 1})")
} }
private fun getRequiredPermissions(): Array<String> { private fun getRequiredPermissions(): Array<String> {

View file

@ -13,6 +13,7 @@ object PrefsHelper {
private const val TAG = "PrefsHelper" private const val TAG = "PrefsHelper"
const val PREFS_NAME = "relay_prefs" const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key" const val PREF_KEY = "hmac_key"
const val PREF_UUID = "device_uuid"
const val PREF_SEQ = "seq_counter" const val PREF_SEQ = "seq_counter"
const val PREF_THEME = "theme" const val PREF_THEME = "theme"
const val SEQ_MAX = 1_000_000 const val SEQ_MAX = 1_000_000

View file

@ -18,12 +18,14 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.Button import android.widget.Button
import android.widget.CheckBox
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.RadioGroup import android.widget.RadioGroup
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -220,19 +222,30 @@ class SettingsActivity : AppCompatActivity() {
private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) { private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER) showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER)
val prefs = PrefsHelper.getPrefs(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
val appKey = prefs.getString(PrefsHelper.PREF_KEY, null)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val result = readKeyFromEsp(usbManager, device) val result = readKeyFromEsp(usbManager, device, appUuid, appKey)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isFinishing || isDestroyed) return@withContext if (isFinishing || isDestroyed) return@withContext
progressDialog?.dismiss()
progressDialog = null
when (result) { when (result) {
is KeyResult.Success -> { is KeyResult.Success -> {
val prefs = PrefsHelper.getPrefs(this@SettingsActivity) prefs.edit()
prefs.edit().putString(PrefsHelper.PREF_KEY, result.hexKey).apply() .putString(PrefsHelper.PREF_UUID, result.deviceUuid)
.putString(PrefsHelper.PREF_KEY, result.hexKey)
.apply()
Toast.makeText(this@SettingsActivity, getString(R.string.key_read_success), Toast.LENGTH_LONG).show() Toast.makeText(this@SettingsActivity, getString(R.string.key_read_success), Toast.LENGTH_LONG).show()
cleanupAction() cleanupAction()
setResult(RESULT_SCAN) setResult(RESULT_SCAN)
finish() finish()
} }
is KeyResult.Conflict -> {
showConflictDialog(usbManager, device, result.espUuid, result.espKey, appKey ?: "")
}
is KeyResult.Error -> { is KeyResult.Error -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_LONG).show() Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_LONG).show()
cleanupAction() cleanupAction()
@ -385,11 +398,12 @@ class SettingsActivity : AppCompatActivity() {
// === ESP Key Exchange via USB === // === ESP Key Exchange via USB ===
sealed class KeyResult { sealed class KeyResult {
data class Success(val hexKey: String) : KeyResult() data class Success(val deviceUuid: String, val hexKey: String) : KeyResult()
data class Conflict(val espUuid: String, val espKey: String) : KeyResult()
data class Error(val message: String) : KeyResult() data class Error(val message: String) : KeyResult()
} }
private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice): KeyResult? { private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice, appUuid: String?, appKey: String?): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device) val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error("Geen CDC interface gevonden") ?: return KeyResult.Error("Geen CDC interface gevonden")
@ -428,18 +442,36 @@ class SettingsActivity : AppCompatActivity() {
Log.d(TAG, "Attempt $attempt response: ${response.take(300)}") Log.d(TAG, "Attempt $attempt response: ${response.take(300)}")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") } val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
val uuidLine = response.lines().firstOrNull { it.startsWith("DEVICE_ID:") }
val noKey = response.lines().any { it.trim() == "NO_KEY" } val noKey = response.lines().any { it.trim() == "NO_KEY" }
if (keyLine != null) { if (keyLine != null && uuidLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim() val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) { val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim()
return KeyResult.Success(hexKey.uppercase()) if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" } &&
hexUuid.length == 32 && hexUuid.all { it in "0123456789ABCDEFabcdef" }) {
val espKeyUpper = hexKey.uppercase()
val espUuidUpper = hexUuid.uppercase()
if (appUuid != null && appKey != null &&
(appUuid != espUuidUpper || appKey != espKeyUpper)) {
return KeyResult.Conflict(espUuidUpper, espKeyUpper)
}
return KeyResult.Success(espUuidUpper, espKeyUpper)
} else { } else {
return KeyResult.Error("Ongeldig sleutel formaat (lengte=${hexKey.length})") return KeyResult.Error("Ongeldig sleutel/UUID formaat")
} }
} else if (noKey) { } else if (noKey) {
Log.d(TAG, "ESP has no key, provisioning new one") val newKey: String
val newKey = generateRandomHexKey() val newUuid: String
if (appUuid != null && appKey != null) {
Log.d(TAG, "ESP has no keys, using existing app keys")
newKey = appKey
newUuid = appUuid
} else {
Log.d(TAG, "Both have no keys, generating new pair")
newKey = generateRandomHexKey()
newUuid = generateRandomHex(16)
}
val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII) val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS) val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS)
@ -449,15 +481,29 @@ class SettingsActivity : AppCompatActivity() {
} }
Thread.sleep(RESPONSE_WAIT_MS) Thread.sleep(RESPONSE_WAIT_MS)
val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_KEY response: ${okKeyResponse.take(200)}")
val okResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS) if (!okKeyResponse.contains("OK")) {
Log.d(TAG, "SET_KEY response: ${okResponse.take(200)}")
if (okResponse.contains("OK")) {
return KeyResult.Success(newKey)
} else {
return KeyResult.Error("ESP bevestigde sleutel niet") return KeyResult.Error("ESP bevestigde sleutel niet")
} }
val setIdCmd = "SET_ID:$newUuid\n".toByteArray(StandardCharsets.US_ASCII)
val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "Sent SET_ID ($w2 bytes)")
if (w2 < 0) {
return KeyResult.Error("SET_ID schrijven mislukt")
}
Thread.sleep(RESPONSE_WAIT_MS)
val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_ID response: ${okUuidResponse.take(200)}")
if (okUuidResponse.contains("OK")) {
return KeyResult.Success(newUuid, newKey)
} else {
return KeyResult.Error("ESP bevestigde UUID niet")
}
} }
Log.w(TAG, "Attempt $attempt: no KEY response, retrying") Log.w(TAG, "Attempt $attempt: no KEY response, retrying")
@ -490,7 +536,7 @@ class SettingsActivity : AppCompatActivity() {
val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS) val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS)
if (len > 0) { if (len > 0) {
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII)) sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY") || sb.contains("OK")) && sb.contains("\n")) { if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) {
break break
} }
} }
@ -539,4 +585,145 @@ class SettingsActivity : AppCompatActivity() {
random.nextBytes(bytes) random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) } return bytes.joinToString("") { "%02X".format(it) }
} }
private fun generateRandomHex(byteCount: Int): String {
val random = SecureRandom()
val bytes = ByteArray(byteCount)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice,
espUuid: String, espKey: String, appKey: String) {
val prefs = PrefsHelper.getPrefs(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, "") ?: ""
AlertDialog.Builder(this)
.setTitle(getString(R.string.title_key_conflict))
.setMessage(getString(R.string.msg_key_conflict))
.setPositiveButton(getString(R.string.button_keep_esp_keys)) { _, _ ->
prefs.edit()
.putString(PrefsHelper.PREF_UUID, espUuid)
.putString(PrefsHelper.PREF_KEY, espKey)
.apply()
Toast.makeText(this, getString(R.string.key_read_success), Toast.LENGTH_LONG).show()
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
.setNegativeButton(getString(R.string.button_overwrite_esp)) { _, _ ->
showOverwriteConfirmDialog(usbManager, device, espUuid, espKey, appKey, appUuid)
}
.setNeutralButton(getString(R.string.button_cancel)) { _, _ ->
cleanupAction()
}
.setCancelable(false)
.show()
}
private fun showOverwriteConfirmDialog(usbManager: UsbManager, device: UsbDevice,
espUuid: String, espKey: String,
appKey: String, appUuid: String) {
val view = layoutInflater.inflate(R.layout.dialog_overwrite_confirm, null)
val checkBox = view.findViewById<CheckBox>(R.id.confirmCheckBox)
val dialog = AlertDialog.Builder(this)
.setTitle(getString(R.string.title_overwrite_confirm))
.setView(view)
.setPositiveButton(getString(R.string.button_confirm)) { _, _ ->
if (checkBox.isChecked) {
doOverwriteEspKeys(usbManager, device, appKey, appUuid)
}
}
.setNegativeButton(getString(R.string.button_cancel)) { _, _ -> }
.setCancelable(true)
.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
checkBox.setOnCheckedChangeListener { _, isChecked ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = isChecked
}
}
private fun doOverwriteEspKeys(usbManager: UsbManager, device: UsbDevice,
appKey: String, appUuid: String) {
showProgressDialog(getString(R.string.title_overwriting_keys), getString(R.string.msg_writing_keys), STYLE_SPINNER)
lifecycleScope.launch(Dispatchers.IO) {
val result = writeKeysToEsp(usbManager, device, appKey, appUuid)
withContext(Dispatchers.Main) {
if (isFinishing || isDestroyed) return@withContext
progressDialog?.dismiss()
progressDialog = null
when (result) {
is KeyResult.Success -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_read_success), Toast.LENGTH_LONG).show()
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Error -> {
Toast.makeText(this@SettingsActivity, result.message, Toast.LENGTH_LONG).show()
cleanupAction()
}
else -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
}
private fun writeKeysToEsp(usbManager: UsbManager, device: UsbDevice,
appKey: String, appUuid: String): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error("Geen CDC interface gevonden")
val connection = usbManager.openDevice(device)
?: return KeyResult.Error("Kan USB apparaat niet openen")
if (!connection.claimInterface(intf, true)) {
connection.close()
return KeyResult.Error("Kan USB interface niet claimen")
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return KeyResult.Error("Geen USB OUT endpoint gevonden") }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return KeyResult.Error("Geen USB IN endpoint gevonden") }
try {
setupCdcControl(connection, device)
Thread.sleep(KEY_SETTLE_MS)
flushReadBuffer(connection, epIn)
val setKeyCmd = "SET_KEY:$appKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS)
if (w < 0) return KeyResult.Error("SET_KEY schrijven mislukt")
Log.d(TAG, "Sent SET_KEY ($w bytes)")
Thread.sleep(RESPONSE_WAIT_MS)
val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
if (!okKeyResponse.contains("OK")) return KeyResult.Error("ESP bevestigde sleutel niet")
val setIdCmd = "SET_ID:$appUuid\n".toByteArray(StandardCharsets.US_ASCII)
val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS)
if (w2 < 0) return KeyResult.Error("SET_ID schrijven mislukt")
Thread.sleep(RESPONSE_WAIT_MS)
val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
if (!okUuidResponse.contains("OK")) return KeyResult.Error("ESP bevestigde UUID niet")
val prefs = PrefsHelper.getPrefs(this)
prefs.edit()
.putString(PrefsHelper.PREF_UUID, appUuid)
.putString(PrefsHelper.PREF_KEY, appKey)
.apply()
return KeyResult.Success(appUuid, appKey)
} catch (e: Exception) {
Log.e(TAG, "Write keys error", e)
return KeyResult.Error("Fout: ${e.message}")
} finally {
try { connection.close() } catch (_: Exception) {}
}
}
} }

View file

@ -13,15 +13,32 @@
android:background="?android:attr/selectableItemBackgroundBorderless" android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Instellingen" /> android:contentDescription="Instellingen" />
<Button <LinearLayout
android:id="@+id/relayButton" android:layout_width="wrap_content"
android:layout_width="200dp" android:layout_height="wrap_content"
android:layout_height="200dp"
android:layout_gravity="center" android:layout_gravity="center"
android:text="HOLD" android:orientation="horizontal"
android:gravity="center">
<Button
android:id="@+id/relay1Button"
android:layout_width="150dp"
android:layout_height="200dp"
android:text="1"
android:textSize="24sp" android:textSize="24sp"
android:enabled="false" /> android:enabled="false" />
<Button
android:id="@+id/relay2Button"
android:layout_width="150dp"
android:layout_height="200dp"
android:layout_marginStart="16dp"
android:text="2"
android:textSize="24sp"
android:enabled="false" />
</LinearLayout>
<TextView <TextView
android:id="@+id/statusText" android:id="@+id/statusText"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/msg_overwrite_warning"
android:textSize="14sp"
android:paddingBottom="16dp" />
<CheckBox
android:id="@+id/confirmCheckBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/checkbox_confirm_overwrite" />
</LinearLayout>

View file

@ -15,6 +15,7 @@
<string name="status_bluetooth_disabled">Bluetooth not enabled</string> <string name="status_bluetooth_disabled">Bluetooth not enabled</string>
<string name="status_scanning">Scanning…</string> <string name="status_scanning">Scanning…</string>
<string name="status_device_not_found">Device not found</string> <string name="status_device_not_found">Device not found</string>
<string name="status_wrong_device">Verkeerd apparaat — opnieuw scannen…</string>
<string name="button_retry_scan">Opnieuw scannen</string> <string name="button_retry_scan">Opnieuw scannen</string>
<!-- MainActivity UI --> <!-- MainActivity UI -->
@ -29,6 +30,20 @@
<string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</string> <string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</string>
<string name="flash_failed">Flashen mislukt: %s</string> <string name="flash_failed">Flashen mislukt: %s</string>
<!-- Key conflict dialog -->
<string name="title_key_conflict">Sleutels komen niet overeen</string>
<string name="msg_key_conflict">De ESP (SylDa_Server) heeft andere sleutels dan deze app. Wat wil je doen?</string>
<string name="button_keep_esp_keys">ESP sleutels overnemen</string>
<string name="button_overwrite_esp">App sleutels overzetten</string>
<string name="button_confirm">Bevestigen</string>
<!-- Overwrite confirmation dialog -->
<string name="title_overwrite_confirm">ESP sleutels overschrijven?</string>
<string name="msg_overwrite_warning">Dit vervangt de sleutels van de ESP. De ESP kan daarna niet meer met de oorspronkelijke app communiceren.</string>
<string name="checkbox_confirm_overwrite">Ik weet het zeker</string>
<string name="title_overwriting_keys">Sleutels overschrijven</string>
<string name="msg_writing_keys">Sleutels schrijven naar ESP…</string>
<!-- SettingsActivity dialogs --> <!-- SettingsActivity dialogs -->
<string name="title_key_exchange">Sleutel Uitwisselen</string> <string name="title_key_exchange">Sleutel Uitwisselen</string>
<string name="title_firmware_flash">Firmware Flashen</string> <string name="title_firmware_flash">Firmware Flashen</string>

View file

@ -7,26 +7,36 @@
#include <mbedtls/md.h> #include <mbedtls/md.h>
#include <Preferences.h> #include <Preferences.h>
#define SENSOR_PIN 38 #define RELAY1_PIN 1
#define RELAY_PIN 1 #define RELAY2_PIN 2
#define TOUCH1_PIN 8
#define TOUCH2_PIN 6
#define BUTTON_PIN 41
#define LED_PIN 46 #define LED_PIN 46
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#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 HMAC_PKT_SIZE 37 // 1 cmd + 4 seq + 32 hmac #define HMAC_PKT_SIZE 37 // 1 cmd + 4 seq + 32 hmac
#define HMAC_KEY_LEN 32 #define HMAC_KEY_LEN 32
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac" #define NVS_NAMESPACE "hmac"
#define NVS_KEY_NAME "key" #define NVS_KEY_NAME "key"
#define NVS_UUID_NAME "uuid"
BLECharacteristic *pCharacteristic; BLECharacteristic *pCharacteristic;
bool deviceConnected = false; bool deviceConnected = false;
bool bleCommand = false; bool bleRelay1 = false;
bool relayState = false; bool bleRelay2 = false;
bool relay1State = false;
bool relay2State = false;
uint32_t lastSeq = 0; uint32_t lastSeq = 0;
uint8_t hmacKey[HMAC_KEY_LEN]; uint8_t hmacKey[HMAC_KEY_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
bool keyProvisioned = false; bool keyProvisioned = false;
bool uuidProvisioned = false;
Preferences preferences; Preferences preferences;
class MyServerCallbacks : public BLEServerCallbacks { class MyServerCallbacks : public BLEServerCallbacks {
@ -37,7 +47,8 @@ class MyServerCallbacks : public BLEServerCallbacks {
} }
void onDisconnect(BLEServer *pServer) override { void onDisconnect(BLEServer *pServer) override {
deviceConnected = false; deviceConnected = false;
bleCommand = false; bleRelay1 = false;
bleRelay2 = false;
lastSeq = 0; lastSeq = 0;
Serial.println("BLE: device disconnected"); Serial.println("BLE: device disconnected");
BLEDevice::startAdvertising(); BLEDevice::startAdvertising();
@ -109,23 +120,29 @@ class MyCharacteristicCallbacks : public BLECharacteristicCallbacks {
uint32_t seq = lastSeq; uint32_t seq = lastSeq;
if (cmd == 0x01) { if (cmd == 0x01) {
bleCommand = true; bleRelay1 = true;
Serial.printf("BLE: relay ON (seq=%lu)\n", seq); Serial.printf("BLE: relay1 ON (seq=%lu)\n", seq);
} else { } else if (cmd == 0x00) {
bleCommand = false; bleRelay1 = false;
Serial.printf("BLE: relay OFF (seq=%lu)\n", seq); Serial.printf("BLE: relay1 OFF (seq=%lu)\n", seq);
} else if (cmd == 0x02) {
bleRelay2 = true;
Serial.printf("BLE: relay2 ON (seq=%lu)\n", seq);
} else if (cmd == 0x03) {
bleRelay2 = false;
Serial.printf("BLE: relay2 OFF (seq=%lu)\n", seq);
} }
} }
}; };
void setup() { void setup() {
delay(2000);
Serial.begin(115200); Serial.begin(115200);
delay(500); delay(2000);
Serial.println("=== Atom VoiceS3R BLE Relay ==="); Serial.println("=== Atom VoiceS3R BLE Relay ===");
preferences.begin(NVS_NAMESPACE, true); preferences.begin(NVS_NAMESPACE, true);
size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN); size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end(); preferences.end();
if (len == HMAC_KEY_LEN) { if (len == HMAC_KEY_LEN) {
@ -136,8 +153,28 @@ void setup() {
Serial.println("No HMAC key provisioned — connect USB and run SET_KEY"); Serial.println("No HMAC key provisioned — connect USB and run SET_KEY");
} }
pinMode(SENSOR_PIN, INPUT_PULLUP); if (uuidLen == ESP_UUID_LEN) {
pinMode(RELAY_PIN, OUTPUT); uuidProvisioned = true;
Serial.print("UUID loaded from NVS: ");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
} else {
uuidProvisioned = false;
for (int i = 0; i < ESP_UUID_LEN; i++) deviceUuid[i] = (uint8_t)(esp_random() & 0xFF);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
Serial.print("Generated new UUID: ");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
}
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT);
pinMode(RELAY1_PIN, OUTPUT);
pinMode(RELAY2_PIN, OUTPUT);
pinMode(LED_PIN, OUTPUT); pinMode(LED_PIN, OUTPUT);
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
@ -148,7 +185,7 @@ void setup() {
} }
Serial.println("LED blink done, starting BLE..."); Serial.println("LED blink done, starting BLE...");
BLEDevice::init("AtomS3R-Relay"); BLEDevice::init("SylDa_Server");
BLEServer *pServer = BLEDevice::createServer(); BLEServer *pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks()); pServer->setCallbacks(new MyServerCallbacks());
@ -164,6 +201,12 @@ void setup() {
pCharacteristic->setValue("0"); pCharacteristic->setValue("0");
pService->start(); pService->start();
BLECharacteristic *pUuidChar = pService->createCharacteristic(
UUID_CHAR_UUID,
BLECharacteristic::PROPERTY_READ
);
pUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID); pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true); pAdvertising->setScanResponse(true);
@ -171,7 +214,7 @@ void setup() {
pAdvertising->setMinPreferred(0x12); pAdvertising->setMinPreferred(0x12);
BLEDevice::startAdvertising(); BLEDevice::startAdvertising();
Serial.println("BLE: advertising started as 'AtomS3R-Relay'"); Serial.println("BLE: advertising started as 'SylDa_Server'");
if (keyProvisioned) { if (keyProvisioned) {
Serial.print("HMAC Key: "); Serial.print("HMAC Key: ");
@ -194,7 +237,10 @@ void loop() {
if (c == '\n') { if (c == '\n') {
serialBuf.trim(); serialBuf.trim();
if (serialBuf == "KEY?") { if (serialBuf == "KEY?") {
if (keyProvisioned) { if (keyProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
Serial.print("HMAC_KEY:"); Serial.print("HMAC_KEY:");
for (int i = 0; i < HMAC_KEY_LEN; i++) { for (int i = 0; i < HMAC_KEY_LEN; i++) {
Serial.printf("%02X", hmacKey[i]); Serial.printf("%02X", hmacKey[i]);
@ -217,35 +263,77 @@ void loop() {
} else { } else {
Serial.println("ERROR: invalid key format"); Serial.println("ERROR: invalid key format");
} }
} else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7);
hex.toUpperCase();
if (hex.length() == 32 && isHexKey(hex)) {
for (int i = 0; i < ESP_UUID_LEN; i++) {
deviceUuid[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
Serial.println("OK");
Serial.println("UUID stored in NVS");
} else {
Serial.println("ERROR: invalid UUID format");
}
} }
serialBuf = ""; serialBuf = "";
} }
} }
int sensorState = digitalRead(SENSOR_PIN); static bool t1Stable = false;
static bool t2Stable = false;
static bool btnStable = false;
static uint32_t t1Change = 0;
static uint32_t t2Change = 0;
static uint32_t btnChange = 0;
#define DEBOUNCE_MS 50
bool t1Raw = (digitalRead(TOUCH1_PIN) == LOW);
bool t2Raw = (digitalRead(TOUCH2_PIN) == LOW);
bool btnRaw = (digitalRead(BUTTON_PIN) == LOW);
if (t1Raw != t1Stable) { t1Change = millis(); t1Stable = t1Raw; }
if (t2Raw != t2Stable) { t2Change = millis(); t2Stable = t2Raw; }
if (btnRaw != btnStable) { btnChange = millis(); btnStable = btnRaw; }
bool t1Active = (millis() - t1Change > DEBOUNCE_MS) && t1Stable;
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
bool touchActive = (sensorState == LOW); relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
bool bleActive = (deviceConnected && bleCommand); relay2State = t2Active || (deviceConnected && bleRelay2);
relayState = touchActive || bleActive; digitalWrite(RELAY1_PIN, relay1State ? HIGH : LOW);
digitalWrite(RELAY2_PIN, relay2State ? HIGH : LOW);
digitalWrite(RELAY_PIN, relayState ? HIGH : LOW); digitalWrite(LED_PIN, (relay1State || relay2State) ? HIGH : LOW);
static bool lastRelay = false; static bool lastRelay1 = false;
if (relayState != lastRelay) { static bool lastRelay2 = false;
Serial.printf("Relay %s (touch=%s, ble=%s)\n", if (relay1State != lastRelay1 || relay2State != lastRelay2) {
relayState ? "ON" : "OFF", Serial.printf("Relays: R1=%s R2=%s (t1=%s t2=%s btn=%s ble1=%s ble2=%s)\n",
sensorState == LOW ? "YES" : "no", relay1State ? "ON" : "OFF",
deviceConnected ? "connected" : "disconnected"); relay2State ? "ON" : "OFF",
lastRelay = relayState; t1Active ? "YES" : "no",
t2Active ? "YES" : "no",
btnActive ? "YES" : "no",
bleRelay1 ? "ON" : "OFF",
bleRelay2 ? "ON" : "OFF");
lastRelay1 = relay1State;
lastRelay2 = relay2State;
} }
static uint32_t heartbeat = 0; static uint32_t heartbeat = 0;
if (millis() - heartbeat > 5000) { if (millis() - heartbeat > 5000) {
Serial.printf("[heartbeat] relay=%s touch=%s ble=%s\n", Serial.printf("[heartbeat] R1=%s R2=%s t1=%s t2=%s btn=%s ble1=%s ble2=%s\n",
relayState ? "ON" : "OFF", relay1State ? "ON" : "OFF",
sensorState == LOW ? "YES" : "no", relay2State ? "ON" : "OFF",
deviceConnected ? "connected" : "disconnected"); t1Active ? "YES" : "no",
t2Active ? "YES" : "no",
btnActive ? "YES" : "no",
bleRelay1 ? "ON" : "OFF",
bleRelay2 ? "ON" : "OFF");
heartbeat = millis(); heartbeat = millis();
} }