Initial commit: BLE relay controller for Atom VoiceS3R

This commit is contained in:
eddy 2026-07-26 01:17:31 +02:00
commit 7c9dde172a
15 changed files with 764 additions and 0 deletions

31
.gitignore vendored Normal file
View 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
View 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
View 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'
}

View 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>

View 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()
}
}

View 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 &amp; Connect"
android:layout_marginTop="48dp"/>
</LinearLayout>

View 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
View 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
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View 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
View 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
View 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
View 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
View 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);
}