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

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>