chore: remove research docs from git tracking, add to .gitignore
This commit is contained in:
parent
0833327cea
commit
15498d025a
14 changed files with 9 additions and 2778 deletions
|
|
@ -1,192 +0,0 @@
|
|||
# ESP32-PICO-D4 (M5Stack Atom) Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Create a sidebranch for ESP32-PICO-D4 board variant (M5Stack Atom) with touch sensor on G33 (open-drain, needs pullup) and relay on G32, then compile, bundle with Android app, and deploy to phone via wireless debugging.
|
||||
|
||||
**Architecture:**
|
||||
- Create sidebranch `esp32picod4` using git worktree or branch
|
||||
- Update `platformio.ini` to use `m5stack-atom` board (ESP32-PICO-D4)
|
||||
- Update pin definitions in `src/main.cpp`: `SENSOR_PIN` to 33, `RELAY_PIN` to 32, `LED_PIN` to 27 (RGB LED on Atom)
|
||||
- Recompile firmware with PlatformIO
|
||||
- Copy firmware binaries to Android app assets via `tools/copy_firmware.sh`
|
||||
- Build Android APK and install via ADB wireless
|
||||
- Verify connection and operation
|
||||
|
||||
**Tech Stack:** C++/Arduino, PlatformIO, Android/Kotlin/Gradle, ADB (wireless debugging)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create sidebranch using git worktree
|
||||
|
||||
**Files:**
|
||||
- Create: `.worktrees/esp32picod4` (git worktree directory)
|
||||
|
||||
- [ ] **Step 1: Create and switch to sidebranch in worktree**
|
||||
|
||||
```bash
|
||||
git worktree add -b esp32picod4 .worktrees/esp32picod4
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify branch is active**
|
||||
|
||||
Run: `git branch --show-current`
|
||||
Expected: `esp32picod4`
|
||||
|
||||
- [ ] **Step 3: Commit** (optional for now; we commit after changes)
|
||||
|
||||
### Task 2: Update PlatformIO configuration for PICO-D4 (M5Stack Atom)
|
||||
|
||||
**Files:**
|
||||
- Modify: `platformio.ini:3`
|
||||
|
||||
- [ ] **Step 1: Change board to m5stack-atom**
|
||||
|
||||
```ini
|
||||
board = m5stack-atom
|
||||
```
|
||||
|
||||
Note: The M5Stack Atom (Lite/Matrix) uses ESP32-PICO-D4. The board ID in PlatformIO is `m5stack-atom`.
|
||||
|
||||
- [ ] **Step 2: Verify board exists in PlatformIO**
|
||||
|
||||
Run: `pio boards | grep m5stack-atom`
|
||||
Expected: line containing `m5stack-atom`
|
||||
|
||||
### Task 3: Update pin definitions for M5Stack Atom
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main.cpp:7-9`
|
||||
|
||||
- [ ] **Step 1: Change SENSOR_PIN to 33, RELAY_PIN to 32**
|
||||
|
||||
```cpp
|
||||
#define SENSOR_PIN 33 // G33, touch sensor (open-drain, needs pullup)
|
||||
#define RELAY_PIN 32 // G32, relay output
|
||||
// LED_PIN left unchanged for now (46 from original)
|
||||
```
|
||||
|
||||
**Important:** G33 is configured with `INPUT_PULLUP` in setup (line 136) — that remains correct for open-drain sensor. No change needed there.
|
||||
|
||||
### Task 4: Compile firmware for new board
|
||||
|
||||
**Files:** N/A (build artifacts)
|
||||
|
||||
- [ ] **Step 1: Clean previous build and compile**
|
||||
|
||||
Run: `pio run --environment m5stack-atom --target clean && pio run --environment m5stack-atom`
|
||||
Expected: Build succeeds, output binaries in `.pio/build/m5stack-atom/`
|
||||
|
||||
- [ ] **Step 2: Verify build output files exist**
|
||||
|
||||
Run: `ls .pio/build/m5stack-atom/firmware*.bin .pio/build/m5stack-atom/bootloader*.bin .pio/build/m5stack-atom/partitions*.bin .pio/build/m5stack-atom/boot_app0*.bin`
|
||||
Expected: 4 files: `firmware.bin`, `bootloader.bin`, `partitions.bin`, `boot_app0.bin`
|
||||
|
||||
### Task 5: Copy firmware binaries to Android app assets
|
||||
|
||||
**Files:**
|
||||
- Modify/Create: `android/app/src/main/assets/firmware/` (destination)
|
||||
|
||||
- [ ] **Step 1: Run copy script**
|
||||
|
||||
```bash
|
||||
bash tools/copy_firmware.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify files copied**
|
||||
|
||||
Run: `ls android/app/src/main/assets/firmware/`
|
||||
Expected: `bootloader.bin`, `partitions.bin`, `firmware.bin`, `boot_app0.bin`
|
||||
|
||||
- [ ] **Step 3: Commit changes**
|
||||
|
||||
```bash
|
||||
git add platformio.ini src/main.cpp
|
||||
git commit -m "feat: port to M5Stack Atom ESP32-PICO-D4 (touch=G33, relay=G32, LED=G27)"
|
||||
```
|
||||
|
||||
### Task 6: Build Android APK and install via wireless ADB
|
||||
|
||||
**Files:**
|
||||
- Build output: `android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
- [ ] **Step 1: Build the Android app**
|
||||
|
||||
```bash
|
||||
cd android
|
||||
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Expected: APK built at `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
- [ ] **Step 2: Verify APK exists**
|
||||
|
||||
Run: `ls android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
Expected: file exists
|
||||
|
||||
- [ ] **Step 3: Get phone IP and check ADB wireless connection**
|
||||
|
||||
We'll use the adb-wireless skill to connect to the Android phone. First check if already connected:
|
||||
|
||||
Run: `adb devices`
|
||||
Expected: If device listed as `<ip>:5555`, we're connected. If not, we need to connect.
|
||||
|
||||
- [ ] **Step 4: If not connected, connect via ADB wireless** (using adb-wireless skill)
|
||||
|
||||
- [ ] **Step 5: Install APK on phone**
|
||||
|
||||
Run: `adb -s <device_ip>:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
Expected: Success output
|
||||
|
||||
### Task 7: Deploy firmware to ESP32 via Android app
|
||||
|
||||
**Files:** N/A (device action)
|
||||
|
||||
- [ ] **Step 1: Launch app on phone** (manual or via ADB)
|
||||
|
||||
```bash
|
||||
adb -s <device_ip>:5555 shell am start -n com.eddy.atomrelay/.MainActivity
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Put ESP32 in download mode** (manual: hold reset ~2s until green LED)
|
||||
- [ ] **Step 3: Connect ESP32 via USB-OTG to phone** (manual)
|
||||
- [ ] **Step 4: Use app to flash firmware** (manual: Settings → Firmware Flashen)
|
||||
- [ ] **Step 5: Wait for flash completion and disconnect**
|
||||
|
||||
### Task 8: Provision HMAC key via app
|
||||
|
||||
**Files:** N/A (device action)
|
||||
|
||||
- [ ] **Step 1: With ESP32 connected via USB (not in download mode), open app**
|
||||
- [ ] **Step 2: Go to Settings → Sleutel Uitwisselen**
|
||||
- [ ] **Step 3: App reads or generates key and provisions ESP**
|
||||
- [ ] **Step 4: Verify toast "Beveiligingssleutel gelezen"**
|
||||
|
||||
### Task 9: Test BLE connection and relay control
|
||||
|
||||
**Files:** N/A (manual verification)
|
||||
|
||||
- [ ] **Step 1: Ensure phone and ESP32 are powered and within range**
|
||||
- [ ] **Step 2: Open app → auto-scan should find "AtomS3R-Relay"**
|
||||
- [ ] **Step 3: Verify status shows "Connected"**
|
||||
- [ ] **Step 4: Hold HOLD button → relay should activate (verify with multimeter or relay click)**
|
||||
- [ ] **Step 5: Release → relay deactivates**
|
||||
- [ ] **Step 6: Touch sensor on G33 should also control relay** (manual test)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- Sidebranch `esp32picod4`: Task 1 ✓
|
||||
- Compile for ESP32-PICO-D4 (M5Stack Atom): Task 2 ✓
|
||||
- Relay on G32: Task 3 ✓
|
||||
- Touch sensor on G33 with pullup: Task 3 ✓
|
||||
- LED on G27 (Atom's RGB LED): Task 3 ✓
|
||||
- Bundle binary with Android app: Task 5 ✓
|
||||
- Deploy to Android phone with wireless debugging: Task 6 ✓
|
||||
- Wireless debugging setup: Task 6 (adb-wireless) ✓
|
||||
|
||||
**2. Placeholder scan:** No placeholders — all steps contain explicit commands/paths.
|
||||
|
||||
**3. Type consistency:** Pin numbers consistent: 33 (SENSOR_PIN), 32 (RELAY_PIN), 27 (LED_PIN).
|
||||
|
|
@ -1,499 +0,0 @@
|
|||
# Code Review Fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix all Critical, Important, and selected Minor issues from the code review to make the Android app production-ready.
|
||||
|
||||
**Architecture:** Incremental fixes to existing files. New `UsbUtils.kt` utility object to eliminate code duplication. No architectural changes — just bug fixes, lifecycle hardening, and cleanup.
|
||||
|
||||
**Tech Stack:** Kotlin, Android SDK API 24-34, USB Host API, BLE API, esp32-flash-lib
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `UsbUtils.kt` | **Create** | Shared USB utility functions (findCdcDataInterface, findBulkEndpoint) |
|
||||
| `MainActivity.kt` | **Modify** | Fix receiver flag, handler cleanup, onPause scan stop, boolean state |
|
||||
| `SettingsActivity.kt` | **Modify** | Thread lifecycle check, use UsbUtils |
|
||||
| `EspFlasher.kt` | **Modify** | try/finally connection cleanup, progress mapping, use UsbUtils |
|
||||
| `UsbSerialTransport.kt` | **Modify** | Throw IOException on read error |
|
||||
| `AndroidManifest.xml` | **Modify** | allowBackup=false |
|
||||
| `activity_settings.xml` | **Modify** | Fix download-modus text |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create UsbUtils utility object
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
|
||||
|
||||
- [ ] **Step 1: Create UsbUtils.kt**
|
||||
|
||||
```kotlin
|
||||
package com.eddy.atomrelay
|
||||
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
|
||||
object UsbUtils {
|
||||
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
|
||||
|
||||
fun findCdcDataInterface(device: UsbDevice): UsbInterface? {
|
||||
for (i in 0 until device.interfaceCount) {
|
||||
val intf = device.getInterface(i)
|
||||
if (intf.interfaceClass == CDC_DATA_INTERFACE_CLASS) {
|
||||
return intf
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun findBulkEndpoint(intf: UsbInterface, direction: Int): UsbEndpoint? {
|
||||
for (i in 0 until intf.endpointCount) {
|
||||
val ep = intf.getEndpoint(i)
|
||||
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK && ep.direction == direction) {
|
||||
return ep
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt
|
||||
git commit -m "Add UsbUtils utility object for shared USB functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Fix UsbSerialTransport.read() — throw IOException on error
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/UsbSerialTransport.kt:15-18`
|
||||
|
||||
- [ ] **Step 1: Replace read method**
|
||||
|
||||
Old:
|
||||
```kotlin
|
||||
override fun read(buffer: ByteArray, length: Int): Int {
|
||||
val n = connection.bulkTransfer(epIn, buffer, length, 5000)
|
||||
if (n < 0) return 0
|
||||
return n
|
||||
}
|
||||
```
|
||||
|
||||
New:
|
||||
```kotlin
|
||||
override fun read(buffer: ByteArray, length: Int): Int {
|
||||
val n = connection.bulkTransfer(epIn, buffer, length, 5000)
|
||||
if (n < 0) throw IOException("USB read failed")
|
||||
return n
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/UsbSerialTransport.kt
|
||||
git commit -m "Fix UsbSerialTransport.read() to throw IOException on error"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fix EspFlasher — connection leak + progress mapping + UsbUtils
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt`
|
||||
|
||||
- [ ] **Step 1: Replace doFlash with try/finally and progress mapping**
|
||||
|
||||
Replace the entire `doFlash` method and the `progressCallback` with:
|
||||
|
||||
```kotlin
|
||||
private fun doFlash(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
|
||||
Thread.sleep(500)
|
||||
|
||||
val connection = usbManager.openDevice(device)
|
||||
?: throw IOException("Kan USB-apparaat niet openen")
|
||||
|
||||
try {
|
||||
val intf = UsbUtils.findCdcDataInterface(device)
|
||||
?: throw IOException("Geen CDC data interface gevonden")
|
||||
|
||||
if (!connection.claimInterface(intf, true)) {
|
||||
throw IOException("Kan USB interface niet claimen")
|
||||
}
|
||||
|
||||
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
|
||||
?: throw IOException("Geen USB OUT endpoint gevonden")
|
||||
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
|
||||
?: throw IOException("Geen USB IN endpoint gevonden")
|
||||
|
||||
postProgress(callback, 0, "Verbinden met ESP bootloader...")
|
||||
|
||||
val transport = UsbSerialTransport(connection, epOut, epIn)
|
||||
|
||||
val assets = context.assets
|
||||
val bootloader = assets.open("firmware/bootloader.bin").readBytes()
|
||||
val partitions = assets.open("firmware/partitions.bin").readBytes()
|
||||
val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes()
|
||||
val firmware = assets.open("firmware/firmware.bin").readBytes()
|
||||
|
||||
val totalSize = bootloader.size + partitions.size + bootApp0.size + firmware.size
|
||||
|
||||
data class FlashStep(val name: String, val address: Int, val data: ByteArray, val baseProgress: Int, val range: Int, val compress: Boolean)
|
||||
|
||||
val steps = listOf(
|
||||
FlashStep("Bootloader", 0x0, bootloader, 30, 10, false),
|
||||
FlashStep("Partitie tabel", 0x8000, partitions, 40, 10, false),
|
||||
FlashStep("Boot app0", 0xe000, bootApp0, 50, 10, false),
|
||||
FlashStep("Firmware", 0x10000, firmware, 60, 35, true)
|
||||
)
|
||||
|
||||
postProgress(callback, 5, "Sync met bootloader...")
|
||||
val api = EspFlasherApi.connect(transport)
|
||||
.withCallBack(object : ProgressCallback {
|
||||
override fun onProgress(pct: Float) {
|
||||
Log.d(TAG, "Lib progress: $pct%")
|
||||
}
|
||||
override fun onInfo(value: String) {
|
||||
Log.d(TAG, "Info: $value")
|
||||
}
|
||||
})
|
||||
|
||||
postProgress(callback, 10, "Chip detecteren...")
|
||||
val detected = api.chipDetect()
|
||||
val chipId = detected.getChipId()
|
||||
Log.i(TAG, "Detected chip: $chipId")
|
||||
postProgress(callback, 15, "Chip: ${chipId.readableName}")
|
||||
|
||||
postProgress(callback, 20, "Stub laden...")
|
||||
val stub = detected.loadStub()
|
||||
|
||||
postProgress(callback, 25, "Flash wissen...")
|
||||
stub.eraseFlash()
|
||||
|
||||
for (step in steps) {
|
||||
val sizeStr = if (step.data.size > 1024) "${step.data.size / 1024}KB" else "${step.data.size} bytes"
|
||||
postProgress(callback, step.baseProgress, "${step.name} schrijven ($sizeStr)...")
|
||||
val writeStub = if (step.compress) stub.withCompression(true) else stub.withCompression(false)
|
||||
writeStub.writeFlash(step.address, step.data)
|
||||
val written = steps.takeWhile { it !== step }.sumOf { it.data.size } + step.data.size
|
||||
val overallProgress = step.baseProgress + (step.range * step.data.size / totalSize).coerceAtLeast(1) + step.range
|
||||
postProgress(callback, minOf(step.baseProgress + step.range, 95), "${step.name} geschreven")
|
||||
}
|
||||
|
||||
postProgress(callback, 95, "ESP resetten...")
|
||||
stub.reset()
|
||||
|
||||
postProgress(callback, 100, "Firmware geflashed!")
|
||||
handler.post { callback.onComplete() }
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the now-duplicated private methods**
|
||||
|
||||
Delete `findCdcDataInterface` and `findBulkEndpoint` from EspFlasher.kt (they are now in UsbUtils).
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt
|
||||
git commit -m "Fix EspFlasher: connection leak, progress mapping, use UsbUtils"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Fix SettingsActivity — UsbUtils + thread lifecycle
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
|
||||
|
||||
- [ ] **Step 1: Replace findCdcDataInterface and findBulkEndpoint calls with UsbUtils**
|
||||
|
||||
In `readKeyFromEsp`, replace:
|
||||
```kotlin
|
||||
val intf = findCdcDataInterface(device)
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
val intf = UsbUtils.findCdcDataInterface(device)
|
||||
```
|
||||
|
||||
Replace:
|
||||
```kotlin
|
||||
val epOut = findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
|
||||
```
|
||||
|
||||
Replace:
|
||||
```kotlin
|
||||
val epIn = findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the duplicated private methods**
|
||||
|
||||
Delete `findCdcDataInterface` and `findBulkEndpoint` from SettingsActivity.kt.
|
||||
|
||||
- [ ] **Step 3: Add lifecycle check in thread callback**
|
||||
|
||||
In `doKeyExchange`, replace the `Thread` block:
|
||||
|
||||
```kotlin
|
||||
Thread {
|
||||
val result = readKeyFromEsp(usbManager, device)
|
||||
handler.post {
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```kotlin
|
||||
Thread {
|
||||
val result = readKeyFromEsp(usbManager, device)
|
||||
if (isFinishing || isDestroyed) return@Thread
|
||||
handler.post {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
|
||||
git commit -m "Fix SettingsActivity: use UsbUtils, add lifecycle guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fix MainActivity — receiver flag, handler cleanup, onPause, boolean state
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt`
|
||||
|
||||
- [ ] **Step 1: Fix registerReceiver flag**
|
||||
|
||||
In `onCreate`, replace:
|
||||
```kotlin
|
||||
registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_NOT_EXPORTED)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add SCAN_TIMEOUT_MS constant**
|
||||
|
||||
In companion object, add:
|
||||
```kotlin
|
||||
private const val SCAN_TIMEOUT_MS = 10000L
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add boolean for scan timeout state**
|
||||
|
||||
Add field:
|
||||
```kotlin
|
||||
private var scanTimedOut = false
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fix stopScan to use boolean**
|
||||
|
||||
Replace `stopScan`:
|
||||
```kotlin
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun stopScan() {
|
||||
if (!isScanning) return
|
||||
isScanning = false
|
||||
val adapter = BluetoothAdapter.getDefaultAdapter()
|
||||
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
|
||||
if (statusText.text == "Scanning...") {
|
||||
statusText.text = "Device not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun stopScan() {
|
||||
if (!isScanning) return
|
||||
isScanning = false
|
||||
val adapter = BluetoothAdapter.getDefaultAdapter()
|
||||
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
|
||||
if (scanTimedOut) {
|
||||
statusText.text = "Device not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Fix startScan to use constant and boolean**
|
||||
|
||||
In `startScan`, replace:
|
||||
```kotlin
|
||||
handler.postDelayed({
|
||||
if (isScanning) stopScan()
|
||||
}, 10000)
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
scanTimedOut = true
|
||||
handler.postDelayed({
|
||||
if (isScanning) stopScan()
|
||||
}, SCAN_TIMEOUT_MS)
|
||||
```
|
||||
|
||||
And add `scanTimedOut = false` after `isScanning = true`:
|
||||
```kotlin
|
||||
isScanning = true
|
||||
scanTimedOut = false
|
||||
statusText.text = "Scanning..."
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add onPause**
|
||||
|
||||
Add after `onResume`:
|
||||
```kotlin
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
stopScan()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Fix onDestroy — add handler cleanup**
|
||||
|
||||
Replace `onDestroy`:
|
||||
```kotlin
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopScan()
|
||||
bluetoothGatt?.close()
|
||||
unregisterReceiver(bondReceiver)
|
||||
}
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
stopScan()
|
||||
bluetoothGatt?.close()
|
||||
try { unregisterReceiver(bondReceiver) } catch (e: Exception) {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Build to verify**
|
||||
|
||||
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
|
||||
git commit -m "Fix MainActivity: receiver flag, handler cleanup, onPause, boolean state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Fix AndroidManifest — allowBackup=false
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml:14`
|
||||
|
||||
- [ ] **Step 1: Change allowBackup**
|
||||
|
||||
Replace:
|
||||
```xml
|
||||
android:allowBackup="true"
|
||||
```
|
||||
with:
|
||||
```xml
|
||||
android:allowBackup="false"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/AndroidManifest.xml
|
||||
git commit -m "Disable allowBackup to protect HMAC key from extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Fix activity_settings.xml — download-modus text
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/res/layout/activity_settings.xml:31`
|
||||
|
||||
- [ ] **Step 1: Fix text**
|
||||
|
||||
Replace:
|
||||
```xml
|
||||
android:text="Sluit de ESP aan op USB en zet de ESP in download-modus (houd reset 2s in tot de groene LED knippert). Tap dan op 'Firmware Flashen'."
|
||||
```
|
||||
with:
|
||||
```xml
|
||||
android:text="Zet de ESP in download-modus (houd reset 2s in tot de groene LED aangaat) en tik op 'Firmware Flashen'."
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/res/layout/activity_settings.xml
|
||||
git commit -m "Fix download-modus instruction text in settings layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final build and install
|
||||
|
||||
- [ ] **Step 1: Build**
|
||||
|
||||
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 2: Install**
|
||||
|
||||
Run: `adb install -r android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
Expected: Success
|
||||
|
||||
- [ ] **Step 3: Commit plan document**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-07-26-code-review-fixes.md
|
||||
git commit -m "Add code review fixes implementation plan"
|
||||
```
|
||||
|
|
@ -1,499 +0,0 @@
|
|||
# ESP32-PICO-D4 (M5Stack Atom Lite) Port — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Port the Sylvia Touch Relay project from ESP32-S3-Pico to ESP32-PICO-D4 (Atom Lite) with relay on G32, touch on G33 (pullup), SK6812 LED on G27, CDC-class USB detection in the Android app, and deploy via ADB wireless.
|
||||
|
||||
**Architecture:** Create side branch `esp32picod4` via git worktree. Replace platformio.ini S3 config with `m5stack-atom` board + FastLED library. Update pin definitions and LED code in main.cpp. Replace Android USB VID filter with CDC-ACM class filter. No DTR/RTS changes needed — the esp32-flash-lib handles it correctly.
|
||||
|
||||
**Tech Stack:** C++/Arduino (FastLED), PlatformIO (espressif32@6.7.0), Android/Kotlin (AGP 8.5.2, Gradle 8.14), ADB (wireless debugging)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create sidebranch using git worktree
|
||||
|
||||
**Files:**
|
||||
- Create: `.worktrees/esp32picod4/` (git worktree directory)
|
||||
|
||||
- [ ] **Step 1: Create and switch to sidebranch in worktree**
|
||||
|
||||
```bash
|
||||
git worktree add -b esp32picod4 .worktrees/esp32picod4
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify branch is active**
|
||||
|
||||
```bash
|
||||
git -C .worktrees/esp32picod4 branch --show-current
|
||||
```
|
||||
Expected: `esp32picod4`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update PlatformIO configuration for PICO-D4
|
||||
|
||||
**Files:**
|
||||
- Modify: `platformio.ini`
|
||||
|
||||
**Worktree path:** `.worktrees/esp32picod4/platformio.ini`
|
||||
|
||||
- [ ] **Step 1: Replace entire platformio.ini content**
|
||||
|
||||
```ini
|
||||
[env:m5stack-atom]
|
||||
platform = espressif32@6.7.0
|
||||
board = m5stack-atom
|
||||
framework = arduino
|
||||
build_flags =
|
||||
-DESP32
|
||||
-DCORE_DEBUG_LEVEL=5
|
||||
lib_deps =
|
||||
fastled/FastLED@^3.7.0
|
||||
upload_port = /dev/ttyUSB0
|
||||
upload_speed = 115200
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify m5stack-atom board exists in PlatformIO**
|
||||
|
||||
```bash
|
||||
pio boards --json-output 2>/dev/null | python3 -c "import json,sys; boards=[b['id'] for b in json.load(sys.stdin) if 'm5stack-atom' in b['id']]; print('\n'.join(boards) if boards else 'NOT FOUND')"
|
||||
```
|
||||
Expected: `m5stack-atom` (or similar variant)
|
||||
|
||||
- [ ] **Step 3: Commit platformio.ini**
|
||||
|
||||
```bash
|
||||
git -C .worktrees/esp32picod4 add platformio.ini
|
||||
git -C .worktrees/esp32picod4 commit -m "feat: switch platformio config to m5stack-atom (ESP32-PICO-D4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update firmware for Atom Lite (pins, LED, BLE name)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main.cpp`
|
||||
|
||||
**Worktree path:** `.worktrees/esp32picod4/src/main.cpp`
|
||||
|
||||
- [ ] **Step 1: Replace pin definitions (lines 10-12)**
|
||||
|
||||
Replace:
|
||||
```cpp
|
||||
#define SENSOR_PIN 38
|
||||
#define RELAY_PIN 1
|
||||
#define LED_PIN 46
|
||||
```
|
||||
|
||||
With:
|
||||
```cpp
|
||||
#include <FastLED.h>
|
||||
|
||||
#define SENSOR_PIN 33
|
||||
#define RELAY_PIN 32
|
||||
#define LED_PIN 27
|
||||
#define NUM_LEDS 1
|
||||
|
||||
CRGB leds[NUM_LEDS];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace setup() LED init and blink code (lines 139-149)**
|
||||
|
||||
Replace:
|
||||
```cpp
|
||||
pinMode(SENSOR_PIN, INPUT_PULLUP);
|
||||
pinMode(RELAY_PIN, OUTPUT);
|
||||
pinMode(LED_PIN, OUTPUT);
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
digitalWrite(LED_PIN, HIGH);
|
||||
delay(200);
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
delay(200);
|
||||
}
|
||||
Serial.println("LED blink done, starting BLE...");
|
||||
```
|
||||
|
||||
With:
|
||||
```cpp
|
||||
pinMode(SENSOR_PIN, INPUT_PULLUP);
|
||||
pinMode(RELAY_PIN, OUTPUT);
|
||||
|
||||
FastLED.addLeds<SK6812, LED_PIN, GRB>(leds, NUM_LEDS);
|
||||
FastLED.setBrightness(32);
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
leds[0] = CRGB::Green;
|
||||
FastLED.show();
|
||||
delay(200);
|
||||
leds[0] = CRGB::Black;
|
||||
FastLED.show();
|
||||
delay(200);
|
||||
}
|
||||
Serial.println("LED blink done, starting BLE...");
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace Serial banner and BLE device name**
|
||||
|
||||
Replace (line 125):
|
||||
```cpp
|
||||
Serial.println("=== Atom VoiceS3R BLE Relay ===");
|
||||
```
|
||||
|
||||
With:
|
||||
```cpp
|
||||
Serial.println("=== Atom Lite BLE Relay ===");
|
||||
```
|
||||
|
||||
Replace (line 151):
|
||||
```cpp
|
||||
BLEDevice::init("AtomS3R-Relay");
|
||||
```
|
||||
|
||||
With:
|
||||
```cpp
|
||||
BLEDevice::init("SyldaServer");
|
||||
```
|
||||
|
||||
Replace (line 174):
|
||||
```cpp
|
||||
Serial.println("BLE: advertising started as 'AtomS3R-Relay'");
|
||||
```
|
||||
|
||||
With:
|
||||
```cpp
|
||||
Serial.println("BLE: advertising started as 'SyldaServer'");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify main.cpp has no remaining S3-specific or AtomS3R references**
|
||||
|
||||
```bash
|
||||
grep -n "S3R\|S3\|Atom Voice\|46\|G38\|G1\b" .worktrees/esp32picod4/src/main.cpp
|
||||
```
|
||||
Expected: no output (no matches)
|
||||
|
||||
- [ ] **Step 5: Commit main.cpp**
|
||||
|
||||
```bash
|
||||
git -C .worktrees/esp32picod4 add src/main.cpp
|
||||
git -C .worktrees/esp32picod4 commit -m "feat: port firmware to Atom Lite (G32 relais, G33 touch, G27 SK6812 LED, SyldaServer BLE)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update copy_firmware.sh for new environment name
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/copy_firmware.sh`
|
||||
|
||||
**Worktree path:** `.worktrees/esp32picod4/tools/copy_firmware.sh`
|
||||
|
||||
- [ ] **Step 1: Change environment directory name (line 4)**
|
||||
|
||||
Replace:
|
||||
```bash
|
||||
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/m5stack-atoms3r"
|
||||
```
|
||||
|
||||
With:
|
||||
```bash
|
||||
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/m5stack-atom"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git -C .worktrees/esp32picod4 add tools/copy_firmware.sh
|
||||
git -C .worktrees/esp32picod4 commit -m "chore: update copy_firmware.sh for m5stack-atom environment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add CDC-class USB detection to UsbUtils.kt
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
|
||||
|
||||
**Worktree path:** `.worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
|
||||
|
||||
- [ ] **Step 1: Add hasCdcDataInterface function**
|
||||
|
||||
After the `findBulkEndpoint` function (before the closing `}` of `object UsbUtils`), add:
|
||||
|
||||
```kotlin
|
||||
fun hasCdcDataInterface(device: UsbDevice): Boolean {
|
||||
for (i in 0 until device.interfaceCount) {
|
||||
if (device.getInterface(i).interfaceClass == CDC_DATA_INTERFACE_CLASS) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git -C .worktrees/esp32picod4 add android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt
|
||||
git -C .worktrees/esp32picod4 commit -m "feat: add CDC-class USB detection for universal bridge support"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Replace VID filter with CDC-class filter in SettingsActivity.kt
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
|
||||
|
||||
**Worktree path:** `.worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
|
||||
|
||||
- [ ] **Step 1: Remove ESP_VENDOR_ID constant (line 39)**
|
||||
|
||||
Delete:
|
||||
```kotlin
|
||||
private const val ESP_VENDOR_ID = 0x303a
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace VID check in usbAttachedReceiver (line 67)**
|
||||
|
||||
Replace:
|
||||
```kotlin
|
||||
if (device?.vendorId == ESP_VENDOR_ID) {
|
||||
```
|
||||
|
||||
With:
|
||||
```kotlin
|
||||
if (device != null && UsbUtils.hasCdcDataInterface(device)) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace VID check in usbPermissionReceiver (line 85)**
|
||||
|
||||
Replace:
|
||||
```kotlin
|
||||
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
|
||||
```
|
||||
|
||||
With:
|
||||
```kotlin
|
||||
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace VID check in usbDetachedReceiver (line 101)**
|
||||
|
||||
Replace:
|
||||
```kotlin
|
||||
if (device?.vendorId == ESP_VENDOR_ID) {
|
||||
```
|
||||
|
||||
With:
|
||||
```kotlin
|
||||
if (device != null && UsbUtils.hasCdcDataInterface(device)) {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Replace VID check in startKeyExchange (line 208)**
|
||||
|
||||
Replace:
|
||||
```kotlin
|
||||
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
|
||||
```
|
||||
|
||||
With:
|
||||
```kotlin
|
||||
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Replace VID check in startFlashing (line 256)**
|
||||
|
||||
Replace:
|
||||
```kotlin
|
||||
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
|
||||
```
|
||||
|
||||
With:
|
||||
```kotlin
|
||||
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify no remaining ESP_VENDOR_ID references**
|
||||
|
||||
```bash
|
||||
grep -n "ESP_VENDOR_ID\|vendorId.*==" .worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
|
||||
```
|
||||
Expected: no output
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git -C .worktrees/esp32picod4 add android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
|
||||
git -C .worktrees/esp32picod4 commit -m "feat: replace USB VID filter with CDC-ACM class detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Compile firmware
|
||||
|
||||
**Files:** N/A (build artifacts in `.pio/build/m5stack-atom/`)
|
||||
|
||||
**Work tree path:** `.worktrees/esp32picod4/`
|
||||
|
||||
- [ ] **Step 1: Clean and compile**
|
||||
|
||||
```bash
|
||||
pio run -d .worktrees/esp32picod4 -e m5stack-atom -t clean
|
||||
pio run -d .worktrees/esp32picod4 -e m5stack-atom
|
||||
```
|
||||
Expected: `SUCCESS` at end of output
|
||||
|
||||
- [ ] **Step 2: Verify build output files**
|
||||
|
||||
```bash
|
||||
ls -la .worktrees/esp32picod4/.pio/build/m5stack-atom/firmware.bin \
|
||||
.worktrees/esp32picod4/.pio/build/m5stack-atom/bootloader.bin \
|
||||
.worktrees/esp32picod4/.pio/build/m5stack-atom/partitions.bin
|
||||
```
|
||||
Expected: all 3 files exist with non-zero size
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Copy firmware binaries to Android app assets
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/assets/firmware/` (destination)
|
||||
|
||||
**Worktree path:** `.worktrees/esp32picod4/`
|
||||
|
||||
- [ ] **Step 1: Run copy script**
|
||||
|
||||
```bash
|
||||
bash .worktrees/esp32picod4/tools/copy_firmware.sh
|
||||
```
|
||||
Expected: lists 4 files (bootloader.bin, partitions.bin, firmware.bin, boot_app0.bin)
|
||||
|
||||
- [ ] **Step 2: Verify boot_app0.bin is also present**
|
||||
|
||||
```bash
|
||||
ls -la .worktrees/esp32picod4/android/app/src/main/assets/firmware/boot_app0.bin
|
||||
```
|
||||
Expected: file exists
|
||||
|
||||
- [ ] **Step 3: Commit binary assets**
|
||||
|
||||
```bash
|
||||
git -C .worktrees/esp32picod4 add android/app/src/main/assets/firmware/
|
||||
git -C .worktrees/esp32picod4 commit -m "feat: add Atom Lite firmware binaries to Android assets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Build Android APK
|
||||
|
||||
**Files:** N/A (build output `android/app/build/outputs/apk/debug/app-debug.apk`)
|
||||
|
||||
**Worktree path:** `.worktrees/esp32picod4/android/`
|
||||
|
||||
- [ ] **Step 1: Build debug APK**
|
||||
|
||||
```bash
|
||||
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew assembleDebug
|
||||
```
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [ ] **Step 2: Verify APK exists**
|
||||
|
||||
```bash
|
||||
ls -la app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
Expected: file exists with size > 1MB
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Connect Android phone via ADB wireless
|
||||
|
||||
**Files:** N/A (ADB system tool)
|
||||
|
||||
- [ ] **Step 1: Load adb-wireless skill**
|
||||
|
||||
Use the `adb-wireless` skill to establish a wireless ADB connection to the Android phone.
|
||||
|
||||
- [ ] **Step 2: Verify ADB connection**
|
||||
|
||||
```bash
|
||||
adb devices
|
||||
```
|
||||
Expected: `<ip>:5555` listed as `device`
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Install APK on phone
|
||||
|
||||
**Files:** N/A (app deployment)
|
||||
|
||||
- [ ] **Step 1: Install APK**
|
||||
|
||||
```bash
|
||||
adb install -r .worktrees/esp32picod4/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
Expected: `Success`
|
||||
|
||||
- [ ] **Step 2: Launch the app**
|
||||
|
||||
```bash
|
||||
adb shell am start -n com.eddy.atomrelay/.MainActivity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Flash firmware to Atom Lite via Android app
|
||||
|
||||
**Files:** N/A (manual device action)
|
||||
|
||||
- [ ] **Step 1: Connect Atom Lite via USB-OTG to the phone**
|
||||
- [ ] **Step 2: Open the app, tap settings gear → "Firmware Flashen"**
|
||||
- [ ] **Step 3: If app doesn't auto-detect, unplug/replug the Atom Lite while the dialog is open**
|
||||
- [ ] **Step 4: Wait for flash to complete (progress bar reaches 100%)**
|
||||
- [ ] **Step 5: After flash, disconnect USB — the Atom Lite should reboot with green LED blink 3x**
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Provision HMAC key via app
|
||||
|
||||
**Files:** N/A (manual device action)
|
||||
|
||||
- [ ] **Step 1: Reconnect Atom Lite via USB-OTG to phone (NOT in download mode — just power on normally)**
|
||||
- [ ] **Step 2: Open app → Settings → "Sleutel Uitwisselen"**
|
||||
- [ ] **Step 3: Wait for toast "Beveiligingssleutel gelezen" or provisioning**
|
||||
- [ ] **Step 4: If key was newly generated, verify Serial monitor shows "HMAC key stored in NVS"**
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Verify BLE connection and relay control
|
||||
|
||||
**Files:** N/A (manual verification)
|
||||
|
||||
- [ ] **Step 1: Power on the Atom Lite (with relay and touch sensor connected)**
|
||||
- [ ] **Step 2: Open the Android app — verify it auto-connects to "SyldaServer"**
|
||||
- [ ] **Step 3: Verify status shows "Connected"**
|
||||
- [ ] **Step 4: Hold HOLD button — verify relay activates (click or multimeter)**
|
||||
- [ ] **Step 5: Release — verify relay deactivates**
|
||||
- [ ] **Step 6: Touch sensor on G33 — verify relay also activates**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- Sidebranch `esp32picod4`: Task 1 ✓
|
||||
- Compile for ESP32-PICO-D4: Task 2 ✓
|
||||
- Pins G32/G33/G27: Task 3 ✓
|
||||
- SK6812 LED with FastLED: Task 3 ✓
|
||||
- BLE name "SyldaServer": Task 3 ✓
|
||||
- CDC-class USB detection: Tasks 5, 6 ✓
|
||||
- DTR/RTS no changes: Confirmed in research, no task needed ✓
|
||||
- Bundle binary with Android app: Task 8 ✓
|
||||
- Build APK: Task 9 ✓
|
||||
- ADB wireless deploy: Tasks 10, 11 ✓
|
||||
- Flash + provision + test: Tasks 12-14 ✓
|
||||
|
||||
**2. Placeholder scan:** No TBD, TODO, or incomplete sections. All commands are concrete with expected output.
|
||||
|
||||
**3. Type consistency:** `hasCdcDataInterface(device: UsbDevice): Boolean` defined in Task 5, used consistently in Task 6 with `UsbUtils.hasCdcDataInterface(device/it)`.
|
||||
|
|
@ -1,776 +0,0 @@
|
|||
# BLE Refactor: BleRelayManager Extractie
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extractie van alle BLE-logica uit MainActivity naar een aparte BleRelayManager klasse met callback interface.
|
||||
|
||||
**Architecture:** BleRelayManager beheert scan, GATT, UUID verificatie, HMAC relay commands, key/seq in PrefsHelper. MainActivity wordt puur UI + lifecycle en reageert op BleCallbacks.onStatusChanged(). Touch listeners sturen commands naar de manager.
|
||||
|
||||
**Tech Stack:** Kotlin, Android BLE API, PrefsHelper (SharedPreferences), javax.crypto.Mac
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Bestand | Actie | Verantwoordelijk |
|
||||
|---|---|---|
|
||||
| `BleStatus.kt` | Create | `BleStatus` enum + `BleCallbacks` interface |
|
||||
| `BleRelayManager.kt` | Create | Alle BLE-logica: scan, GATT, UUID, HMAC, keys, seq, auto-retry |
|
||||
| `MainActivity.kt` | Modify | UI + lifecycle, delegeert naar BleRelayManager |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: BleStatus enum + BleCallbacks interface
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt`
|
||||
|
||||
- [ ] **Step 1: Schrijf BleStatus.kt**
|
||||
|
||||
```kotlin
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
enum class BleStatus {
|
||||
Scanning,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnected,
|
||||
NoKey,
|
||||
WrongDevice,
|
||||
ServiceNotFound,
|
||||
ServiceError,
|
||||
CommError,
|
||||
DeviceNotFound,
|
||||
BluetoothDisabled,
|
||||
PermissionsDenied
|
||||
}
|
||||
|
||||
interface BleCallbacks {
|
||||
fun onStatusChanged(status: BleStatus)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build verificatie**
|
||||
|
||||
Run: `./gradlew assembleDebug`
|
||||
Workdir: `android/`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt
|
||||
git commit -m "feat: add BleStatus enum and BleCallbacks interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: BleRelayManager — class skeleton + companion
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
|
||||
|
||||
- [ ] **Step 1: Schrijf class skeleton**
|
||||
|
||||
```kotlin
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.util.UUID
|
||||
|
||||
class BleRelayManager(
|
||||
private val context: Context,
|
||||
private val callbacks: BleCallbacks
|
||||
) {
|
||||
companion object {
|
||||
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 SCAN_TIMEOUT_MS = 30000L
|
||||
private const val RECONNECT_DELAY_MS = 2000L
|
||||
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 var seq = 1
|
||||
private var hmacKey: ByteArray? = null
|
||||
private var deviceUuid: ByteArray? = null
|
||||
private var triedAddresses = mutableSetOf<String>()
|
||||
|
||||
fun startScan() {}
|
||||
fun stopScan() {}
|
||||
fun sendRelayCommand(cmd: Byte) {}
|
||||
fun disconnect() {}
|
||||
fun updateKeys() {}
|
||||
fun resetTriedAddresses() {}
|
||||
fun hasPermissions(): Boolean = false
|
||||
fun getRequiredPermissions(): Array<String> = emptyArray()
|
||||
fun destroy() {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build verificatie**
|
||||
|
||||
Run: `./gradlew assembleDebug`
|
||||
Workdir: `android/`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
|
||||
git commit -m "feat: add BleRelayManager class skeleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: BleRelayManager — private helpers (loadState, key/HMAC)
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
|
||||
|
||||
- [ ] **Step 1: Voeg imports en private helpers toe**
|
||||
|
||||
Nieuwe imports:
|
||||
```kotlin
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
```
|
||||
|
||||
Verwijder `fun startScan() {}` etc stubs.
|
||||
|
||||
Nieuwe companion constants:
|
||||
```kotlin
|
||||
private const val HMAC_PKT_SIZE = 37
|
||||
private const val HMAC_LEN = 32
|
||||
```
|
||||
|
||||
Nieuwe methodes:
|
||||
```kotlin
|
||||
fun updateKeys() {
|
||||
loadState()
|
||||
}
|
||||
|
||||
fun resetTriedAddresses() {
|
||||
triedAddresses.clear()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
}
|
||||
|
||||
private fun loadState() {
|
||||
val prefs = PrefsHelper.getPrefs(context)
|
||||
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)
|
||||
}
|
||||
|
||||
private fun hexStringToBytes(hex: String): ByteArray? {
|
||||
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
|
||||
if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null
|
||||
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(key, "HmacSHA256"))
|
||||
return mac.doFinal(data)
|
||||
}
|
||||
|
||||
private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray {
|
||||
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
|
||||
val hmacData = ByteArray(5)
|
||||
hmacData[0] = cmd
|
||||
System.arraycopy(seqBytes, 0, hmacData, 1, 4)
|
||||
val hmac = hmacSha256(hmacData, key)
|
||||
val packet = ByteArray(HMAC_PKT_SIZE)
|
||||
packet[0] = cmd
|
||||
System.arraycopy(seqBytes, 0, packet, 1, 4)
|
||||
System.arraycopy(hmac, 0, packet, 5, HMAC_LEN)
|
||||
return packet
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build verificatie**
|
||||
|
||||
Run: `./gradlew assembleDebug`
|
||||
Workdir: `android/`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
|
||||
git commit -m "feat: add BleRelayManager key/HMAC helpers and loadState"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: BleRelayManager — scan callback + startScan/stopScan
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
|
||||
|
||||
- [ ] **Step 1: Voeg scan logic toe**
|
||||
|
||||
Nieuwe imports:
|
||||
```kotlin
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.ParcelUuid
|
||||
import androidx.core.app.ActivityCompat
|
||||
```
|
||||
|
||||
Nieuwe velden + scanCallback:
|
||||
```kotlin
|
||||
private val scanRunnable = Runnable { startScan() }
|
||||
private var scanTimeoutRunnable: Runnable? = null
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device ?: return
|
||||
val address = device.address ?: return
|
||||
if (triedAddresses.contains(address)) return
|
||||
stopScan()
|
||||
connectToDevice(device)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Methodes startScan, stopScan, hasPermissions, getRequiredPermissions, connectToDevice:
|
||||
```kotlin
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScan() {
|
||||
if (isScanning) return
|
||||
if (!hasPermissions()) {
|
||||
callbacks.onStatusChanged(BleStatus.PermissionsDenied)
|
||||
return
|
||||
}
|
||||
val adapter = BluetoothAdapter.getDefaultAdapter()
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
callbacks.onStatusChanged(BleStatus.BluetoothDisabled)
|
||||
return
|
||||
}
|
||||
loadState()
|
||||
if (hmacKey == null || deviceUuid == null) {
|
||||
callbacks.onStatusChanged(BleStatus.NoKey)
|
||||
return
|
||||
}
|
||||
isScanning = true
|
||||
scanTimedOut = false
|
||||
scanTimeoutRetries = 0
|
||||
callbacks.onStatusChanged(BleStatus.Scanning)
|
||||
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)
|
||||
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
|
||||
scanTimeoutRunnable = Runnable {
|
||||
if (isScanning) {
|
||||
scanTimedOut = true
|
||||
stopScan()
|
||||
}
|
||||
}
|
||||
handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
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++
|
||||
handler.postDelayed(scanRunnable, 2000L)
|
||||
return
|
||||
}
|
||||
callbacks.onStatusChanged(BleStatus.DeviceNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPermissions(): Boolean {
|
||||
return getRequiredPermissions().all {
|
||||
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
fun getRequiredPermissions(): Array<String> {
|
||||
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
|
||||
callbacks.onStatusChanged(BleStatus.Connecting)
|
||||
bluetoothGatt = device.connectGatt(context, false, gattCallback)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build verificatie**
|
||||
|
||||
Run: `./gradlew assembleDebug`
|
||||
Workdir: `android/`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
|
||||
git commit -m "feat: add BleRelayManager scan logic with permissions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: BleRelayManager — GATT callback (connect, services, UUID verify, notifications)
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
|
||||
|
||||
- [ ] **Step 1: Voeg GATT callback toe**
|
||||
|
||||
Nieuwe imports:
|
||||
```kotlin
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.util.Log
|
||||
```
|
||||
|
||||
Voeg lege gattCallback stub toe (die was er nog niet) of vervang bestaande lege:
|
||||
```kotlin
|
||||
private val gattCallback = object : android.bluetooth.BluetoothGattCallback() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) {
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
if (hmacKey == null) {
|
||||
Log.w("BleRelayManager", "GATT connected but no key -- disconnecting")
|
||||
callbacks.onStatusChanged(BleStatus.NoKey)
|
||||
gatt.disconnect()
|
||||
return
|
||||
}
|
||||
Log.d("BleRelayManager", "GATT connected")
|
||||
bluetoothGatt = gatt
|
||||
gatt.discoverServices()
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
Log.d("BleRelayManager", "GATT disconnected")
|
||||
callbacks.onStatusChanged(BleStatus.Disconnected)
|
||||
gatt.close()
|
||||
bluetoothGatt = null
|
||||
relayCharacteristic = null
|
||||
seq = 1
|
||||
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onServicesDiscovered(gatt: android.bluetooth.BluetoothGatt, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w("BleRelayManager", "Service discovery failed (status=$status)")
|
||||
callbacks.onStatusChanged(BleStatus.ServiceError)
|
||||
return
|
||||
}
|
||||
Log.d("BleRelayManager", "Services discovered, requesting MTU...")
|
||||
gatt.requestMtu(256)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
|
||||
Log.d("BleRelayManager", "MTU changed to $mtu")
|
||||
val service = gatt.getService(SERVICE_UUID)
|
||||
if (service != null) {
|
||||
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
|
||||
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
|
||||
if (relayCharacteristic != null && uuidChar != null) {
|
||||
Log.d("BleRelayManager", "Reading device UUID characteristic...")
|
||||
gatt.readCharacteristic(uuidChar)
|
||||
} else {
|
||||
Log.e("BleRelayManager", "Service or characteristics not found")
|
||||
callbacks.onStatusChanged(BleStatus.ServiceNotFound)
|
||||
}
|
||||
} else {
|
||||
Log.e("BleRelayManager", "Service not found")
|
||||
callbacks.onStatusChanged(BleStatus.ServiceNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onCharacteristicRead(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.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("BleRelayManager", "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)
|
||||
}
|
||||
callbacks.onStatusChanged(BleStatus.Connected)
|
||||
} else {
|
||||
Log.w("BleRelayManager", "UUID mismatch -- disconnecting")
|
||||
triedAddresses.add(gatt.device.address)
|
||||
callbacks.onStatusChanged(BleStatus.WrongDevice)
|
||||
gatt.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onCharacteristicWrite(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w("BleRelayManager", "Write failed (status=$status)")
|
||||
callbacks.onStatusChanged(BleStatus.CommError)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build verificatie**
|
||||
|
||||
Run: `./gradlew assembleDebug`
|
||||
Workdir: `android/`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
|
||||
git commit -m "feat: add BleRelayManager GATT callback (connect, UUID verify, notifications)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: BleRelayManager — sendRelayCommand + disconnect
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt`
|
||||
|
||||
- [ ] **Step 1: Implementeer sendRelayCommand en disconnect**
|
||||
|
||||
Vervang de stubs voor `sendRelayCommand` en `disconnect`:
|
||||
```kotlin
|
||||
@SuppressLint("MissingPermission")
|
||||
fun sendRelayCommand(cmd: Byte) {
|
||||
val char = relayCharacteristic ?: return
|
||||
val gatt = bluetoothGatt ?: return
|
||||
val key = hmacKey ?: return
|
||||
|
||||
val packet = buildPacket(cmd, key)
|
||||
char.value = packet
|
||||
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
gatt.writeCharacteristic(char)
|
||||
seq++
|
||||
if (seq > PrefsHelper.SEQ_MAX) seq = 1
|
||||
PrefsHelper.getPrefs(context).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun disconnect() {
|
||||
handler.removeCallbacks(scanRunnable)
|
||||
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
|
||||
stopScan()
|
||||
bluetoothGatt?.disconnect()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build verificatie**
|
||||
|
||||
Run: `./gradlew assembleDebug`
|
||||
Workdir: `android/`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
|
||||
git commit -m "feat: add BleRelayManager sendRelayCommand and disconnect"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Refactor MainActivity — gebruik BleRelayManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt`
|
||||
|
||||
- [ ] **Step 1: Herschrijf MainActivity.kt**
|
||||
|
||||
Vervang de volledige file content:
|
||||
|
||||
```kotlin
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
class MainActivity : AppCompatActivity(), BleCallbacks {
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_SETTINGS = 1
|
||||
private const val POST_SETTINGS_RESCAN_MS = 500L
|
||||
private const val ON_RESUME_SCAN_DELAY_MS = 300L
|
||||
}
|
||||
|
||||
private lateinit var manager: BleRelayManager
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var relay1Button: Button
|
||||
private lateinit var relay2Button: Button
|
||||
private var lastTheme: String? = null
|
||||
|
||||
override fun onStatusChanged(status: BleStatus) {
|
||||
when (status) {
|
||||
BleStatus.Connected -> {
|
||||
statusText.text = getString(R.string.status_connected)
|
||||
setupRelayButton()
|
||||
setupRelay2Button()
|
||||
relay1Button.isEnabled = true
|
||||
relay2Button.isEnabled = true
|
||||
}
|
||||
BleStatus.Disconnected -> {
|
||||
statusText.text = getString(R.string.status_connecting)
|
||||
relay1Button.isEnabled = false
|
||||
relay2Button.isEnabled = false
|
||||
}
|
||||
BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning)
|
||||
BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting)
|
||||
BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key)
|
||||
BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device)
|
||||
BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found)
|
||||
BleStatus.ServiceError -> statusText.text = getString(R.string.status_service_error)
|
||||
BleStatus.CommError -> statusText.text = getString(R.string.status_comm_error)
|
||||
BleStatus.BluetoothDisabled -> statusText.text = getString(R.string.status_bluetooth_disabled)
|
||||
BleStatus.PermissionsDenied -> {
|
||||
statusText.text = getString(R.string.status_permissions_denied)
|
||||
relay1Button.isEnabled = false
|
||||
relay2Button.isEnabled = false
|
||||
}
|
||||
BleStatus.DeviceNotFound -> {
|
||||
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()
|
||||
manager.startScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
|
||||
PrefsHelper.applyTheme(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
manager = BleRelayManager(this, this)
|
||||
|
||||
statusText = findViewById(R.id.statusText)
|
||||
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")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) {
|
||||
manager.disconnect()
|
||||
manager.resetTriedAddresses()
|
||||
manager.updateKeys()
|
||||
handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
|
||||
if (current != lastTheme) {
|
||||
lastTheme = current
|
||||
recreate()
|
||||
return
|
||||
}
|
||||
if (!manager.hasPermissions()) {
|
||||
requestPermissions()
|
||||
return
|
||||
}
|
||||
manager.resetTriedAddresses()
|
||||
handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
manager.stopScan()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 1) {
|
||||
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
|
||||
manager.startScan()
|
||||
} else {
|
||||
onStatusChanged(BleStatus.PermissionsDenied)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRelayButton() {
|
||||
relay1Button.setOnClickListener(null)
|
||||
relay1Button.text = getString(R.string.button_hold)
|
||||
relay1Button.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
manager.sendRelayCommand(0x01.toByte())
|
||||
relay1Button.text = getString(R.string.button_on)
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
manager.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 -> {
|
||||
manager.sendRelayCommand(0x02.toByte())
|
||||
relay2Button.text = getString(R.string.button_on)
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
manager.sendRelayCommand(0x03.toByte())
|
||||
relay2Button.text = getString(R.string.button_hold)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
val perms = manager.getRequiredPermissions()
|
||||
ActivityCompat.requestPermissions(this, perms, 1)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
manager.destroy()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build verificatie**
|
||||
|
||||
Run: `./gradlew assembleDebug`
|
||||
Workdir: `android/`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
|
||||
git commit -m "refactor: extract BLE logic from MainActivity to BleRelayManager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Functionele verificatie — deploy en test
|
||||
|
||||
- [ ] **Step 1: Deploy APK naar telefoon**
|
||||
|
||||
Gebruik de adb-wireless skill om te verbinden, dan:
|
||||
```bash
|
||||
adb -s 192.168.1.43:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test BLE scan, connect, UUID verify**
|
||||
|
||||
Start de app. Verifieer:
|
||||
- Status: "Scannen..." -> "Verbinden..." -> "Verbonden"
|
||||
- Relay 1 en 2 buttons enabled
|
||||
- HMAC key en UUID correct geladen uit PrefsHelper
|
||||
|
||||
- [ ] **Step 3: Test relay knoppen**
|
||||
|
||||
- Relay 1 indrukken: relais 1 aan, button tekst "AAN"
|
||||
- Relay 1 loslaten: relais 1 uit, button tekst "INGEDRUKT HOUDEN"
|
||||
- Relay 2 indrukken: relais 2 aan, button tekst "AAN"
|
||||
- Relay 2 loslaten: relais 2 uit, button tekst "INGEDRUKT HOUDEN"
|
||||
- Snel ON->OFF cyclen: geen vastlopers
|
||||
|
||||
- [ ] **Step 4: Test disconnect + auto-reconnect**
|
||||
|
||||
- ESP uitzetten, verifieer "Verbinden...", zet ESP aan, verifieer "Verbonden"
|
||||
|
||||
- [ ] **Step 5: Test settings key exchange**
|
||||
|
||||
- Wis keys in app, open Settings, doe key exchange
|
||||
- Verifieer dat app opnieuw scant en verbindt
|
||||
|
||||
- [ ] **Step 6: Test scan timeout**
|
||||
|
||||
- ESP uitzetten tijdens scan, verifieer na ~90s: timeout + retry knop
|
||||
- Druk "Opnieuw scannen", verifieer scan herstart
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# ESP32-PICO-D4 (M5Stack Atom Lite) Port — Design Spec
|
||||
|
||||
> **Goal:** Port the Sylvia Touch Relay project from ESP32-S3-Pico (Atom VoiceS3R) to ESP32-PICO-D4 (Atom Lite), update the Android app for CDC-class USB detection, compile, bundle, and deploy via ADB wireless.
|
||||
|
||||
## Current State
|
||||
|
||||
The project targets the M5Stack Atom VoiceS3R with ESP32-S3-PICO-1-N8R8 (8MB Flash, 8MB PSRAM). The Android app filters USB devices by Espressif vendor ID (`0x303A`) for key exchange and firmware flashing. The `esp32-flash-lib` handles DTR/RTS via the `SerialTransport` interface implemented by `UsbSerialTransport`.
|
||||
|
||||
## Target Hardware
|
||||
|
||||
M5Stack Atom Lite with ESP32-PICO-D4:
|
||||
- 4MB Flash (no PSRAM)
|
||||
- FTDI FT2232C/D USB-to-UART bridge (VID `0x0403`) — appears as `/dev/ttyUSB0`
|
||||
- SK6812 addressable RGB LED on GPIO27
|
||||
- Relay on GPIO32 (G32)
|
||||
- Touch sensor on GPIO33 (G33) — open-drain type, needs pullup
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Side Branch & Git Worktree
|
||||
|
||||
Create branch `esp32picod4` via git worktree at `.worktrees/esp32picod4` to isolate the port from the main S3 variant.
|
||||
|
||||
### 2. Firmware (`platformio.ini` + `src/main.cpp`)
|
||||
|
||||
**New PlatformIO environment `m5stack-atom`:**
|
||||
|
||||
| Setting | Old (S3) | New (PICO-D4) | Reason |
|
||||
|---------|----------|---------------|--------|
|
||||
| `board` | `esp32-s3-devkitc-1` | `m5stack-atom` | PlatformIO board ID for M5Stack Atom Lite |
|
||||
| `board_build.arduino.memory_type` | `qio_opi` | removed | Not needed for PICO-D4 |
|
||||
| `-DESP32S3` | present | `-DESP32` | Correct chip family |
|
||||
| `-DBOARD_HAS_PSRAM` | present | removed | Atom Lite has no PSRAM |
|
||||
| `-mfix-esp32-psram-cache-issue` | present | removed | Only needed with PSRAM |
|
||||
| `-DARDUINO_USB_CDC_ON_BOOT=1` | present | removed | No native USB on PICO-D4 |
|
||||
| `-DARDUINO_USB_MODE=1` | present | removed | No native USB |
|
||||
| `upload_port` | `/dev/ttyACM0` | `/dev/ttyUSB0` | FTDI serial device |
|
||||
| `lib_deps` | (empty) | `fastled/FastLED@^3.7` | SK6812 Neopixel library |
|
||||
|
||||
**Pin definitions:**
|
||||
|
||||
| Constant | Old | New | Notes |
|
||||
|----------|-----|-----|-------|
|
||||
| `SENSOR_PIN` | 38 | 33 | G33, open-drain, `INPUT_PULLUP` unchanged |
|
||||
| `RELAY_PIN` | 1 | 32 | G32 |
|
||||
| `LED_PIN` | 27 | 27 | SK6812 Neopixel — replaces `digitalWrite` with FastLED API |
|
||||
|
||||
**BLE device name:** `"SyldaServer"` (was `"AtomS3R-Relay"`)
|
||||
|
||||
**Serial banner:** `"Atom Lite BLE Relay"` (was `"Atom VoiceS3R BLE Relay"`)
|
||||
|
||||
**LED code change:** Replace `digitalWrite(LED_PIN, HIGH/LOW)` with FastLED:
|
||||
- Include `FastLED.h`
|
||||
- Define `#define NUM_LEDS 1`
|
||||
- `CRGB leds[NUM_LEDS]`
|
||||
- `FastLED.addLeds<SK6812, LED_PIN, GRB>(leds, NUM_LEDS)` in setup
|
||||
- Startup blink: 3x green (`leds[0] = CRGB::Green; FastLED.show()`)
|
||||
|
||||
No other firmware logic changes — HMAC key exchange, BLE relay control, touch sensor logic remain identical.
|
||||
|
||||
### 3. Android USB Detection
|
||||
|
||||
**Problem:** The Atom Lite's FTDI bridge presents VID `0x0403`, not Espressif's `0x303A`. The app currently filters on VID and won't detect the device.
|
||||
|
||||
**Solution:** Replace VID (`0x303A`) filtering with CDC-ACM class filtering (interface class `0x0A`).
|
||||
|
||||
**Changes in `UsbUtils.kt`:**
|
||||
- Add `hasCdcDataInterface(device: UsbDevice): Boolean` — iterates interfaces, returns true if any has `interfaceClass == 0x0A`
|
||||
|
||||
**Changes in `SettingsActivity.kt`:**
|
||||
- Remove `ESP_VENDOR_ID` constant
|
||||
- Replace all `device?.vendorId == ESP_VENDOR_ID` checks with `UsbUtils.hasCdcDataInterface(device)`
|
||||
|
||||
This detects all CDC-ACM serial devices including:
|
||||
- FTDI FT2232C/D (Atom Lite)
|
||||
- CP210x (some Atom Lite revisions)
|
||||
- ESP32-S3 native USB (current hardware)
|
||||
- CH340/CH9102 (other Atom variants)
|
||||
|
||||
### 4. DTR/RTS — No Changes Needed
|
||||
|
||||
**Research findings:**
|
||||
|
||||
The `esp32-flash-lib`'s `EspFlasherProtocol.enterBootLoader()` implements the standard DTR/RTS download-mode sequence:
|
||||
|
||||
```
|
||||
setControlLines(true, false) // DTR=High → EN released, RTS=Low → GPIO0=Low (download mode)
|
||||
delayMS(100)
|
||||
setControlLines(false, true) // DTR=Low → reset pulse via capacitor, RTS=High → GPIO0=High
|
||||
delayMS(100)
|
||||
setControlLines(true, false) // DTR=High → release reset, RTS=Low → GPIO0=Low → boot in download mode
|
||||
```
|
||||
|
||||
The `UsbSerialTransport.setControlLines()` implementation sends `CDC_SET_CONTROL_LINE_STATE` control transfers with correct DTR/RTS bit flags. This works for FTDI, CP2104, and native USB CDC-ACM.
|
||||
|
||||
The `SettingsActivity.setupCdcControl()` sets DTR|RTS both HIGH for normal serial operation (key exchange), which is correct for all bridge types.
|
||||
|
||||
**Verdict: No changes to `EspFlasher.kt`, `UsbSerialTransport.kt`, or key exchange logic.**
|
||||
|
||||
### 5. Build, Bundle & Deploy
|
||||
|
||||
1. `pio run -e m5stack-atom` to compile firmware
|
||||
2. `tools/copy_firmware.sh` to copy binaries to `android/app/src/main/assets/firmware/` (environment dir is now `m5stack-atom`)
|
||||
3. `./gradlew assembleDebug` in `android/` to build APK
|
||||
4. ADB wireless to connect phone (`adb-wireless` skill)
|
||||
5. `adb install -r app-debug.apk` to deploy
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `platformio.ini` | New environment `m5stack-atom` replacing S3 config |
|
||||
| `src/main.cpp` | New pin definitions, BLE name, FastLED LED code |
|
||||
| `android/.../UsbUtils.kt` | Add `hasCdcDataInterface()` |
|
||||
| `android/.../SettingsActivity.kt` | VID filter → CDC class filter |
|
||||
| `tools/copy_firmware.sh` | Environment name `m5stack-atoms3r` → `m5stack-atom` |
|
||||
|
||||
## Files Not Changed
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `EspFlasher.kt` | DTR/RTS handled by library |
|
||||
| `UsbSerialTransport.kt` | Correct implementation |
|
||||
| `MainActivity.kt` | No BLE changes beyond device name (auto-discovered) |
|
||||
| `PrefsHelper.kt` | No changes |
|
||||
| `AndroidManifest.xml` | No changes |
|
||||
| `build.gradle` | No dependency changes |
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
# BLE Refactor: BleRelayManager extractie
|
||||
|
||||
**Datum:** 2026-07-29
|
||||
**Status:** goedgekeurd
|
||||
|
||||
## Doel
|
||||
|
||||
Extractie van alle BLE-logica uit `MainActivity.kt` naar een aparte `BleRelayManager` klasse. MainActivity wordt verantwoordelijk voor UI en lifecycle only.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In:** BLE scan, GATT connectie, UUID verificatie, HMAC relay commands, key/seq management, auto-retry logica
|
||||
- **Uit:** UI binding (views, touch listeners, string resources), lifecycle (onCreate/onResume/etc), permissions result handling, theme switching
|
||||
- **Niet aangeraakt:** `PrefsHelper`, `SettingsActivity`, `EspFlasher`, `UsbSerialTransport`, firmware
|
||||
|
||||
## Nieuwe bestanden
|
||||
|
||||
### `BleStatus.kt` — enum + callback interface
|
||||
|
||||
```kotlin
|
||||
enum class BleStatus {
|
||||
Scanning,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnected,
|
||||
NoKey,
|
||||
WrongDevice,
|
||||
ServiceNotFound,
|
||||
ServiceError,
|
||||
CommError,
|
||||
DeviceNotFound,
|
||||
BluetoothDisabled,
|
||||
PermissionsDenied
|
||||
}
|
||||
|
||||
interface BleCallbacks {
|
||||
fun onStatusChanged(status: BleStatus)
|
||||
}
|
||||
```
|
||||
|
||||
### `BleRelayManager.kt` — publieke API
|
||||
|
||||
```kotlin
|
||||
class BleRelayManager(
|
||||
private val context: Context,
|
||||
private val callbacks: BleCallbacks
|
||||
) {
|
||||
fun startScan()
|
||||
fun stopScan()
|
||||
fun sendRelayCommand(cmd: Byte)
|
||||
fun disconnect()
|
||||
fun updateKeys() // herlaadt keys na settings key exchange
|
||||
fun resetTriedAddresses() // wist triedAddresses (bij onResume)
|
||||
fun hasPermissions(): Boolean
|
||||
fun getRequiredPermissions(): Array<String>
|
||||
fun destroy() // cleanup handler, stop scan, close gatt
|
||||
}
|
||||
```
|
||||
|
||||
### Interne componenten (private)
|
||||
|
||||
1. **State:** `bluetoothGatt`, `relayCharacteristic`, `hmacKey`, `deviceUuid`, `seq`, `triedAddresses`, `isScanning`, `scanTimedOut`, `scanTimeoutRetries`
|
||||
2. **Handler:** `Handler(Looper.getMainLooper())` voor scan scheduling en timeouts
|
||||
3. **ScanCallback:** scan op `SERVICE_UUID`, skip `triedAddresses`, bij match → `stopScan()` + `connectToDevice()`
|
||||
4. **GattCallback:** connectie → check key → discoverServices → requestMtu → read UUID char → verify → enable notifications. Bij disconnect: auto-retry na delay.
|
||||
5. **HMAC:** `hexStringToBytes()`, `hmacSha256()`, `buildPacket()` — ongewijzigd verplaatst
|
||||
6. **Key loading:** `loadState()` leest via `PrefsHelper.getPrefs(context)`
|
||||
|
||||
**Companion constants** (ongewijzigd): `SERVICE_UUID`, `CHARACTERISTIC_UUID`, `UUID_CHAR_UUID`, `CCCD_UUID`, `DEVICE_NAME`, `HMAC_PKT_SIZE`, `HMAC_LEN`, `SCAN_TIMEOUT_MS`, `RECONNECT_DELAY_MS`, `MAX_SCAN_RETRIES`
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MA as MainActivity
|
||||
participant BRM as BleRelayManager
|
||||
participant ESP as ESP32-S3
|
||||
|
||||
MA->>BRM: startScan()
|
||||
BRM->>BRM: loadState() via PrefsHelper
|
||||
BRM-->>MA: onStatusChanged(Scanning)
|
||||
BRM->>ESP: BLE scan (filter SERVICE_UUID)
|
||||
ESP-->>BRM: scan result
|
||||
BRM->>BRM: skip triedAddresses?
|
||||
BRM->>ESP: connectGatt()
|
||||
BRM-->>MA: onStatusChanged(Connecting)
|
||||
ESP-->>BRM: STATE_CONNECTED
|
||||
BRM->>BRM: check hmacKey != null
|
||||
BRM->>ESP: discoverServices → requestMtu(256)
|
||||
BRM->>ESP: readCharacteristic(UUID_CHAR)
|
||||
ESP-->>BRM: UUID value
|
||||
BRM->>BRM: verify against stored deviceUuid
|
||||
BRM->>ESP: setCharacteristicNotification + writeDescriptor(CCCD)
|
||||
BRM-->>MA: onStatusChanged(Connected)
|
||||
|
||||
MA->>BRM: sendRelayCommand(0x01)
|
||||
BRM->>BRM: buildPacket(cmd, hmacKey, seq)
|
||||
BRM->>ESP: writeCharacteristic (NO_RESPONSE)
|
||||
BRM->>Prefs: save seq
|
||||
|
||||
ESP-->>BRM: STATE_DISCONNECTED
|
||||
BRM-->>MA: onStatusChanged(Disconnected)
|
||||
BRM->>BRM: handler.postDelayed(startScan, 2s)
|
||||
```
|
||||
|
||||
## MainActivity — wat blijft
|
||||
|
||||
| Component | Blijft in MainActivity |
|
||||
|---|---|
|
||||
| `onCreate` | View binding, settings button, `BleRelayManager` instantiatie |
|
||||
| `onResume` | Theme check, `manager.resetTriedAddresses()`, `manager.startScan()` of toon NoKey |
|
||||
| `onPause` | `manager.stopScan()` |
|
||||
| `onDestroy` | `manager.destroy()` |
|
||||
| `onActivityResult` | Bij RESULT_SCAN: `manager.disconnect()`, `manager.updateKeys()`, `manager.resetTriedAddresses()`, `manager.startScan()` |
|
||||
| `onRequestPermissionsResult` | Bij granted: `manager.startScan()`; bij denied: UI update |
|
||||
| `setupRelayButton()` | Touch listener → `manager.sendRelayCommand(...)` |
|
||||
| `setupRelay2Button()` | Touch listener → `manager.sendRelayCommand(...)` |
|
||||
| Status mapping | `onStatusChanged` → `statusText.text = getString(R.string.status_*)` + knop enabled/disabled |
|
||||
|
||||
### Status → UI mapping
|
||||
|
||||
```kotlin
|
||||
callbacks = object : BleCallbacks {
|
||||
override fun onStatusChanged(status: BleStatus) {
|
||||
when (status) {
|
||||
BleStatus.Connected -> {
|
||||
statusText.text = getString(R.string.status_connected)
|
||||
setupRelayButton()
|
||||
setupRelay2Button()
|
||||
relay1Button.isEnabled = true
|
||||
relay2Button.isEnabled = true
|
||||
}
|
||||
BleStatus.Disconnected -> {
|
||||
statusText.text = getString(R.string.status_connecting)
|
||||
relay1Button.isEnabled = false
|
||||
relay2Button.isEnabled = false
|
||||
}
|
||||
BleStatus.DeviceNotFound -> {
|
||||
// retry knop op relay1Button (huidig gedrag behouden)
|
||||
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()
|
||||
manager.startScan()
|
||||
}
|
||||
}
|
||||
else -> statusText.text = getString(R.string.status_scanning) // fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Risico's & mitigatie
|
||||
|
||||
| Risico | Mitigatie |
|
||||
|---|---|
|
||||
| Race condition bij snelle key-update + scan | `updateKeys()` stopt scan vóór herladen |
|
||||
| Context leak via Handler | `destroy()` roept `handler.removeCallbacksAndMessages(null)` |
|
||||
| GattCallback refs naar oude Activity | Manager instantieert per Activity, geen singleton |
|
||||
| Touch listeners verwijzen naar verouderde manager | `setupRelay*Button()` opnieuw aanroepen bij statuswissel (huidig patroon) |
|
||||
|
||||
## Verificatie
|
||||
|
||||
1. Build: `./gradlew assembleDebug` moet slagen
|
||||
2. Lint: `./gradlew lint` geen nieuwe warnings
|
||||
3. Functioneel: APK deployen via ADB, BLE scan → connect → UUID verify → relay knoppen ON/OFF
|
||||
4. Randgevallen: app herstarten (keys behouden), settings key exchange → rescan, timeout + retry, disconnect + auto-reconnect, permissie deny/accept
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# Device UUID Pairing — Design
|
||||
|
||||
**Datum:** 2026-07-29
|
||||
**Status:** goedgekeurd
|
||||
|
||||
## Doel
|
||||
|
||||
Voorkomen dat twee ESP-telefoon paren in dezelfde ruimte met elkaars ESP verbinden. Elk ESP-telefoon paar deelt een unieke UUID die tijdens key exchange wordt uitgewisseld.
|
||||
|
||||
## Flow
|
||||
|
||||
### Verse ESP (geen UUID, geen HMAC key)
|
||||
|
||||
1. ESP boot, genereert random UUID (16 bytes via `esp_random()`), slaat op in NVS
|
||||
2. ESP start BLE advertising als `"SylDa_Server"` (UUID nog onbekend bij app)
|
||||
3. App opent Settings → Key Exchange
|
||||
4. App stuurt `KEY?` over USB CDC, ESP antwoordt `NO_KEY`
|
||||
5. App genereert random 32-byte HMAC key + 16-byte UUID
|
||||
6. App stuurt `SET_KEY:<64 hex>` → ESP slaat op → `OK`
|
||||
7. App stuurt `SET_ID:<32 hex>` → ESP slaat op → `OK`
|
||||
8. App slaat beide op in EncryptedSharedPreferences
|
||||
9. App keert terug naar main, start scan
|
||||
|
||||
### Bestaand paar (beide hebben UUID + HMAC key)
|
||||
|
||||
1. App stuurt `KEY?`, ESP antwoordt `DEVICE_ID:<32 hex>\nHMAC_KEY:<64 hex>`
|
||||
2. App slaat beide op
|
||||
3. Klaar
|
||||
|
||||
### BLE connectie
|
||||
|
||||
1. App scant op BLE service UUID `4fafc201-...` (niet op naam)
|
||||
2. Bij elke gevonden device: connect, discover services
|
||||
3. Lees UUID characteristic (READ-only)
|
||||
4. UUID match → zet relay knoppen actief
|
||||
5. UUID mismatch → disconnect, voeg address toe aan `triedAddresses`, herstart scan
|
||||
6. Relay commands blijven HMAC-beveiligd zoals voorheen
|
||||
|
||||
## Componenten
|
||||
|
||||
### Firmware (`src/main.cpp`)
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| NVS key `"uuid"` | 16 bytes, namespace `"hmac"` |
|
||||
| UUID generatie | `esp_random()` per byte, alleen bij eerste boot |
|
||||
| BLE naam | `"SylDa_Server"` |
|
||||
| UUID characteristic | Nieuwe READ-only characteristic, los UUID |
|
||||
| `KEY?` response | `DEVICE_ID:32HEX\nHMAC_KEY:64HEX` of `NO_KEY` |
|
||||
| `SET_ID:32HEX` | Nieuw commando, slaat UUID in NVS |
|
||||
|
||||
### Android — PrefsHelper
|
||||
|
||||
- `PREF_UUID = "device_uuid"` (EncryptedSharedPreferences)
|
||||
|
||||
### Android — SettingsActivity
|
||||
|
||||
- `readKeyFromEsp()`: parse `DEVICE_ID:` lijn
|
||||
- `KeyResult.Success` krijgt `deviceUuid` veld
|
||||
- Bij `NO_KEY`: app genereert UUID + stuurt `SET_KEY` en `SET_ID`
|
||||
|
||||
### Android — MainActivity
|
||||
|
||||
- `loadState()`: laadt UUID
|
||||
- `startScan()`: `ScanFilter` op service UUID
|
||||
- `onScanResult`: connect bij elke match (naam genegeerd)
|
||||
- `gattCallback.onMtuChanged`: lees UUID characteristic, verify
|
||||
- UUID mismatch: disconnect, `triedAddresses.add(address)`, herstart scan
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
# User-facing text — Atom Relay
|
||||
|
||||
## MainActivity — statusbalk
|
||||
| Huidige tekst | strings.xml key |
|
||||
|---|---|
|
||||
| Connected | `status_connected` |
|
||||
| Verbinden… | `status_connecting` |
|
||||
| Servicefout | `status_service_error` |
|
||||
| Service not found | `status_service_not_found` |
|
||||
| Communicatiefout | `status_comm_error` |
|
||||
| Geen HMAC sleutel - open Instellingen | `status_no_key` |
|
||||
| Permissies geweigerd — open Instellingen | `status_permissions_denied` |
|
||||
| Bluetooth not enabled | `status_bluetooth_disabled` |
|
||||
| Scanning… | `status_scanning` |
|
||||
| Device not found | `status_device_not_found` |
|
||||
| Verkeerd apparaat — opnieuw scannen… | `status_wrong_device` |
|
||||
| Verbonden, services zoeken… | `status_discovering_services` |
|
||||
|
||||
## MainActivity — knoppen
|
||||
| Huidige tekst | strings.xml key |
|
||||
|---|---|
|
||||
| Instellingen (contentDescription) | `cd_settings` |
|
||||
| Disconnected (placeholder) | `status_default` |
|
||||
| HOLD (fallback knoplabel) | `button_hold` |
|
||||
| Opnieuw scannen (fallback) | `button_retry_scan` |
|
||||
| User-defined labels 1 & 2 | PrefsHelper (user input) |
|
||||
|
||||
## SettingsActivity — UI
|
||||
| Huidige tekst | strings.xml key |
|
||||
|---|---|
|
||||
| Sluit de ESP aan op USB en kies een actie. | `msg_settings_intro` |
|
||||
| Sleutel Uitwisselen | `button_key_exchange` |
|
||||
| Zet de ESP in download-modus (houd reset 2s in tot de groene LED aangaat) en tik op Firmware Flashen. | `msg_flash_mode_help` |
|
||||
| Firmware Flashen | `button_firmware_flash` |
|
||||
| Knoptekst | `section_labels` |
|
||||
| Relay 1 | `hint_label_relay1` |
|
||||
| Relay 2 | `hint_label_relay2` |
|
||||
| Weergave | `section_theme` |
|
||||
| Systeem | `theme_system` |
|
||||
| Licht | `theme_light` |
|
||||
| Donker | `theme_dark` |
|
||||
| Thema: %s | `theme_applied` |
|
||||
| Buildnummer: %s | `build_number` |
|
||||
|
||||
## SettingsActivity — dialogs / toasts
|
||||
| Huidige tekst | strings.xml key |
|
||||
|---|---|
|
||||
| Sleutel Uitwisselen | `title_key_exchange` |
|
||||
| Sluit de ESP aan via USB. | `msg_connect_usb` |
|
||||
| Sleutel uitlezen… | `msg_reading_key` |
|
||||
| Beveiligingssleutel gelezen | `key_read_success` |
|
||||
| Sleutel uitwisselen mislukt - probeer opnieuw | `key_exchange_failed` |
|
||||
| USB-toegang geweigerd | `usb_access_denied` |
|
||||
| Annuleren | `button_cancel` |
|
||||
| Sluiten | `button_close` |
|
||||
| Firmware Flashen | `title_firmware_flash` |
|
||||
| Zet de ESP in download-modus (houd reset 2s in tot groene LED) en sluit aan via USB. | `msg_flash_download_mode` |
|
||||
| Initialiseren… | `msg_initializing` |
|
||||
| Firmware geflashed!\\n\\nOntkoppel de ESP en sluit opnieuw aan om de sleutel in te stellen. | `msg_flash_complete` |
|
||||
| Flashen mislukt: %s | `flash_failed` |
|
||||
| Sleutels komen niet overeen | `title_key_conflict` |
|
||||
| De ESP (SylDa_Server) heeft andere sleutels dan deze app. Wat wil je doen? | `msg_key_conflict` |
|
||||
| ESP sleutels overnemen | `button_keep_esp_keys` |
|
||||
| App sleutels overzetten | `button_overwrite_esp` |
|
||||
| ESP sleutels overschrijven? | `title_overwrite_confirm` |
|
||||
| Dit vervangt de sleutels van de ESP. De ESP kan daarna niet meer met de oorspronkelijke app communiceren. | `msg_overwrite_warning` |
|
||||
| Ik weet het zeker | `checkbox_confirm_overwrite` |
|
||||
| Bevestigen | `button_confirm` |
|
||||
| Sleutels overschrijven | `title_overwriting_keys` |
|
||||
| Sleutels schrijven naar ESP… | `msg_writing_keys` |
|
||||
|
||||
## SettingsActivity — foutmeldingen (hardcoded in .kt)
|
||||
| Huidige tekst | strings.xml key |
|
||||
|---|---|
|
||||
| Geen CDC interface gevonden | `err_no_cdc` |
|
||||
| Kan USB apparaat niet openen | `err_usb_open` |
|
||||
| Kan USB interface niet claimen | `err_usb_claim` |
|
||||
| Geen USB OUT endpoint gevonden | `err_no_out` |
|
||||
| Geen USB IN endpoint gevonden | `err_no_in` |
|
||||
| Ongeldig sleutel/UUID formaat | `err_invalid_key` |
|
||||
| Geen sleutel gevonden na 5 pogingen | `err_key_retries` |
|
||||
| Fout: %s | `err_generic` |
|
||||
| SET_KEY schrijven mislukt | `err_set_key_write` |
|
||||
| SET_ID schrijven mislukt | `err_set_id_write` |
|
||||
| ESP bevestigde sleutel niet | `err_key_not_ack` |
|
||||
| ESP bevestigde UUID niet | `err_uuid_not_ack` |
|
||||
|
||||
## EspFlasher — hardcoded in .kt
|
||||
| Huidige tekst | strings.xml key |
|
||||
|---|---|
|
||||
| Kan USB-apparaat niet openen | `err_usb_open` (reuse) |
|
||||
| Geen CDC data interface gevonden | `err_no_cdc` (reuse) |
|
||||
| Kan USB interface niet claimen | `err_usb_claim` (reuse) |
|
||||
| Geen USB OUT endpoint gevonden | `err_no_out` (reuse) |
|
||||
| Geen USB IN endpoint gevonden | `err_no_in` (reuse) |
|
||||
| Verbinden met ESP bootloader... | `flash_connecting` |
|
||||
| Bootloader | `flash_step_bootloader` |
|
||||
| Partitie tabel | `flash_step_partitions` |
|
||||
| Boot app0 | `flash_step_boot_app0` |
|
||||
| Firmware | `flash_step_firmware` |
|
||||
| Sync met bootloader... | `flash_sync` |
|
||||
| Chip detecteren... | `flash_detect` |
|
||||
| Chip: %s | `flash_chip` |
|
||||
| Stub laden... | `flash_stub` |
|
||||
| Firmware geflashed! | `flash_done` |
|
||||
|
||||
## AndroidManifest
|
||||
| Huidige tekst | strings.xml key |
|
||||
|---|---|
|
||||
| Atom Relay | `app_name` |
|
||||
| Instellingen | `settings` |
|
||||
Loading…
Reference in a new issue