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:
parent
73b3c0eaa0
commit
348c55d830
9 changed files with 527 additions and 106 deletions
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,40 +40,50 @@ 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)) {
|
||||||
stopScan()
|
Log.d(TAG, "Skipping already-tried device $address")
|
||||||
connectToDevice(device)
|
return
|
||||||
}
|
}
|
||||||
|
stopScan()
|
||||||
|
connectToDevice(device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,31 @@
|
||||||
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:textSize="24sp"
|
android:gravity="center">
|
||||||
android:enabled="false" />
|
|
||||||
|
<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
|
<TextView
|
||||||
android:id="@+id/statusText"
|
android:id="@+id/statusText"
|
||||||
|
|
|
||||||
21
android/app/src/main/res/layout/dialog_overwrite_confirm.xml
Normal file
21
android/app/src/main/res/layout/dialog_overwrite_confirm.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
156
src/main.cpp
156
src/main.cpp
|
|
@ -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 LED_PIN 46
|
#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 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue