Initial commit: BLE relay controller for Atom VoiceS3R
This commit is contained in:
commit
7c9dde172a
15 changed files with 764 additions and 0 deletions
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
|
*~
|
||||||
165
README.md
Normal file
165
README.md
Normal file
|
|
@ -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 <ip>:<poort>
|
||||||
|
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.
|
||||||
37
android/app/build.gradle
Normal file
37
android/app/build.gradle
Normal file
|
|
@ -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'
|
||||||
|
}
|
||||||
28
android/app/src/main/AndroidManifest.xml
Normal file
28
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="Atom Relay"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.AppCompat.Light">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
232
android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
Normal file
232
android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
Normal file
|
|
@ -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<out String>, 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<String> {
|
||||||
|
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.BLUETOOTH_SCAN,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
32
android/app/src/main/res/layout/activity_main.xml
Normal file
32
android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="32dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statusText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Disconnected"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:layout_marginBottom="48dp"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/relayButton"
|
||||||
|
android:layout_width="200dp"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
android:text="HOLD"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:enabled="false"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/scanButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Scan & Connect"
|
||||||
|
android:layout_marginTop="48dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Atom Relay</string>
|
||||||
|
</resources>
|
||||||
4
android/build.gradle
Normal file
4
android/build.gradle
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.2.2' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
69
android/gradlew
vendored
Executable file
69
android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Gradle start up script
|
||||||
|
#
|
||||||
|
|
||||||
|
DIRNAME=$(dirname "$0")
|
||||||
|
APP_HOME=$DIRNAME
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in
|
||||||
|
max*)
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in
|
||||||
|
'' | soft) :;;
|
||||||
|
*)
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
exec "$JAVACMD" \
|
||||||
|
$DEFAULT_JVM_OPTS \
|
||||||
|
$JAVA_OPTS \
|
||||||
|
$GRADLE_OPTS \
|
||||||
|
"-Dorg.gradle.appname=${0##*/}" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
16
android/settings.gradle
Normal file
16
android/settings.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "AtomRelay"
|
||||||
|
include ':app'
|
||||||
15
platformio.ini
Normal file
15
platformio.ini
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[env:m5stack-atoms3r]
|
||||||
|
platform = espressif32@6.7.0
|
||||||
|
board = esp32-s3-devkitc-1
|
||||||
|
framework = arduino
|
||||||
|
board_build.arduino.memory_type = qio_opi
|
||||||
|
build_flags =
|
||||||
|
-DESP32S3
|
||||||
|
-DBOARD_HAS_PSRAM
|
||||||
|
-mfix-esp32-psram-cache-issue
|
||||||
|
-DCORE_DEBUG_LEVEL=5
|
||||||
|
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||||
|
-DARDUINO_USB_MODE=1
|
||||||
|
lib_deps =
|
||||||
|
upload_port = /dev/ttyACM0
|
||||||
|
upload_speed = 115200
|
||||||
122
src/main.cpp
Normal file
122
src/main.cpp
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <BLEDevice.h>
|
||||||
|
#include <BLEServer.h>
|
||||||
|
#include <BLEUtils.h>
|
||||||
|
#include <BLE2902.h>
|
||||||
|
|
||||||
|
#define SENSOR_PIN 38
|
||||||
|
#define RELAY_PIN 1
|
||||||
|
#define LED_PIN 46
|
||||||
|
|
||||||
|
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
|
||||||
|
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
|
||||||
|
|
||||||
|
BLECharacteristic *pCharacteristic;
|
||||||
|
bool deviceConnected = false;
|
||||||
|
bool bleCommand = false;
|
||||||
|
bool relayState = false;
|
||||||
|
|
||||||
|
class MyServerCallbacks : public BLEServerCallbacks {
|
||||||
|
void onConnect(BLEServer *pServer) override {
|
||||||
|
deviceConnected = true;
|
||||||
|
Serial.println("BLE: device connected");
|
||||||
|
}
|
||||||
|
void onDisconnect(BLEServer *pServer) override {
|
||||||
|
deviceConnected = false;
|
||||||
|
bleCommand = false;
|
||||||
|
Serial.println("BLE: device disconnected");
|
||||||
|
BLEDevice::startAdvertising();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class MyCharacteristicCallbacks : public BLECharacteristicCallbacks {
|
||||||
|
void onWrite(BLECharacteristic *pChar) override {
|
||||||
|
std::string value = pChar->getValue();
|
||||||
|
if (value.length() > 0) {
|
||||||
|
uint8_t cmd = (uint8_t)value[0];
|
||||||
|
if (cmd == 0x01) {
|
||||||
|
bleCommand = true;
|
||||||
|
Serial.println("BLE: relay ON");
|
||||||
|
} else {
|
||||||
|
bleCommand = false;
|
||||||
|
Serial.println("BLE: relay OFF");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
delay(2000);
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(500);
|
||||||
|
Serial.println("=== Atom VoiceS3R BLE Relay ===");
|
||||||
|
|
||||||
|
pinMode(SENSOR_PIN, INPUT_PULLUP);
|
||||||
|
pinMode(RELAY_PIN, OUTPUT);
|
||||||
|
pinMode(LED_PIN, OUTPUT);
|
||||||
|
|
||||||
|
// Visual confirmation: blink LED 3x before BLE init
|
||||||
|
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...");
|
||||||
|
|
||||||
|
BLEDevice::init("AtomS3R-Relay");
|
||||||
|
BLEDevice::setMTU(23);
|
||||||
|
BLEServer *pServer = BLEDevice::createServer();
|
||||||
|
pServer->setCallbacks(new MyServerCallbacks());
|
||||||
|
|
||||||
|
BLEService *pService = pServer->createService(SERVICE_UUID);
|
||||||
|
pCharacteristic = pService->createCharacteristic(
|
||||||
|
CHARACTERISTIC_UUID,
|
||||||
|
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY
|
||||||
|
);
|
||||||
|
pCharacteristic->addDescriptor(new BLE2902());
|
||||||
|
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
|
||||||
|
pCharacteristic->setValue("0");
|
||||||
|
pService->start();
|
||||||
|
|
||||||
|
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
|
||||||
|
pAdvertising->addServiceUUID(SERVICE_UUID);
|
||||||
|
pAdvertising->setScanResponse(true);
|
||||||
|
pAdvertising->setMinPreferred(0x06);
|
||||||
|
pAdvertising->setMinPreferred(0x12);
|
||||||
|
BLEDevice::startAdvertising();
|
||||||
|
|
||||||
|
Serial.println("BLE: advertising started as 'AtomS3R-Relay'");
|
||||||
|
Serial.println("Setup done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
int sensorState = digitalRead(SENSOR_PIN);
|
||||||
|
|
||||||
|
bool touchActive = (sensorState == LOW);
|
||||||
|
bool bleActive = (deviceConnected && bleCommand);
|
||||||
|
|
||||||
|
relayState = touchActive || bleActive;
|
||||||
|
|
||||||
|
digitalWrite(RELAY_PIN, relayState ? HIGH : LOW);
|
||||||
|
|
||||||
|
static bool lastRelay = false;
|
||||||
|
if (relayState != lastRelay) {
|
||||||
|
Serial.printf("Relay %s (touch=%s, ble=%s)\n",
|
||||||
|
relayState ? "ON" : "OFF",
|
||||||
|
sensorState == LOW ? "YES" : "no",
|
||||||
|
deviceConnected ? "connected" : "disconnected");
|
||||||
|
lastRelay = relayState;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t heartbeat = 0;
|
||||||
|
if (millis() - heartbeat > 5000) {
|
||||||
|
Serial.printf("[heartbeat] relay=%s touch=%s ble=%s\n",
|
||||||
|
relayState ? "ON" : "OFF",
|
||||||
|
sensorState == LOW ? "YES" : "no",
|
||||||
|
deviceConnected ? "connected" : "disconnected");
|
||||||
|
heartbeat = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(50);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue