diff --git a/.gitignore b/.gitignore index 0856c12..de6c73b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ local.properties *.ap_ *.dex +# Android signing (contains secrets) +android/keystore/ +android/signing.properties + # OS .DS_Store Thumbs.db @@ -38,5 +42,4 @@ appstore.md codereview.md DC3-24V*.md DC3-24V*.pdf -esplibalternatief.md -docs/ \ No newline at end of file +esplibalternatief.md \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 71f9cf9..4331f86 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -3,6 +3,9 @@ plugins { id 'org.jetbrains.kotlin.android' } +def signingPropsFile = rootProject.file('signing.properties') +def hasSigningConfig = signingPropsFile.exists() + android { namespace 'nl.eddydevink.sylda' // compileSdk/targetSdk stays on 34 (Android 14) by deliberate choice. The target audience @@ -10,6 +13,20 @@ android { // on API 34 maximises device compatibility. Re-evaluate when the user base has migrated. compileSdk 34 + if (hasSigningConfig) { + def props = new Properties() + props.load(new FileInputStream(signingPropsFile)) + + signingConfigs { + release { + storeFile rootProject.file(props['KEYSTORE_PATH']) + storePassword props['KEYSTORE_PASSWORD'] + keyAlias props['KEY_ALIAS'] + keyPassword props['KEY_PASSWORD'] + } + } + } + defaultConfig { applicationId "nl.eddydevink.sylda" minSdk 26 @@ -25,6 +42,9 @@ android { buildTypes { release { + if (hasSigningConfig) { + signingConfig signingConfigs.release + } minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt index da135a9..23e4f4e 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt @@ -10,6 +10,7 @@ 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 android.widget.Toast import androidx.appcompat.app.AppCompatActivity @@ -101,6 +102,9 @@ class MainActivity : AppCompatActivity(), BleCallbacks { settingsContainer.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) } + findViewById(R.id.settingsButton).setOnClickListener { + startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) + } setupRelayButton() setupRelay2Button() } diff --git a/docs/superpowers/plans/2025-07-28-esp32-pico-d4-port.md b/docs/superpowers/plans/2025-07-28-esp32-pico-d4-port.md new file mode 100644 index 0000000..ac323fb --- /dev/null +++ b/docs/superpowers/plans/2025-07-28-esp32-pico-d4-port.md @@ -0,0 +1,192 @@ +# 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 `: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 :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 :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). diff --git a/docs/superpowers/plans/2026-07-26-code-review-fixes.md b/docs/superpowers/plans/2026-07-26-code-review-fixes.md new file mode 100644 index 0000000..1ca8b30 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-code-review-fixes.md @@ -0,0 +1,499 @@ +# 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" +``` diff --git a/docs/superpowers/plans/2026-07-28-esp32-pico-d4-port.md b/docs/superpowers/plans/2026-07-28-esp32-pico-d4-port.md new file mode 100644 index 0000000..10f41c3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-esp32-pico-d4-port.md @@ -0,0 +1,499 @@ +# 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 + +#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(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: `: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)`. diff --git a/docs/superpowers/plans/2026-07-29-ble-refactor.md b/docs/superpowers/plans/2026-07-29-ble-refactor.md new file mode 100644 index 0000000..235b929 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-ble-refactor.md @@ -0,0 +1,776 @@ +# 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() + + fun startScan() {} + fun stopScan() {} + fun sendRelayCommand(cmd: Byte) {} + fun disconnect() {} + fun updateKeys() {} + fun resetTriedAddresses() {} + fun hasPermissions(): Boolean = false + fun getRequiredPermissions(): Array = 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 { + 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(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, 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 diff --git a/docs/superpowers/plans/2026-07-30-release-build.md b/docs/superpowers/plans/2026-07-30-release-build.md new file mode 100644 index 0000000..14e8214 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-release-build.md @@ -0,0 +1,552 @@ +# Release Build & Forgejo Publishing — 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:** Signed APK + AAB release builds, published as Forgejo release with firmware attachments. + +**Architecture:** A `signing.properties` file (gitignored) provides Gradle with keystore credentials. A `scripts/release.sh` script builds APK and AAB, collects firmware, tags git, and uploads everything to Forgejo via HTTP API. + +**Tech Stack:** Gradle 8.14, AGP 8.5.2, keytool, curl, Forgejo API v1 + +--- + +### Pre-check: BUILD_NUMBER timestamp + +`buildConfigField "String", "BUILD_NUMBER", ..."` staat in `defaultConfig` in `build.gradle`, maar `BUILD_NUMBER` wordt nergens in de Kotlin code gebruikt. Geen impact op release build reproduceerbaarheid. Het veld blijft bestaan (geen actie nodig). + +--- + +### Task 1: Genereer keystore en sla op in Obsidian vault + +**Files:** +- Create: `android/keystore/sylda-release.jks` (gitignored, later) +- Create: Obsidian vault notitie met credentials + +- [ ] **Step 1: Genereer keystore in workspace** + +Run vanaf de project root: + +```bash +keytool -genkey -v \ + -keystore android/keystore/sylda-release.jks \ + -alias sylda \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -storepass "SyldaRel34se2026!" \ + -keypass "SyldaRel34se2026!" \ + -dname "CN=Eddy Devink, OU=SylDa, O=SylDa, L=Unknown, ST=Unknown, C=NL" +``` + +Verify: + +```bash +keytool -list -v -keystore android/keystore/sylda-release.jks -storepass "SyldaRel34se2026!" | head -10 +``` + +Expected: Alias `sylda`, certificate details. + +- [ ] **Step 2: Bewaar in Obsidian vault** + +Maak notitie `Homelab/Project_SylDa/keystore.md` via `obsidian-vault` skill of directe CouchDB API met: + +``` +# SylDa Release Keystore + +- Path workspace: android/keystore/sylda-release.jks +- Alias: sylda +- Key password: SyldaRel34se2026! +- Store password: SyldaRel34se2026! +- Validity: 10000 days (tot ~2053) +- Keyalg: RSA 2048 + +## Keytool commando (hergeneratie) + +keytool -genkey -v -keystore android/keystore/sylda-release.jks -alias sylda -keyalg RSA -keysize 2048 -validity 10000 -storepass "SyldaRel34se2026!" -keypass "SyldaRel34se2026!" -dname "CN=Eddy Devink, OU=SylDa, O=SylDa, L=Unknown, ST=Unknown, C=NL" +``` + +- [ ] **Step 3: Commit (keystore wordt later gitignored in Task 4 — nu nog niet gecommit)** + +Geen git actie nog. Task 4 voegt gitignore toe. + +--- + +### Task 2: Maak signing.properties + +**Files:** +- Create: `android/signing.properties` (gitignored in Task 4) + +- [ ] **Step 1: Creëer signing.properties** + +Run: + +```bash +cat > android/signing.properties << 'EOF' +KEYSTORE_PATH=keystore/sylda-release.jks +KEYSTORE_PASSWORD=SyldaRel34se2026! +KEY_ALIAS=sylda +KEY_PASSWORD=SyldaRel34se2026! +EOF +``` + +- [ ] **Step 2: Verifieer bestand** + +```bash +cat android/signing.properties +``` + +Expected: 4 properties zoals hierboven. + +--- + +### Task 3: Update build.gradle met signing config + +**Files:** +- Modify: `android/app/build.gradle` (hele bestand overschrijven) + +- [ ] **Step 1: Voeg signing block toe** + +Huidig bestand (`android/app/build.gradle`): + +```groovy +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'nl.eddydevink.sylda' + compileSdk 34 + + defaultConfig { + applicationId "nl.eddydevink.sylda" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + buildConfigField "String", "BUILD_NUMBER", "\"${new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date())}\"" + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'io.github.dkaukov:esp32-flash-lib:1.1.17' + implementation 'com.eclipsesource.minimal-json:minimal-json:0.9.5' + implementation 'org.slf4j:slf4j-nop:2.0.16' + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' +} +``` + +Vervangen door (signing block toegevoegd na `buildTypes {}`): + +```groovy +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +def signingPropsFile = rootProject.file('signing.properties') +def hasSigningConfig = signingPropsFile.exists() + +android { + namespace 'nl.eddydevink.sylda' + compileSdk 34 + + if (hasSigningConfig) { + def props = new Properties() + props.load(new FileInputStream(signingPropsFile)) + + signingConfigs { + release { + storeFile rootProject.file(props['KEYSTORE_PATH']) + storePassword props['KEYSTORE_PASSWORD'] + keyAlias props['KEY_ALIAS'] + keyPassword props['KEY_PASSWORD'] + } + } + } + + defaultConfig { + applicationId "nl.eddydevink.sylda" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + buildConfigField "String", "BUILD_NUMBER", "\"${new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date())}\"" + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + if (hasSigningConfig) { + signingConfig signingConfigs.release + } + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'io.github.dkaukov:esp32-flash-lib:1.1.17' + implementation 'com.eclipsesource.minimal-json:minimal-json:0.9.5' + implementation 'org.slf4j:slf4j-nop:2.0.16' + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' +} +``` + +- [ ] **Step 2: Sync Gradle** + +```bash +./gradlew --no-daemon dependencies > /dev/null 2>&1 +echo $? +``` + +Expected: `0` (geen Gradle-fouten). + +- [ ] **Step 3: Commit** + +```bash +git add android/app/build.gradle +git commit -m "feat: add release signing config via signing.properties" +``` + +--- + +### Task 4: Update .gitignore + +**Files:** +- Modify: `.gitignore` (toevoegen van nieuwe ignore-regels) + +- [ ] **Step 1: Voeg keystore en signing.properties toe aan .gitignore** + +Toevoegen aan `.gitignore`, onder de Android/Gradle sectie: + +``` +# Android signing (contains secrets) +android/keystore/ +android/signing.properties +``` + +- [ ] **Step 2: Verifieer dat keystore nu gitignored is** + +```bash +git status android/keystore/ android/signing.properties +``` + +Expected: geen output (bestanden worden genegeerd). + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore +git commit -m "chore: gitignore keystore and signing.properties" +``` + +--- + +### Task 5: Maak release script + +**Files:** +- Create: `scripts/release.sh` + +- [ ] **Step 1: Creëer scripts/ director** + +```bash +mkdir -p scripts +``` + +- [ ] **Step 2: Schrijf release.sh** + +```bash +cat > scripts/release.sh << 'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +log() { echo -e "${GREEN}[release]${NC} $1"; } +err() { echo -e "${RED}[release]${NC} $1" >&2; } + +# ------------------------------------------------------------------ config +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ANDROID_DIR="$PROJECT_DIR/android" +APP_BUILD_GRADLE="$ANDROID_DIR/app/build.gradle" +SIGNING_PROPS="$ANDROID_DIR/signing.properties" +FORGEJO_URL="https://git.eddydevink.nl/api/v1/repos/eddy/Project_SylDa" +FIRMWARE_DIR="$ANDROID_DIR/app/src/main/assets/firmware" + +# lees versionName uit build.gradle +VERSION=$(grep -oP 'versionName\s+"\K[^"]+' "$APP_BUILD_GRADLE") +TAG="v$VERSION" +RELEASE_DIR="$PROJECT_DIR/release/$TAG" + +log "Release v$VERSION (tag $TAG)" + +# ------------------------------------------------------------------ checks +if [ ! -f "$SIGNING_PROPS" ]; then + err "signing.properties niet gevonden op $SIGNING_PROPS" + exit 1 +fi + +# ------------------------------------------------------------------ clean +log "Clean..." +cd "$ANDROID_DIR" +./gradlew clean + +# ------------------------------------------------------------------ build +log "Build APK + AAB..." +./gradlew assembleRelease bundleRelease + +# ------------------------------------------------------------------ verzamel artefacten +log "Verzamel artefacten in $RELEASE_DIR" +mkdir -p "$RELEASE_DIR" + +cp "$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" "$RELEASE_DIR/" +cp "$ANDROID_DIR/app/build/outputs/bundle/release/app-release.aab" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/bootloader.bin" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/partitions.bin" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/boot_app0.bin" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/firmware.bin" "$RELEASE_DIR/" + +# ------------------------------------------------------------------ checksums +log "Checksums..." +cd "$RELEASE_DIR" +sha256sum * > checksums.txt +cat checksums.txt + +# ------------------------------------------------------------------ git tag +cd "$PROJECT_DIR" +if git rev-parse "$TAG" >/dev/null 2>&1; then + log "Tag $TAG bestaat al, skip git tag." +else + log "Git tag $TAG..." + git tag "$TAG" + git push origin "$TAG" +fi + +# ------------------------------------------------------------------ Forgejo release +log "Forgejo release aanmaken..." +TOKEN_FILE="$HOME/.config/sylda/forgejo-token" +if [ ! -f "$TOKEN_FILE" ]; then + err "Forgejo token niet gevonden op $TOKEN_FILE" + err "Genereer een token in Forgejo: Settings > Applications > Generate Token (scope: write:repository)" + err "Sla op als: echo 'je-token' > $TOKEN_FILE" + exit 1 +fi +TOKEN=$(cat "$TOKEN_FILE") + +# maak release aan +RELEASE_RESP=$(curl -s -X POST "$FORGEJO_URL/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"SylDa Android app + firmware\", \"draft\": false, \"prerelease\": false}") + +RELEASE_ID=$(echo "$RELEASE_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true) + +if [ -z "$RELEASE_ID" ]; then + err "Release aanmaken mislukt. Response:" + echo "$RELEASE_RESP" + exit 1 +fi +log "Release ID: $RELEASE_ID" + +# upload artefacten +for f in *; do + log " Upload $f..." + curl -s -X POST "$FORGEJO_URL/releases/$RELEASE_ID/assets?name=$f" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$f" > /dev/null +done + +log "Klaar! Release $TAG: $FORGEJO_URL/releases/tag/$TAG (vervang api/v1/repos door web URL)" +SCRIPT + +chmod +x scripts/release.sh +``` + +- [ ] **Step 3: Verifieer script syntax** + +```bash +bash -n scripts/release.sh +echo $? +``` + +Expected: `0`. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/release.sh +git commit -m "feat: add release build script" +``` + +--- + +### Task 6: Droogtest build + +**Files:** +- Geen wijzigingen — verifieer dat release build werkt + +- [ ] **Step 1: Controleer dat keystore en signing.properties aanwezig zijn** + +```bash +ls -la android/keystore/sylda-release.jks android/signing.properties +``` + +- [ ] **Step 2: Build release APK + AAB** + +```bash +./gradlew assembleRelease bundleRelease +``` + +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Verifieer ondertekening APK** + +```bash +jarsigner -verify -verbose -certs android/app/build/outputs/apk/release/app-release.apk 2>&1 | grep -E "CN=|jar verified" +``` + +Expected melding: `CN=Eddy Devink` en `jar verified`. + +- [ ] **Step 4: Verifieer ondertekening AAB** + +```bash +jarsigner -verify -verbose -certs android/app/build/outputs/bundle/release/app-release.aab 2>&1 | grep -E "CN=|jar verified" +``` + +Expected melding: `CN=Eddy Devink` en `jar verified`. + +--- + +### Task 7: Maak Forgejo access token aan + +**Files:** +- Create: `~/.config/sylda/forgejo-token` (lokaal configuratiebestand, niet in repo) + +- [ ] **Step 1: Maak token in Forgejo web UI** + +Navigeer in browser naar: `https://git.eddydevink.nl/user/settings/applications` + +- Klik "Generate Token" +- Token name: `syl-release` +- Scope: vink `write:repository` aan +- Klik "Generate" + +Kopieer de gegenereerde token. + +- [ ] **Step 2: Sla token op** + +```bash +mkdir -p ~/.config/sylda +echo "" > ~/.config/sylda/forgejo-token +chmod 600 ~/.config/sylda/forgejo-token +``` + +- [ ] **Step 3: Sla token op in Obsidian vault** + +Maak notitie `Homelab/Project_SylDa/forgejo-token.md` met: + +``` +# Forgejo API Token — syl-release + +Token voor release uploads. Scope: write:repository. + +Gegenereerd op: +In: Forgejo web UI → Settings → Applications + +Token: + +Bestand: ~/.config/sylda/forgejo-token (chmod 600) +``` + +- [ ] **Step 4: Verifieer token werkt** + +```bash +ssh aiaiaiaiagent@nextcloud 'curl -s http://localhost:3000/api/v1/user -H "Authorization: token '"$(cat ~/.config/sylda/forgejo-token)"'"' | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("login","FAIL"))' +``` + +Expected: `eddy` (de gebruikersnaam in Forgejo). + +--- + +### Task 8: Draai volledige release + +**Files:** +- Geen wijzigingen — voert het script uit + +- [ ] **Step 1: Check git status (schoon)** + +```bash +git status +``` + +Expected: `nothing to commit, working tree clean` + +- [ ] **Step 2: Draai release script** + +```bash +./scripts/release.sh +``` + +- [ ] **Step 3: Verifieer release op Forgejo** + +```bash +ssh aiaiaiaiagent@nextcloud 'curl -s http://localhost:3000/api/v1/repos/eddy/Project_SylDa/releases/tags/v1.0' | python3 -c 'import sys,json; r=json.load(sys.stdin); print(f"Tag: {r[\"tag_name\"]}, Assets: {len(r[\"assets\"])}"); [print(f" - {a[\"name\"]} ({a[\"size\"]} bytes)") for a in r["assets"]]' +``` + +Expected: Tag `v1.0`, 6 assets (APK, AAB, 4 firmware bins, checksums.txt). + +- [ ] **Step 4: Open browser ter verificatie** + +Open `https://git.eddydevink.nl/eddy/Project_SylDa/releases` en controleer dat release `v1.0` zichtbaar is met alle attachments. + +--- + +### Post-release: Cleanup + +Release artefacten in `release/v1.0/` worden niet gecommit (`.gitignore` sluit `*.apk` en `*.aab` al uit). Na release draaien kan deze directory verwijderd worden: + +```bash +rm -rf release/ +``` diff --git a/docs/superpowers/specs/2026-07-28-esp32-pico-d4-port-design.md b/docs/superpowers/specs/2026-07-28-esp32-pico-d4-port-design.md new file mode 100644 index 0000000..ffa6363 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-esp32-pico-d4-port-design.md @@ -0,0 +1,127 @@ +# 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(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 | diff --git a/docs/superpowers/specs/2026-07-29-ble-refactor-design.md b/docs/superpowers/specs/2026-07-29-ble-refactor-design.md new file mode 100644 index 0000000..b046529 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-ble-refactor-design.md @@ -0,0 +1,172 @@ +# 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 + 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 diff --git a/docs/superpowers/specs/2026-07-29-device-uuid-pairing-design.md b/docs/superpowers/specs/2026-07-29-device-uuid-pairing-design.md new file mode 100644 index 0000000..a22ee4c --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-device-uuid-pairing-design.md @@ -0,0 +1,68 @@ +# 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 diff --git a/docs/superpowers/specs/2026-07-30-release-build-design.md b/docs/superpowers/specs/2026-07-30-release-build-design.md new file mode 100644 index 0000000..28ef6dd --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-release-build-design.md @@ -0,0 +1,99 @@ +# Release build & Forgejo publishing — design + +**Datum:** 2026-07-30 +**Context:** Android app (AtomRelay) heeft nog geen ondertekende release build. Doel: signing keystore aanmaken, release APK + AAB bouwen, en publiceren als release op Forgejo (`git.eddydevink.nl/eddy/Project_SylDa`). Vooruitkijkend op multi-store publishing (Amazon, Samsung, F-Droid, later Google Play). + +## Scope + +- Keystore genereren en veilig opslaan (Obsidian vault + gitignored workspace copy) +- Signing configuratie in `build.gradle` via extern `signing.properties` +- Release script `scripts/release.sh` dat APK, AAB en firmware binaries bundelt +- Upload naar Forgejo als git-tagged release met attachments +- Buiten scope: daadwerkelijk uploaden naar app stores, Forgejo Actions/CI + +## Huidige situatie + +- `versionCode 1`, `versionName "1.0"` +- Geen keystore, geen signing config +- `gradle.properties` is tracked (geen secrets) +- APK/AAB bestanden al in `.gitignore` +- Forgejo repo `eddy/Project_SylDa`, API op `http://localhost:3000/api/v1`, 0 releases, 0 tags +- Firmware binaries in `android/app/src/main/assets/firmware/` +- Root project: `AtomRelay`, Gradle 8.14, AGP 8.5.2, Kotlin 2.0.21 + +## Ontwerp + +### 1. Keystore & signing config + +| Bestand | Pad | Git | +|---|---|---| +| Keystore | `android/keystore/sylda-release.jks` | `.gitignore` | +| Wachtwoorden | `android/signing.properties` | `.gitignore` | +| Signing block | `android/app/build.gradle` (aanpassing) | tracked | + +**`signing.properties`** inhoud: +```properties +KEYSTORE_PATH=keystore/sylda-release.jks +KEYSTORE_PASSWORD= +KEY_ALIAS=sylda +KEY_PASSWORD= +``` + +**`build.gradle`** aanpassing: onder `android {}` block wordt signing block toegevoegd dat `signing.properties` inleest. Alleen actief als het bestand bestaat → debug builds blijven werken zonder keystore. + +**Keystore generatie:** +```bash +keytool -genkey -v -keystore android/keystore/sylda-release.jks \ + -alias sylda -keyalg RSA -keysize 2048 -validity 10000 +``` + +**Opslag:** kopie van `.jks` + wachtwoorden naar Obsidian vault. Gitignored kopie blijft in workspace voor Gradle. + +### 2. .gitignore + +Toevoegen: +``` +android/keystore/ +android/signing.properties +``` + +### 3. Release script (`scripts/release.sh`) + +Flow: +1. Controleert dat `signing.properties` bestaat +2. `./gradlew clean` +3. `./gradlew bundleRelease assembleRelease` +4. Verzamelt artefacten in `release/v{version}/`: + - `app-release.apk` + - `app-release.aab` + - `bootloader.bin`, `partitions.bin`, `boot_app0.bin`, `firmware.bin` + - `checksums.txt` (SHA256 van alle bestanden) +5. `git tag v{version} && git push origin v{version}` +6. Maakt Forgejo release aan via API +7. Uploadt alle artefacten als attachments + +**Forgejo token:** opgeslagen in `~/.config/sylda/forgejo-token` (niet in repo). Moet handmatig aangemaakt worden in Forgejo web UI (Settings > Applications > Generate Token, scope `write:repository`). + +**Versie-detectie:** script leest `versionName` uit `build.gradle` via grep, géén handmatige argumenten nodig. + +### 4. F-Droid compatibiliteit + +- Geen timestamp in APK/AAB build (huidige `BUILD_NUMBER` buildConfigField wordt alleen voor debug gebruikt en zit niet in release builds — check) +- Vaste Gradle wrapper versie (8.14) +- Check: `buildConfigField` met timestamp staat in `defaultConfig`, niet in release buildType. Dit is OK voor F-Droid want F-Droid bouwt reproduceerbare builds met eigen tooling. + +### 5. Multi-store vooruitblik + +| Platform | Artefact | Extra nodig | +|---|---|---| +| Forgejo release | APK + AAB + firmware | Deze release | +| Google Play | AAB | Google Play developer account | +| Amazon Appstore | APK | Amazon developer account | +| Samsung Galaxy Store | APK | Samsung developer account | +| F-Droid | Source | F-Droid metadata + merge request | + +## Beslissingen + +- Keystore in Obsidian vault (conform AGENTS.md credentials policy) +- Huidig versienummer behouden (1/1.0) — eerste release +- Aanpak A: release script, handmatig (geen CI voor nu) diff --git a/docs/user-facing-text.md b/docs/user-facing-text.md new file mode 100644 index 0000000..f40a153 --- /dev/null +++ b/docs/user-facing-text.md @@ -0,0 +1,111 @@ +# 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` | diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..1752e48 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +log() { echo -e "${GREEN}[release]${NC} $1"; } +err() { echo -e "${RED}[release]${NC} $1" >&2; } + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ANDROID_DIR="$PROJECT_DIR/android" +APP_BUILD_GRADLE="$ANDROID_DIR/app/build.gradle" +SIGNING_PROPS="$ANDROID_DIR/signing.properties" +FORGEJO_URL="https://git.eddydevink.nl/api/v1/repos/eddy/Project_SylDa" +FIRMWARE_DIR="$ANDROID_DIR/app/src/main/assets/firmware" + +VERSION=$(grep -oP 'versionName\s+"\K[^"]+' "$APP_BUILD_GRADLE") +TAG="v$VERSION" +RELEASE_DIR="$PROJECT_DIR/release/$TAG" + +log "Release v$VERSION (tag $TAG)" + +if [ ! -f "$SIGNING_PROPS" ]; then + err "signing.properties niet gevonden op $SIGNING_PROPS" + exit 1 +fi + +log "Clean..." +cd "$ANDROID_DIR" +./gradlew clean + +log "Build APK + AAB..." +./gradlew assembleRelease bundleRelease + +log "Verzamel artefacten in $RELEASE_DIR" +mkdir -p "$RELEASE_DIR" + +cp "$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" "$RELEASE_DIR/" +cp "$ANDROID_DIR/app/build/outputs/bundle/release/app-release.aab" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/bootloader.bin" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/partitions.bin" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/boot_app0.bin" "$RELEASE_DIR/" +cp "$FIRMWARE_DIR/firmware.bin" "$RELEASE_DIR/" + +log "Checksums..." +cd "$RELEASE_DIR" +sha256sum * > checksums.txt +cat checksums.txt + +cd "$PROJECT_DIR" +if git rev-parse "$TAG" >/dev/null 2>&1; then + log "Tag $TAG bestaat al, skip git tag." +else + log "Git tag $TAG..." + git tag "$TAG" + git push origin "$TAG" +fi + +log "Forgejo release aanmaken..." +TOKEN_FILE="$HOME/.config/sylda/forgejo-token" +if [ ! -f "$TOKEN_FILE" ]; then + err "Forgejo token niet gevonden op $TOKEN_FILE" + err "Genereer een token in Forgejo: Settings > Applications > Generate Token (scope: write:repository)" + err "Sla op als: echo 'je-token' > $TOKEN_FILE" + exit 1 +fi +TOKEN=$(cat "$TOKEN_FILE") + +RELEASE_RESP=$(curl -s -X POST "$FORGEJO_URL/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"SylDa Android app + firmware\", \"draft\": false, \"prerelease\": false}") + +RELEASE_ID=$(echo "$RELEASE_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true) + +if [ -z "$RELEASE_ID" ]; then + err "Release aanmaken mislukt. Response:" + echo "$RELEASE_RESP" + exit 1 +fi +log "Release ID: $RELEASE_ID" + +for f in *; do + log " Upload $f..." + curl -s -X POST "$FORGEJO_URL/releases/$RELEASE_ID/assets?name=$f" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$f" > /dev/null +done + +log "Klaar! Release $TAG staat op Forgejo."