Add USB auto-read key transfer + UI redesign

USB auto-read:
- ESP responds to 'KEY?\n' over USB serial with 'HMAC_KEY:<hex>'
- Android UsbKeyReceiver (manifest BroadcastReceiver) catches USB_DEVICE_ATTACHED
- Filters on Espressif vendor 0x303a, requests USB permission
- Opens CDC-ACM serial (115200), sends KEY?, parses response, saves to SharedPreferences
- Works even when app not in foreground
- usb-serial-for-android v3.7.3 via JitPack

UI redesign:
- activity_main.xml: FrameLayout with settings icon top-right, relay button center, status bottom-left (14sp)
- Scan & Connect button moved to SettingsActivity
- SettingsActivity: hint text 'Voer de HMAC sleutel in of sluit de relaismodule aan op de telefoon'
- SettingsActivity: scan button saves key + returns RESULT_OK to trigger scanning
- MainActivity: removed scanButton, auto-scan on onResume when key is set
- Back button removed from settings (ActionBar up navigation only)

README updated with USB protocol docs, new UI description, and updated setup guide
This commit is contained in:
eddy 2026-07-26 08:39:28 +02:00
commit 4518aec34e
10 changed files with 270 additions and 74 deletions

View file

@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:allowBackup="true"
@ -29,5 +30,13 @@
android:name=".SettingsActivity"
android:label="Instellingen"
android:parentActivityName=".MainActivity"/>
<receiver
android:name=".UsbKeyReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -2,6 +2,7 @@ package com.eddy.atomrelay
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
@ -40,8 +41,9 @@ class MainActivity : AppCompatActivity() {
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val DEVICE_NAME = "AtomS3R-Relay"
private const val HMAC_PKT_SIZE = 37 // 1 cmd + 4 seq + 32 hmac
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
private const val REQUEST_SETTINGS = 1
}
private var bluetoothGatt: BluetoothGatt? = null
@ -55,7 +57,6 @@ class MainActivity : AppCompatActivity() {
private lateinit var statusText: TextView
private lateinit var relayButton: Button
private lateinit var scanButton: Button
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
@ -111,7 +112,7 @@ class MainActivity : AppCompatActivity() {
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
seq = 0
seq = 1
handler.postDelayed({ startScan() }, 2000)
}
}
@ -160,18 +161,22 @@ class MainActivity : AppCompatActivity() {
statusText = findViewById(R.id.statusText)
relayButton = findViewById(R.id.relayButton)
scanButton = findViewById(R.id.scanButton)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
scanButton.setOnClickListener { startScan() }
settingsButton.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
}
setupRelayButton()
registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
}
handler.postDelayed({ startScan() }, 500)
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == Activity.RESULT_OK) {
handler.postDelayed({ startScan() }, 300)
}
}
override fun onResume() {
@ -179,6 +184,8 @@ class MainActivity : AppCompatActivity() {
loadHmacKey()
if (hmacKey == null) {
statusText.text = "Geen HMAC sleutel - open Instellingen"
} else if (!isScanning && bluetoothGatt == null) {
handler.postDelayed({ startScan() }, 300)
}
}
@ -261,7 +268,6 @@ class MainActivity : AppCompatActivity() {
isScanning = true
statusText.text = "Scanning..."
scanButton.isEnabled = false
Log.d(TAG, "Starting BLE scan for $DEVICE_NAME")
adapter.bluetoothLeScanner.startScan(scanCallback)
@ -275,7 +281,6 @@ class MainActivity : AppCompatActivity() {
private fun stopScan() {
if (!isScanning) return
isScanning = false
scanButton.isEnabled = true
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (statusText.text == "Scanning...") {

View file

@ -16,6 +16,7 @@ class SettingsActivity : AppCompatActivity() {
companion object {
const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key"
const val RESULT_SCAN = 100
}
private var isVisible = false
@ -31,7 +32,7 @@ class SettingsActivity : AppCompatActivity() {
val keyInput = findViewById<EditText>(R.id.keyInput)
val toggleBtn = findViewById<ImageButton>(R.id.toggleVisibility)
val saveButton = findViewById<Button>(R.id.saveButton)
val backButton = findViewById<Button>(R.id.backButton)
val scanConnectButton = findViewById<Button>(R.id.scanConnectButton)
val keyError = findViewById<TextView>(R.id.keyError)
keyInput.setText(prefs.getString(PREF_KEY, ""))
@ -80,7 +81,14 @@ class SettingsActivity : AppCompatActivity() {
finish()
}
backButton.setOnClickListener { finish() }
scanConnectButton.setOnClickListener {
val key = keyInput.text.toString().uppercase().filter { it in "0123456789ABCDEF" }
if (key.length == 64) {
prefs.edit().putString(PREF_KEY, key).apply()
}
setResult(RESULT_SCAN)
finish()
}
}
override fun onSupportNavigateUp(): Boolean {

View file

@ -0,0 +1,139 @@
package com.eddy.atomrelay
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import com.hoho.android.usbserial.driver.CdcAcmSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import java.nio.charset.StandardCharsets
class UsbKeyReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "UsbKeyReceiver"
private const val ACTION_USB_PERMISSION = "com.eddy.atomrelay.USB_PERMISSION"
private const val ESP_VENDOR_ID = 0x303a
private const val KEY_TIMEOUT_MS = 3000
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: return
Log.d(TAG, "USB device attached: vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
if (device.vendorId != ESP_VENDOR_ID) return
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
if (usbManager.hasPermission(device)) {
Thread { readKey(context, usbManager, device) }.start()
} else {
val permIntent = PendingIntent.getBroadcast(
context, 0,
Intent(ACTION_USB_PERMISSION)
.setClass(context, UsbKeyReceiver::class.java)
.putExtra(UsbManager.EXTRA_DEVICE, device),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
usbManager.requestPermission(device, permIntent)
Log.d(TAG, "Requesting USB permission for ${device.deviceName}")
}
}
ACTION_USB_PERMISSION -> {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: return
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
Log.d(TAG, "USB permission granted for ${device.deviceName}")
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
Thread { readKey(context, usbManager, device) }.start()
} else {
Log.w(TAG, "USB permission denied")
Handler(Looper.getMainLooper()).post {
Toast.makeText(context, "USB-toegang geweigerd", Toast.LENGTH_SHORT).show()
}
}
}
}
}
private fun readKey(context: Context, usbManager: UsbManager, device: UsbDevice) {
val handler = Handler(Looper.getMainLooper())
var port: UsbSerialPort? = null
try {
val driver = CdcAcmSerialDriver(device)
val connection = usbManager.openDevice(device)
if (connection == null) {
Log.e(TAG, "Failed to open USB device")
handler.post { Toast.makeText(context, "Kan USB-apparaat niet openen", Toast.LENGTH_SHORT).show() }
return
}
val ports = driver.ports
if (ports.isEmpty()) {
Log.e(TAG, "No serial ports found")
handler.post { Toast.makeText(context, "Geen serial-poort gevonden", Toast.LENGTH_SHORT).show() }
return
}
port = ports[0]
port.open(connection)
port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
Log.d(TAG, "Serial port opened, sending KEY?")
port.purgeHwBuffers(true, true)
val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
port.write(cmd, 500)
Thread.sleep(200)
val buf = ByteArray(256)
val startTime = System.currentTimeMillis()
val sb = StringBuilder()
while (System.currentTimeMillis() - startTime < KEY_TIMEOUT_MS) {
val len = port.read(buf, 200)
if (len > 0) {
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
if (sb.contains("HMAC_KEY:") && sb.contains("\n")) {
break
}
}
}
val response = sb.toString()
Log.d(TAG, "Serial response: $response")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
if (keyLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) {
val upperKey = hexKey.uppercase()
val prefs = context.getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(SettingsActivity.PREF_KEY, upperKey).apply()
Log.d(TAG, "HMAC key saved: $upperKey")
handler.post {
Toast.makeText(context, "HMAC sleutel gelezen van ESP!", Toast.LENGTH_LONG).show()
}
} else {
Log.e(TAG, "Invalid key format: length=${hexKey.length}")
handler.post { Toast.makeText(context, "Ongeldig sleutel formaat van ESP", Toast.LENGTH_SHORT).show() }
}
} else {
Log.e(TAG, "No HMAC_KEY line found in response")
handler.post { Toast.makeText(context, "Geen sleutel gevonden op de ESP", Toast.LENGTH_SHORT).show() }
}
} catch (e: Exception) {
Log.e(TAG, "Error reading key: ${e.message}", e)
handler.post { Toast.makeText(context, "Fout bij uitlezen: ${e.message}", Toast.LENGTH_SHORT).show() }
} finally {
try { port?.close() } catch (e: Exception) {}
}
}
}

View file

@ -1,42 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="32dp">
android:padding="16dp">
<ImageButton
android:id="@+id/settingsButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="end"
android:layout_gravity="end|top"
android:src="@drawable/ic_settings"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Instellingen"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Disconnected"
android:textSize="20sp"
android:layout_marginBottom="48dp"/>
android:contentDescription="Instellingen" />
<Button
android:id="@+id/relayButton"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center"
android:text="HOLD"
android:textSize="24sp"
android:enabled="false"/>
android:enabled="false" />
<Button
android:id="@+id/scanButton"
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Scan &amp; Connect"
android:layout_marginTop="48dp"/>
android:layout_gravity="bottom|start"
android:text="Disconnected"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>

View file

@ -5,6 +5,15 @@
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/keyHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Voer de HMAC sleutel in of sluit de relaismodule aan op de telefoon."
android:textSize="14sp"
android:textStyle="italic"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -66,11 +75,10 @@
android:layout_marginTop="24dp" />
<Button
android:id="@+id/backButton"
android:id="@+id/scanConnectButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Terug"
android:background="@android:color/transparent"
android:text="Scan &amp; Verbind"
android:layout_marginTop="8dp" />
</LinearLayout>