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 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(
FlashStep("Bootloader", 0x0, bootloader, 30, 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...")
val stub = detected.loadStub()
callback.onProgress(25, "Flash wissen...")
stub.eraseFlash()
for (step in steps) {
coroutineContext.ensureActive()
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.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import android.view.MotionEvent
import android.widget.Button
@ -37,42 +40,52 @@ class MainActivity : AppCompatActivity() {
private const val TAG = "AtomRelay"
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 UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9")
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_LEN = 32
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 POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L
private const val MAX_SCAN_RETRIES = 3
}
private var bluetoothGatt: BluetoothGatt? = null
private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false
private var scanTimedOut = false
private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper())
private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
private var seq = 1
private var hmacKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
private var lastTheme: String? = null
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() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val address = device?.address ?: return
val name = device?.name ?: ""
Log.d(TAG, "Found device: $name (${device?.address})")
if (name == DEVICE_NAME) {
Log.d(TAG, "Found device: $name ($address)")
if (triedAddresses.contains(address)) {
Log.d(TAG, "Skipping already-tried device $address")
return
}
stopScan()
connectToDevice(device)
}
}
}
private val gattCallback = object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
@ -81,6 +94,12 @@ class MainActivity : AppCompatActivity() {
Log.w(TAG, "GATT connection state change error: status=$status newState=$newState")
}
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")
bluetoothGatt = gatt
runOnUiThread { statusText.text = getString(R.string.status_discovering_services) }
@ -89,7 +108,8 @@ class MainActivity : AppCompatActivity() {
Log.d(TAG, "GATT disconnected")
runOnUiThread {
statusText.text = getString(R.string.status_connecting)
relayButton.isEnabled = false
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
gatt.close()
bluetoothGatt = null
@ -119,16 +139,13 @@ class MainActivity : AppCompatActivity() {
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
if (relayCharacteristic != null) {
gatt.setCharacteristicNotification(relayCharacteristic, true)
val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
Log.d(TAG, "Ready, relay characteristic found")
runOnUiThread {
statusText.text = getString(R.string.status_connected)
relayButton.isEnabled = true
}
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
if (relayCharacteristic != null && uuidChar != null) {
Log.d(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e(TAG, "Service or characteristics not found")
runOnUiThread { statusText.text = getString(R.string.status_service_not_found) }
}
} else {
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")
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
@ -154,13 +199,15 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main)
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)
settingsButton.setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
}
setupRelayButton()
setupRelay2Button()
}
@Deprecated("Deprecated in Java")
@ -172,6 +219,7 @@ class MainActivity : AppCompatActivity() {
bluetoothGatt = null
relayCharacteristic = null
seq = 1
triedAddresses.clear()
loadState()
handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS)
}
@ -186,9 +234,10 @@ class MainActivity : AppCompatActivity() {
return
}
loadState()
if (hmacKey == null) {
if (hmacKey == null || deviceUuid == null) {
statusText.text = getString(R.string.status_no_key)
} else if (!isScanning && bluetoothGatt == null) {
triedAddresses.clear()
handler.postDelayed(scanRunnable, ON_RESUME_SCAN_DELAY_MS)
}
}
@ -203,8 +252,10 @@ class MainActivity : AppCompatActivity() {
val prefs = PrefsHelper.getPrefs(this)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
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)
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? {
@ -240,24 +291,45 @@ class MainActivity : AppCompatActivity() {
startScan()
} else {
statusText.text = getString(R.string.status_permissions_denied)
relayButton.isEnabled = false
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
}
}
private fun setupRelayButton() {
relayButton.setOnClickListener(null)
relayButton.text = getString(R.string.button_hold)
relayButton.setOnTouchListener { _, event ->
relay1Button.setOnClickListener(null)
relay1Button.text = getString(R.string.button_hold)
relay1Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
sendRelayCommand(true)
relayButton.text = getString(R.string.button_on)
sendRelayCommand(0x01.toByte())
relay1Button.text = getString(R.string.button_on)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(false)
relayButton.text = getString(R.string.button_hold)
sendRelayCommand(0x00.toByte())
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
}
else -> false
@ -279,7 +351,7 @@ class MainActivity : AppCompatActivity() {
return
}
if (hmacKey == null) {
if (hmacKey == null || deviceUuid == null) {
statusText.text = getString(R.string.status_no_key)
return
}
@ -287,31 +359,49 @@ class MainActivity : AppCompatActivity() {
isScanning = true
scanTimedOut = false
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) {
scanTimedOut = true
stopScan()
}
}, SCAN_TIMEOUT_MS)
}
handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS)
}
@SuppressLint("MissingPermission")
private fun stopScan() {
if (!isScanning) return
isScanning = false
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
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)
relayButton.setOnTouchListener(null)
relayButton.text = getString(R.string.button_retry_scan)
relayButton.isEnabled = true
relayButton.setOnClickListener {
relay1Button.setOnTouchListener(null)
relay2Button.setOnTouchListener(null)
relay1Button.setOnClickListener(null)
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()
setupRelay2Button()
startScan()
}
}
@ -325,12 +415,11 @@ class MainActivity : AppCompatActivity() {
}
@SuppressLint("MissingPermission")
private fun sendRelayCommand(on: Boolean) {
private fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return
val cmd: Byte = if (on) 0x01 else 0x00
val packet = buildPacket(cmd, key)
char.value = packet
// WRITE_TYPE_NO_RESPONSE is intentional. Previously, WRITE_TYPE_DEFAULT (with ACK) was
@ -345,7 +434,7 @@ class MainActivity : AppCompatActivity() {
seq++
if (seq > PrefsHelper.SEQ_MAX) seq = 1
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> {

View file

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

View file

@ -18,12 +18,14 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Button
import android.widget.CheckBox
import android.widget.ProgressBar
import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
@ -220,19 +222,30 @@ class SettingsActivity : AppCompatActivity() {
private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) {
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) {
val result = readKeyFromEsp(usbManager, device)
val result = readKeyFromEsp(usbManager, device, appUuid, appKey)
withContext(Dispatchers.Main) {
if (isFinishing || isDestroyed) return@withContext
progressDialog?.dismiss()
progressDialog = null
when (result) {
is KeyResult.Success -> {
val prefs = PrefsHelper.getPrefs(this@SettingsActivity)
prefs.edit().putString(PrefsHelper.PREF_KEY, result.hexKey).apply()
prefs.edit()
.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()
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Conflict -> {
showConflictDialog(usbManager, device, result.espUuid, result.espKey, appKey ?: "")
}
is KeyResult.Error -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_LONG).show()
cleanupAction()
@ -385,11 +398,12 @@ class SettingsActivity : AppCompatActivity() {
// === ESP Key Exchange via USB ===
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()
}
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)
?: return KeyResult.Error("Geen CDC interface gevonden")
@ -428,18 +442,36 @@ class SettingsActivity : AppCompatActivity() {
Log.d(TAG, "Attempt $attempt response: ${response.take(300)}")
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" }
if (keyLine != null) {
if (keyLine != null && uuidLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) {
return KeyResult.Success(hexKey.uppercase())
val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim()
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 {
return KeyResult.Error("Ongeldig sleutel formaat (lengte=${hexKey.length})")
return KeyResult.Error("Ongeldig sleutel/UUID formaat")
}
} else if (noKey) {
Log.d(TAG, "ESP has no key, provisioning new one")
val newKey = generateRandomHexKey()
val newKey: String
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 w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS)
@ -449,15 +481,29 @@ class SettingsActivity : AppCompatActivity() {
}
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)
Log.d(TAG, "SET_KEY response: ${okResponse.take(200)}")
if (okResponse.contains("OK")) {
return KeyResult.Success(newKey)
} else {
if (!okKeyResponse.contains("OK")) {
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")
@ -490,7 +536,7 @@ class SettingsActivity : AppCompatActivity() {
val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS)
if (len > 0) {
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
}
}
@ -539,4 +585,145 @@ class SettingsActivity : AppCompatActivity() {
random.nextBytes(bytes)
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:contentDescription="Instellingen" />
<Button
android:id="@+id/relayButton"
android:layout_width="200dp"
android:layout_height="200dp"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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: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
android:id="@+id/statusText"
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_scanning">Scanning…</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>
<!-- MainActivity UI -->
@ -29,6 +30,20 @@
<string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</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 -->
<string name="title_key_exchange">Sleutel Uitwisselen</string>
<string name="title_firmware_flash">Firmware Flashen</string>

View file

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