commit 7c9dde172ad548a193a0aeb3d0786678a4c436ca Author: eddy Date: Sun Jul 26 01:17:31 2026 +0200 Initial commit: BLE relay controller for Atom VoiceS3R diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a955ae2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# PlatformIO +.pio/ +.pioenvs/ +.piolibdeps/ +.clang_complete +.gcc-flags.json +>Sconscript +>SConscript +>*.scons + +# Android/Gradle +.gradle/ +build/ +local.properties +*.iml +.idea/ +*.apk +*.aab +*.ap_ +*.dex + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2485979 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# Sylvia Touch Relay + +BLE-gestuurde relais-switch voor de M5Stack Atom VoiceS3R (ESP32-S3) met een bijbehorende Android-app. Het relais kan worden bediend via een capacitieve touch-sensor (open-drain) óf via Bluetooth Low Energy vanaf een Android-telefoon. + +## Hardware + +| Component | Details | +|---|---| +| Board | M5Stack Atom VoiceS3R (SKU: C126-ECHO) | +| SoC | ESP32-S3-PICO-1-N8R8 (8MB Flash, 8MB PSRAM) | +| Touch sensor | Open-drain sensor op **G38** (3.3V, interne pullup) | +| Relais / LED | Op **G1** (3.3V GPIO output) | +| Status LED | Op **G46** (knippert 3x bij opstarten) | + +### Pinout (Atom VoiceS3R onderste header) + +``` +G38 G39 3.3V GND 5V G5 G6 G7 G8 +``` + +- **G38** — Touch sensor input (active LOW, interne pullup ingeschakeld) +- **G1** — Relais output (HIGH = aan, LOW = uit) +- **G46** — Status LED (3x knipperen bij opstarten, daarna uit) + +### Aansluiting + +``` +Sensor GND → GND +Sensor OUT → G38 +Sensor VCC → 3.3V + +Relais + → G1 +Relais - → GND +``` + +> **Let op:** De ESP32-S3 interne pullup is ~45kΩ. Bij lange draden of elektrische ruis een externe 4.7kΩ–10kΩ pullup naar 3.3V plaatsen. + +## Firmware + +De firmware is geschreven in C++ met behulp van PlatformIO en de Arduino-framework. BLE wordt verzorgd door de ESP32 BLE library. + +### Logica + +Het relais gaat aan als **één van beide** bronnen actief is: + +| Bron | Voorwaarde | Resultaat | +|---|---|---| +| Touch sensor | G38 = LOW (aanraken) | Relais aan | +| BLE command | App stuurt `0x01` | Relais aan | +| Geen van beide | G38 = HIGH + geen BLE command | Relais uit | + +De touch sensor en BLE werken onafhankelijk. Als de sensor losgelaten wordt én de BLE-knop niet ingedrukt is, gaat het relais uit. + +### BLE GATT + +| Eigenschap | UUID | +|---|---| +| Service | `4fafc201-1fb5-459e-8fcc-c5c9c331914b` | +| Characteristic | `beb5483e-36e1-4688-b7f5-ea07361f26d8` | + +De characteristic ondersteunt `WRITE` en `NOTIFY`. Schrijf `0x01` om het relais aan te zetten, `0x00` om uit te zetten. + +### Serial monitor + +Baudrate: **115200** (USB CDC). De firmware stuurt heartbeat-statussen elke 5 seconden en logt statuswijzigingen: + +``` +=== Atom VoiceS3R BLE Relay === +LED blink done, starting BLE... +BLE: advertising started as 'AtomS3R-Relay' +Setup done. +[heartbeat] relay=OFF touch=no ble=disconnected +BLE: device connected +Relay ON (touch=YES, ble=connected) +Relay OFF (touch=no, ble=connected) +``` + +### Uploaden + +> **Download-mode:** Houd de reset-knop ~2 seconden ingedrukt tot de groene LED aangaat, laat dan los. Het board is nu in download-mode. + +```bash +pio run --target upload +``` + +Na het uploaden treedt er een harde reset op. Als het board in download-mode blijft, USB eruit/erin of kort op reset tikken om in run-mode te komen. + +## Android App + +### Features + +- **Auto-connect:** De app scant automatisch bij opstarten naar het BLE-apparaat "AtomS3R-Relay" +- **Auto-reconnect:** Bij wegvallen van de verbinding wordt na 2 seconden opnieuw gescant +- **Hold-to-activate:** Houd de knop ingedrukt voor relais aan, loslaten voor relais uit (zelfde gedrag als de sensor) +- **Permissies:** Vraagt BLUETOOTH_SCAN, BLUETOOTH_CONNECT en ACCESS_FINE_LOCATION aan (Android 12+) + +### Bouwen + +Vereisten: +- Android SDK (API 34+) +- Java 21 +- Gradle 8.5 + +```bash +cd android +JAVA_HOME=/pad/naar/jdk21 ./gradlew assembleDebug +``` + +De APK staat na build in `android/app/build/outputs/apk/debug/app-debug.apk`. + +### Installeren + +```bash +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +``` + +Of via draadloze foutopsporing: + +```bash +adb connect : +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +``` + +### UUID's + +De app gebruikt dezelfde UUID's als de firmware: + +```kotlin +private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") +private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") +``` + +## Projectstructuur + +``` +sylvia-touch-relay/ +├── platformio.ini # PlatformIO configuratie (ESP32-S3) +├── src/ +│ └── main.cpp # Firmware (BLE + sensor + relais) +├── android/ +│ ├── build.gradle # Root Gradle config +│ ├── settings.gradle +│ ├── gradle.properties +│ ├── gradlew # Gradle wrapper +│ ├── gradle/wrapper/ +│ │ └── gradle-wrapper.properties +│ └── app/ +│ ├── build.gradle # App module config +│ └── src/main/ +│ ├── AndroidManifest.xml +│ ├── java/com/eddy/atomrelay/ +│ │ └── MainActivity.kt +│ └── res/ +│ ├── layout/activity_main.xml +│ └── values/strings.xml +├── .gitignore +└── README.md +``` + +## Technische notities + +- De Atom VoiceS3R gebruikt de ES8311 audio codec op G45/G0/G48/G4/G3/G17/G11, IR TX op G47, en de knop op G41. Deze pinnen zijn niet beschikbaar voor eigen gebruik. +- G1 en G2 zitten op de HY2.0-4P Grove poort. G5-G8, G38, G39 zitten op de onderste expantie-header. +- De firmware gebruikt geen M5Unified library — alleen Arduino core + ESP32 BLE. Dit maakt de serial output betrouwbaarder via USB CDC. +- `ARDUINO_USB_CDC_ON_BOOT=1` is ingeschakeld voor virtuele serial via USB. \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..4981ef8 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.eddy.atomrelay' + compileSdk 34 + + defaultConfig { + applicationId "com.eddy.atomrelay" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + } + + 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' +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1bc43b6 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt b/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt new file mode 100644 index 0000000..a27934e --- /dev/null +++ b/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt @@ -0,0 +1,232 @@ +package com.eddy.atomrelay + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.MotionEvent +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import java.util.UUID + +class MainActivity : AppCompatActivity() { + + companion object { + private const val TAG = "AtomRelay" + private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") + private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") + private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val DEVICE_NAME = "AtomS3R-Relay" + } + + private var bluetoothGatt: BluetoothGatt? = null + private var relayCharacteristic: BluetoothGattCharacteristic? = null + private var isScanning = false + private val handler = Handler(Looper.getMainLooper()) + + private lateinit var statusText: TextView + private lateinit var relayButton: Button + private lateinit var scanButton: Button + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val name = device?.name ?: "" + Log.d(TAG, "Found device: $name (${device?.address})") + if (name == DEVICE_NAME) { + stopScan() + connectToDevice(device) + } + } + } + + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "GATT connected, discovering services...") + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "GATT disconnected") + runOnUiThread { + statusText.text = "Reconnecting..." + relayButton.isEnabled = false + } + gatt.close() + bluetoothGatt = null + relayCharacteristic = null + // Auto-reconnect after 2 seconds + handler.postDelayed({ startScan() }, 2000) + } + } + + @SuppressLint("MissingPermission") + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + val service = gatt.getService(SERVICE_UUID) + if (service != null) { + relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) + if (relayCharacteristic != null) { + // Enable notifications + gatt.setCharacteristicNotification(relayCharacteristic, true) + val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID) + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(descriptor) + Log.d(TAG, "Services discovered, relay characteristic found") + runOnUiThread { + statusText.text = "Connected" + relayButton.isEnabled = true + } + } + } else { + Log.e(TAG, "Service not found") + runOnUiThread { statusText.text = "Service not found" } + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + Log.d(TAG, "Write status: $status") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + statusText = findViewById(R.id.statusText) + relayButton = findViewById(R.id.relayButton) + scanButton = findViewById(R.id.scanButton) + + scanButton.setOnClickListener { startScan() } + setupRelayButton() + + // Auto-connect on app start + handler.postDelayed({ startScan() }, 500) + } + + @Deprecated("Deprecated in Java") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 1 && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { + startScan() + } + } + + private fun setupRelayButton() { + relayButton.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + sendRelayCommand(true) + relayButton.text = "ON" + true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + sendRelayCommand(false) + relayButton.text = "HOLD" + true + } + else -> false + } + } + } + + @SuppressLint("MissingPermission") + private fun startScan() { + if (isScanning) return + if (!hasPermissions()) { + requestPermissions() + return + } + + val adapter = BluetoothAdapter.getDefaultAdapter() + if (adapter == null || !adapter.isEnabled) { + statusText.text = "Bluetooth not enabled" + return + } + + isScanning = true + statusText.text = "Scanning..." + scanButton.isEnabled = false + Log.d(TAG, "Starting BLE scan for $DEVICE_NAME") + + adapter.bluetoothLeScanner.startScan(scanCallback) + + // Stop scan after 10 seconds + handler.postDelayed({ + if (isScanning) stopScan() + }, 10000) + } + + @SuppressLint("MissingPermission") + private fun stopScan() { + if (!isScanning) return + isScanning = false + scanButton.isEnabled = true + val adapter = BluetoothAdapter.getDefaultAdapter() + adapter?.bluetoothLeScanner?.stopScan(scanCallback) + if (statusText.text == "Scanning...") { + statusText.text = "Device not found" + } + } + + @SuppressLint("MissingPermission") + private fun connectToDevice(device: BluetoothDevice) { + runOnUiThread { statusText.text = "Connecting..." } + Log.d(TAG, "Connecting to ${device.address}") + bluetoothGatt = device.connectGatt(this, false, gattCallback) + } + + @SuppressLint("MissingPermission") + private fun sendRelayCommand(on: Boolean) { + val char = relayCharacteristic ?: return + val gatt = bluetoothGatt ?: return + char.value = byteArrayOf(if (on) 0x01 else 0x00) + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + gatt.writeCharacteristic(char) + Log.d(TAG, "Sent relay ${if (on) "ON" else "OFF"}") + } + + private 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) + } + } + + private fun hasPermissions(): Boolean { + return getRequiredPermissions().all { + ActivityCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + } + + private fun requestPermissions() { + ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1) + } + + override fun onDestroy() { + super.onDestroy() + stopScan() + bluetoothGatt?.close() + } +} \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..33d0555 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,32 @@ + + + + + +