Initial implementation of BLE-to-TCP serial bridge for Android
Android app that connects to a BLE UART device (Nordic UART Service by default, matching RNode BLE) and exposes it as a TCP byte pipe, so socat can present it to Reticulum as a serial port. Foreground service hosts a GATT client with MTU negotiation, chunked serialized writes, and auto-reconnect, plus a single-client last-wins TCP server. No root required on either end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
.kotlin/
|
||||||
117
README.md
Normal file
117
README.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# BLE Serial Bridge
|
||||||
|
|
||||||
|
An Android app that connects to a BLE UART device (Nordic UART Service by
|
||||||
|
default — the same service RNode firmware uses for BLE serial) and exposes it
|
||||||
|
as a TCP byte pipe. With `socat` on the consuming side, that pipe becomes a
|
||||||
|
real serial device node that [Reticulum](https://reticulum.network/) can use
|
||||||
|
via its serial-based interfaces.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ BLE/GATT ┌──────────────────┐ TCP ┌──────────────────────┐
|
||||||
|
│ BLE device │◄────────────►│ BLE Serial Bridge │◄───────►│ socat → pty │
|
||||||
|
│ (NUS UART, │ │ (this app, │ :7777 │ → rnsd / picocom / │
|
||||||
|
│ RNode, …) │ │ foreground svc) │ │ anything serial │
|
||||||
|
└─────────────┘ └──────────────────┘ └──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
No root required anywhere. The consumer can be Termux on the same phone
|
||||||
|
(loopback) or a PC on the same network (enable "Allow LAN").
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Install and open the app, grant Bluetooth (and notification) permissions.
|
||||||
|
2. If the device requires bonding (RNode BLE does — it displays a pairing
|
||||||
|
PIN), pair it once in Android's Bluetooth settings first.
|
||||||
|
3. Set the TCP port (default `7777`). Leave "Allow LAN" off for
|
||||||
|
Termux-on-the-same-phone use; turn it on to serve a PC over WiFi.
|
||||||
|
4. The UUID fields are pre-filled with Nordic UART Service. Change them only
|
||||||
|
for devices with a custom UART-style service.
|
||||||
|
5. Tap **Scan**, then tap your device. The bridge starts as a foreground
|
||||||
|
service and auto-reconnects if the BLE link drops.
|
||||||
|
|
||||||
|
The TCP server accepts one client at a time; a new connection displaces the
|
||||||
|
old one, so a restarted `socat` reconnects cleanly.
|
||||||
|
|
||||||
|
## Reticulum on the same phone (Termux)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pkg install socat
|
||||||
|
socat -d pty,link=$HOME/ttyBLE0,raw,echo=0 tcp:127.0.0.1:7777
|
||||||
|
```
|
||||||
|
|
||||||
|
Then point an RNS interface at the pty. For an RNode over BLE:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[[RNode BLE]]
|
||||||
|
type = RNodeInterface
|
||||||
|
enabled = true
|
||||||
|
port = /data/data/com.termux/files/home/ttyBLE0
|
||||||
|
frequency = 867200000
|
||||||
|
bandwidth = 125000
|
||||||
|
txpower = 7
|
||||||
|
spreadingfactor = 8
|
||||||
|
codingrate = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
For a generic KISS TNC behind a BLE UART:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[[BLE KISS]]
|
||||||
|
type = KISSInterface
|
||||||
|
enabled = true
|
||||||
|
port = /data/data/com.termux/files/home/ttyBLE0
|
||||||
|
speed = 115200
|
||||||
|
```
|
||||||
|
|
||||||
|
(`speed` is required by pyserial but meaningless here — the BLE link ignores
|
||||||
|
it.)
|
||||||
|
|
||||||
|
Run `socat` and `rnsd` together, e.g. in two Termux sessions or under a
|
||||||
|
process supervisor. Disable Android battery optimization for both this app
|
||||||
|
and Termux, or the OS will kill the bridge.
|
||||||
|
|
||||||
|
## Reticulum on a PC (phone as BLE radio bridge)
|
||||||
|
|
||||||
|
Enable "Allow LAN" in the app, then on the PC:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
socat -d pty,link=/tmp/ttyBLE0,raw,echo=0 tcp:<phone-ip>:7777
|
||||||
|
```
|
||||||
|
|
||||||
|
and use `/tmp/ttyBLE0` as the `port` in the RNS interface config as above.
|
||||||
|
To get a system-style name, run socat as root with
|
||||||
|
`pty,link=/dev/ttyBLE0,raw,echo=0`.
|
||||||
|
|
||||||
|
Note: "Allow LAN" binds to all interfaces with no authentication or
|
||||||
|
encryption — anyone on the network can talk to your radio. Use it only on
|
||||||
|
networks you trust.
|
||||||
|
|
||||||
|
## Sanity check without Reticulum
|
||||||
|
|
||||||
|
```sh
|
||||||
|
socat -d pty,link=/tmp/ttyBLE0,raw,echo=0 tcp:<phone-ip>:7777 &
|
||||||
|
picocom /tmp/ttyBLE0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires the Android SDK (API 36) and JDK 17+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gradle assembleDebug
|
||||||
|
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- **BLE side**: GATT central. Negotiates the largest MTU it can (up to 517),
|
||||||
|
chunks writes to MTU−3, serializes them (one write in flight), and uses
|
||||||
|
write-without-response when the characteristic supports it. Notifications
|
||||||
|
on the RX characteristic feed the TCP side. Reconnects every 3 s if the
|
||||||
|
link drops, for as long as the bridge is running.
|
||||||
|
- **TCP side**: plain byte stream, no framing, no RFC 2217 — the BLE link has
|
||||||
|
no baud rate to negotiate. `TCP_NODELAY` is set to keep KISS frame latency
|
||||||
|
down.
|
||||||
|
- **Defaults**: Nordic UART Service `6e400001-…`, write `6e400002-…`,
|
||||||
|
notify `6e400003-…` — matches RNode BLE and most BLE-UART firmware
|
||||||
|
(Adafruit, Espruino, many ESP32 sketches).
|
||||||
41
app/build.gradle.kts
Normal file
41
app/build.gradle.kts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "dev.wntrmute.bleserial"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "dev.wntrmute.bleserial"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.16.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
|
}
|
||||||
45
app/src/main/AndroidManifest.xml
Normal file
45
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Legacy Bluetooth permissions (Android 11 and below). -->
|
||||||
|
<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.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
|
<!-- Android 12+ Bluetooth permissions. -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:theme="@style/Theme.BleSerial"
|
||||||
|
android:allowBackup="false">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".BridgeService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
241
app/src/main/java/dev/wntrmute/bleserial/BleLink.kt
Normal file
241
app/src/main/java/dev/wntrmute/bleserial/BleLink.kt
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package dev.wntrmute.bleserial
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
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.BluetoothStatusCodes
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GATT client that treats a UART-style service (Nordic UART Service by
|
||||||
|
* default) as a transparent byte pipe. Handles MTU negotiation, write
|
||||||
|
* chunking and serialization, notification subscription, and automatic
|
||||||
|
* reconnection.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
class BleLink(
|
||||||
|
private val context: Context,
|
||||||
|
private val device: BluetoothDevice,
|
||||||
|
private val serviceUuid: UUID,
|
||||||
|
private val writeUuid: UUID,
|
||||||
|
private val notifyUuid: UUID,
|
||||||
|
private val onData: (ByteArray) -> Unit,
|
||||||
|
private val onConnected: (Boolean) -> Unit,
|
||||||
|
private val onEvent: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BleLink"
|
||||||
|
private const val RECONNECT_DELAY_MS = 3000L
|
||||||
|
private const val MAX_QUEUED_CHUNKS = 4096
|
||||||
|
private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val lock = Object()
|
||||||
|
|
||||||
|
@Volatile private var closed = false
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var writeChar: BluetoothGattCharacteristic? = null
|
||||||
|
private var ready = false
|
||||||
|
private var writeInFlight = false
|
||||||
|
private var mtu = 23
|
||||||
|
private val txQueue = ArrayDeque<ByteArray>()
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (closed) return
|
||||||
|
onEvent("Connecting to ${device.address}")
|
||||||
|
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
closed = true
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
synchronized(lock) {
|
||||||
|
ready = false
|
||||||
|
writeInFlight = false
|
||||||
|
txQueue.clear()
|
||||||
|
}
|
||||||
|
gatt?.close()
|
||||||
|
gatt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue payload for transmission, split into MTU-sized chunks. */
|
||||||
|
fun send(data: ByteArray) {
|
||||||
|
val payload = maxPayload()
|
||||||
|
synchronized(lock) {
|
||||||
|
if (!ready) return
|
||||||
|
var off = 0
|
||||||
|
while (off < data.size) {
|
||||||
|
if (txQueue.size >= MAX_QUEUED_CHUNKS) {
|
||||||
|
txQueue.removeFirst()
|
||||||
|
}
|
||||||
|
val end = minOf(off + payload, data.size)
|
||||||
|
txQueue.addLast(data.copyOfRange(off, end))
|
||||||
|
off = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kickWrites()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxPayload() = mtu - 3
|
||||||
|
|
||||||
|
private fun kickWrites() {
|
||||||
|
val g = gatt ?: return
|
||||||
|
val ch: BluetoothGattCharacteristic
|
||||||
|
val chunk: ByteArray
|
||||||
|
synchronized(lock) {
|
||||||
|
if (!ready || writeInFlight) return
|
||||||
|
ch = writeChar ?: return
|
||||||
|
chunk = txQueue.removeFirstOrNull() ?: return
|
||||||
|
writeInFlight = true
|
||||||
|
}
|
||||||
|
val ok = if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
g.writeCharacteristic(ch, chunk, ch.writeType) == BluetoothStatusCodes.SUCCESS
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
run {
|
||||||
|
ch.value = chunk
|
||||||
|
g.writeCharacteristic(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
synchronized(lock) { writeInFlight = false }
|
||||||
|
Log.w(TAG, "writeCharacteristic failed to start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (closed) return
|
||||||
|
gatt?.close()
|
||||||
|
gatt = null
|
||||||
|
synchronized(lock) {
|
||||||
|
ready = false
|
||||||
|
writeInFlight = false
|
||||||
|
txQueue.clear()
|
||||||
|
}
|
||||||
|
onConnected(false)
|
||||||
|
onEvent("Disconnected; retrying in ${RECONNECT_DELAY_MS / 1000}s")
|
||||||
|
handler.postDelayed({ connect() }, RECONNECT_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback = object : BluetoothGattCallback() {
|
||||||
|
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||||
|
if (closed) return
|
||||||
|
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||||
|
onEvent("Connected; negotiating MTU")
|
||||||
|
if (!g.requestMtu(517)) {
|
||||||
|
g.discoverServices()
|
||||||
|
}
|
||||||
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMtuChanged(g: BluetoothGatt, newMtu: Int, status: Int) {
|
||||||
|
if (closed) return
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
mtu = newMtu
|
||||||
|
}
|
||||||
|
g.discoverServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||||
|
if (closed) return
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
onEvent("Service discovery failed (status $status)")
|
||||||
|
g.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val svc = g.getService(serviceUuid)
|
||||||
|
if (svc == null) {
|
||||||
|
onEvent("Service $serviceUuid not found on device")
|
||||||
|
g.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val wc = svc.getCharacteristic(writeUuid)
|
||||||
|
val nc = svc.getCharacteristic(notifyUuid)
|
||||||
|
if (wc == null || nc == null) {
|
||||||
|
onEvent("UART characteristics not found")
|
||||||
|
g.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wc.writeType =
|
||||||
|
if (wc.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) {
|
||||||
|
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||||
|
} else {
|
||||||
|
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
|
}
|
||||||
|
synchronized(lock) { writeChar = wc }
|
||||||
|
|
||||||
|
g.setCharacteristicNotification(nc, true)
|
||||||
|
val cccd = nc.getDescriptor(CCCD_UUID)
|
||||||
|
if (cccd == null) {
|
||||||
|
onEvent("Notify characteristic has no CCCD")
|
||||||
|
g.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
run {
|
||||||
|
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
g.writeDescriptor(cccd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
|
||||||
|
if (closed) return
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
onEvent("Failed to enable notifications (status $status)")
|
||||||
|
g.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
synchronized(lock) { ready = true }
|
||||||
|
onConnected(true)
|
||||||
|
onEvent("Link ready (MTU $mtu)")
|
||||||
|
kickWrites()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicWrite(
|
||||||
|
g: BluetoothGatt,
|
||||||
|
ch: BluetoothGattCharacteristic,
|
||||||
|
status: Int,
|
||||||
|
) {
|
||||||
|
synchronized(lock) { writeInFlight = false }
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
Log.w(TAG, "write failed: status $status")
|
||||||
|
}
|
||||||
|
kickWrites()
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 33+ delivers notification payloads here.
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
g: BluetoothGatt,
|
||||||
|
ch: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
) {
|
||||||
|
if (ch.uuid == notifyUuid) onData(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-33 path; not invoked on API 33+.
|
||||||
|
@Deprecated("Deprecated in API 33")
|
||||||
|
@Suppress("OVERRIDE_DEPRECATION")
|
||||||
|
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) return
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val value = ch.value ?: return
|
||||||
|
if (ch.uuid == notifyUuid) onData(value.copyOf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
app/src/main/java/dev/wntrmute/bleserial/BridgeService.kt
Normal file
228
app/src/main/java/dev/wntrmute/bleserial/BridgeService.kt
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package dev.wntrmute.bleserial
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that owns one BLE link and one TCP server and pipes
|
||||||
|
* bytes between them.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
class BridgeService : Service() {
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_ADDRESS = "address"
|
||||||
|
const val EXTRA_NAME = "name"
|
||||||
|
const val ACTION_STOP = "dev.wntrmute.bleserial.STOP"
|
||||||
|
private const val TAG = "BridgeService"
|
||||||
|
private const val CHANNEL_ID = "bridge"
|
||||||
|
private const val NOTIF_ID = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var bleLink: BleLink? = null
|
||||||
|
private var tcpServer: TcpServer? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
// Refresh the notification when connection state changes (counters
|
||||||
|
// are excluded so per-packet traffic doesn't spam the notification).
|
||||||
|
scope.launch {
|
||||||
|
BridgeState.status
|
||||||
|
.map { Triple(it.bleConnected, it.clientAddr, it.deviceName) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { if (BridgeState.status.value.running) updateNotification() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stopBridge()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val address = intent?.getStringExtra(EXTRA_ADDRESS)
|
||||||
|
if (address == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
val name = intent.getStringExtra(EXTRA_NAME)
|
||||||
|
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this, NOTIF_ID, buildNotification(),
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
stopBridge()
|
||||||
|
startBridge(address, name)
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopBridge()
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startBridge(address: String, name: String?) {
|
||||||
|
val prefs = Prefs(this)
|
||||||
|
val adapter = getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
if (adapter == null || !adapter.isEnabled) {
|
||||||
|
fail("Bluetooth is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val serviceUuid: UUID
|
||||||
|
val writeUuid: UUID
|
||||||
|
val notifyUuid: UUID
|
||||||
|
try {
|
||||||
|
serviceUuid = UUID.fromString(prefs.serviceUuid)
|
||||||
|
writeUuid = UUID.fromString(prefs.writeUuid)
|
||||||
|
notifyUuid = UUID.fromString(prefs.notifyUuid)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
fail("Invalid UUID in settings: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = try {
|
||||||
|
adapter.getRemoteDevice(address)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
fail("Invalid device address $address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeState.update {
|
||||||
|
BridgeStatus(
|
||||||
|
running = true,
|
||||||
|
deviceName = name,
|
||||||
|
deviceAddress = address,
|
||||||
|
message = "Starting",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val server = TcpServer(
|
||||||
|
bindAddr = prefs.bindAddr,
|
||||||
|
port = prefs.port,
|
||||||
|
onData = { data ->
|
||||||
|
bleLink?.send(data)
|
||||||
|
BridgeState.update { it.copy(bytesToBle = it.bytesToBle + data.size) }
|
||||||
|
},
|
||||||
|
onClient = { peer -> BridgeState.update { it.copy(clientAddr = peer) } },
|
||||||
|
onEvent = { msg -> setMessage(msg) },
|
||||||
|
)
|
||||||
|
val listenAddr = try {
|
||||||
|
server.start()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
fail("TCP bind failed: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tcpServer = server
|
||||||
|
BridgeState.update { it.copy(listenAddr = listenAddr) }
|
||||||
|
|
||||||
|
val link = BleLink(
|
||||||
|
context = this,
|
||||||
|
device = device,
|
||||||
|
serviceUuid = serviceUuid,
|
||||||
|
writeUuid = writeUuid,
|
||||||
|
notifyUuid = notifyUuid,
|
||||||
|
onData = { data ->
|
||||||
|
tcpServer?.send(data)
|
||||||
|
BridgeState.update { it.copy(bytesToTcp = it.bytesToTcp + data.size) }
|
||||||
|
},
|
||||||
|
onConnected = { up -> BridgeState.update { it.copy(bleConnected = up) } },
|
||||||
|
onEvent = { msg -> setMessage(msg) },
|
||||||
|
)
|
||||||
|
bleLink = link
|
||||||
|
link.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopBridge() {
|
||||||
|
bleLink?.close()
|
||||||
|
bleLink = null
|
||||||
|
tcpServer?.stop()
|
||||||
|
tcpServer = null
|
||||||
|
BridgeState.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fail(msg: String) {
|
||||||
|
Log.w(TAG, msg)
|
||||||
|
setMessage(msg)
|
||||||
|
stopBridge()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMessage(msg: String) {
|
||||||
|
Log.i(TAG, msg)
|
||||||
|
BridgeState.update { it.copy(message = msg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.notif_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): android.app.Notification {
|
||||||
|
val s = BridgeState.status.value
|
||||||
|
val text = buildString {
|
||||||
|
append(s.deviceName ?: s.deviceAddress ?: "—")
|
||||||
|
append(if (s.bleConnected) " · BLE up" else " · BLE down")
|
||||||
|
append(s.clientAddr?.let { " · client $it" } ?: " · no client")
|
||||||
|
}
|
||||||
|
val stopIntent = PendingIntent.getService(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, BridgeService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val openIntent = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notif)
|
||||||
|
.setContentTitle(getString(R.string.app_name))
|
||||||
|
.setContentText(text)
|
||||||
|
.setContentIntent(openIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(0, getString(R.string.stop), stopIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification() {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.notify(NOTIF_ID, buildNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/java/dev/wntrmute/bleserial/BridgeState.kt
Normal file
31
app/src/main/java/dev/wntrmute/bleserial/BridgeState.kt
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.wntrmute.bleserial
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
data class BridgeStatus(
|
||||||
|
val running: Boolean = false,
|
||||||
|
val deviceName: String? = null,
|
||||||
|
val deviceAddress: String? = null,
|
||||||
|
val bleConnected: Boolean = false,
|
||||||
|
val listenAddr: String? = null,
|
||||||
|
val clientAddr: String? = null,
|
||||||
|
val bytesToTcp: Long = 0,
|
||||||
|
val bytesToBle: Long = 0,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for bridge status, shared between the foreground
|
||||||
|
* service (writer) and the activity (reader). Survives activity recreation.
|
||||||
|
*/
|
||||||
|
object BridgeState {
|
||||||
|
private val mutable = MutableStateFlow(BridgeStatus())
|
||||||
|
val status: StateFlow<BridgeStatus> = mutable.asStateFlow()
|
||||||
|
|
||||||
|
fun update(transform: (BridgeStatus) -> BridgeStatus) = mutable.update(transform)
|
||||||
|
|
||||||
|
fun reset() = mutable.update { BridgeStatus(message = it.message) }
|
||||||
|
}
|
||||||
208
app/src/main/java/dev/wntrmute/bleserial/MainActivity.kt
Normal file
208
app/src/main/java/dev/wntrmute/bleserial/MainActivity.kt
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package dev.wntrmute.bleserial
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.le.ScanCallback
|
||||||
|
import android.bluetooth.le.ScanResult
|
||||||
|
import android.bluetooth.le.ScanSettings
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.CheckBox
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ListView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
private lateinit var prefs: Prefs
|
||||||
|
private lateinit var statusView: TextView
|
||||||
|
private lateinit var scanButton: Button
|
||||||
|
private lateinit var stopButton: Button
|
||||||
|
private lateinit var portEdit: EditText
|
||||||
|
private lateinit var lanCheck: CheckBox
|
||||||
|
private lateinit var serviceUuidEdit: EditText
|
||||||
|
private lateinit var writeUuidEdit: EditText
|
||||||
|
private lateinit var notifyUuidEdit: EditText
|
||||||
|
private lateinit var deviceList: ListView
|
||||||
|
private lateinit var adapter: ArrayAdapter<String>
|
||||||
|
|
||||||
|
private val devices = mutableListOf<ScanResult>()
|
||||||
|
private var scanning = false
|
||||||
|
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||||
|
if (grants.values.all { it }) {
|
||||||
|
startScan()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, R.string.perms_needed, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
prefs = Prefs(this)
|
||||||
|
|
||||||
|
statusView = findViewById(R.id.status)
|
||||||
|
scanButton = findViewById(R.id.scan)
|
||||||
|
stopButton = findViewById(R.id.stop)
|
||||||
|
portEdit = findViewById(R.id.port)
|
||||||
|
lanCheck = findViewById(R.id.allow_lan)
|
||||||
|
serviceUuidEdit = findViewById(R.id.service_uuid)
|
||||||
|
writeUuidEdit = findViewById(R.id.write_uuid)
|
||||||
|
notifyUuidEdit = findViewById(R.id.notify_uuid)
|
||||||
|
deviceList = findViewById(R.id.devices)
|
||||||
|
|
||||||
|
portEdit.setText(prefs.port.toString())
|
||||||
|
lanCheck.isChecked = prefs.allowLan
|
||||||
|
serviceUuidEdit.setText(prefs.serviceUuid)
|
||||||
|
writeUuidEdit.setText(prefs.writeUuid)
|
||||||
|
notifyUuidEdit.setText(prefs.notifyUuid)
|
||||||
|
|
||||||
|
adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1)
|
||||||
|
deviceList.adapter = adapter
|
||||||
|
deviceList.setOnItemClickListener { _, _, pos, _ ->
|
||||||
|
stopScan()
|
||||||
|
startBridge(devices[pos])
|
||||||
|
}
|
||||||
|
|
||||||
|
scanButton.setOnClickListener {
|
||||||
|
if (scanning) stopScan() else requestPermissionsAndScan()
|
||||||
|
}
|
||||||
|
stopButton.setOnClickListener {
|
||||||
|
startService(
|
||||||
|
Intent(this, BridgeService::class.java).setAction(BridgeService.ACTION_STOP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
BridgeState.status.collect { render(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
stopScan()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(s: BridgeStatus) {
|
||||||
|
statusView.text = if (!s.running) {
|
||||||
|
getString(R.string.status_idle, s.message ?: "")
|
||||||
|
} else {
|
||||||
|
getString(
|
||||||
|
R.string.status_running,
|
||||||
|
s.deviceName ?: s.deviceAddress ?: "?",
|
||||||
|
if (s.bleConnected) "up" else "down",
|
||||||
|
s.listenAddr ?: "?",
|
||||||
|
s.clientAddr ?: "none",
|
||||||
|
s.bytesToTcp,
|
||||||
|
s.bytesToBle,
|
||||||
|
s.message ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stopButton.isEnabled = s.running
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requiredPermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
buildList {
|
||||||
|
add(Manifest.permission.BLUETOOTH_SCAN)
|
||||||
|
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}.toTypedArray()
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestPermissionsAndScan() {
|
||||||
|
val missing = requiredPermissions().filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isEmpty()) startScan() else permissionLauncher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startScan() {
|
||||||
|
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) {
|
||||||
|
Toast.makeText(this, R.string.bt_disabled, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scanner = bluetoothAdapter.bluetoothLeScanner ?: return
|
||||||
|
devices.clear()
|
||||||
|
adapter.clear()
|
||||||
|
scanning = true
|
||||||
|
scanButton.setText(R.string.stop_scan)
|
||||||
|
scanner.startScan(
|
||||||
|
null,
|
||||||
|
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
||||||
|
scanCallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopScan() {
|
||||||
|
if (!scanning) return
|
||||||
|
scanning = false
|
||||||
|
scanButton.setText(R.string.scan)
|
||||||
|
getSystemService(BluetoothManager::class.java)
|
||||||
|
.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
val idx = devices.indexOfFirst { it.device.address == result.device.address }
|
||||||
|
if (idx >= 0) {
|
||||||
|
devices[idx] = result
|
||||||
|
} else {
|
||||||
|
devices.add(result)
|
||||||
|
adapter.add(label(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
scanning = false
|
||||||
|
scanButton.setText(R.string.scan)
|
||||||
|
Toast.makeText(this@MainActivity, "Scan failed: $errorCode", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun label(r: ScanResult): String {
|
||||||
|
val name = r.scanRecord?.deviceName ?: r.device.name ?: "(unnamed)"
|
||||||
|
return "$name\n${r.device.address} ${r.rssi} dBm"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startBridge(result: ScanResult) {
|
||||||
|
val port = portEdit.text.toString().toIntOrNull()
|
||||||
|
if (port == null || port !in 1..65535) {
|
||||||
|
Toast.makeText(this, R.string.bad_port, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.port = port
|
||||||
|
prefs.allowLan = lanCheck.isChecked
|
||||||
|
prefs.serviceUuid = serviceUuidEdit.text.toString().trim()
|
||||||
|
prefs.writeUuid = writeUuidEdit.text.toString().trim()
|
||||||
|
prefs.notifyUuid = notifyUuidEdit.text.toString().trim()
|
||||||
|
|
||||||
|
val intent = Intent(this, BridgeService::class.java)
|
||||||
|
.putExtra(BridgeService.EXTRA_ADDRESS, result.device.address)
|
||||||
|
.putExtra(
|
||||||
|
BridgeService.EXTRA_NAME,
|
||||||
|
result.scanRecord?.deviceName ?: result.device.name,
|
||||||
|
)
|
||||||
|
ContextCompat.startForegroundService(this, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/src/main/java/dev/wntrmute/bleserial/Prefs.kt
Normal file
39
app/src/main/java/dev/wntrmute/bleserial/Prefs.kt
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.wntrmute.bleserial
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
/** Nordic UART Service — also used by RNode firmware for BLE serial. */
|
||||||
|
object Defaults {
|
||||||
|
const val SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
|
||||||
|
const val WRITE_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
|
||||||
|
const val NOTIFY_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
|
||||||
|
const val PORT = 7777
|
||||||
|
}
|
||||||
|
|
||||||
|
class Prefs(context: Context) {
|
||||||
|
private val sp = context.getSharedPreferences("ble-serial", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var port: Int
|
||||||
|
get() = sp.getInt("port", Defaults.PORT)
|
||||||
|
set(v) = sp.edit { putInt("port", v) }
|
||||||
|
|
||||||
|
var allowLan: Boolean
|
||||||
|
get() = sp.getBoolean("allow_lan", false)
|
||||||
|
set(v) = sp.edit { putBoolean("allow_lan", v) }
|
||||||
|
|
||||||
|
var serviceUuid: String
|
||||||
|
get() = sp.getString("service_uuid", Defaults.SERVICE_UUID)!!
|
||||||
|
set(v) = sp.edit { putString("service_uuid", v) }
|
||||||
|
|
||||||
|
var writeUuid: String
|
||||||
|
get() = sp.getString("write_uuid", Defaults.WRITE_UUID)!!
|
||||||
|
set(v) = sp.edit { putString("write_uuid", v) }
|
||||||
|
|
||||||
|
var notifyUuid: String
|
||||||
|
get() = sp.getString("notify_uuid", Defaults.NOTIFY_UUID)!!
|
||||||
|
set(v) = sp.edit { putString("notify_uuid", v) }
|
||||||
|
|
||||||
|
val bindAddr: String
|
||||||
|
get() = if (allowLan) "0.0.0.0" else "127.0.0.1"
|
||||||
|
}
|
||||||
107
app/src/main/java/dev/wntrmute/bleserial/TcpServer.kt
Normal file
107
app/src/main/java/dev/wntrmute/bleserial/TcpServer.kt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package dev.wntrmute.bleserial
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-client TCP server. Bytes from the client go to [onData]; bytes from
|
||||||
|
* the BLE side are pushed in via [send]. A new connection displaces the
|
||||||
|
* current client (last-wins), which lets socat reconnect cleanly.
|
||||||
|
*/
|
||||||
|
class TcpServer(
|
||||||
|
private val bindAddr: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val onData: (ByteArray) -> Unit,
|
||||||
|
private val onClient: (String?) -> Unit,
|
||||||
|
private val onEvent: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TcpServer"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var closed = false
|
||||||
|
@Volatile private var serverSocket: ServerSocket? = null
|
||||||
|
@Volatile private var client: Socket? = null
|
||||||
|
@Volatile private var clientOut: OutputStream? = null
|
||||||
|
private var acceptThread: Thread? = null
|
||||||
|
|
||||||
|
/** Returns the actual listen address, or throws if the bind fails. */
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun start(): String {
|
||||||
|
val ss = ServerSocket(port, 1, InetAddress.getByName(bindAddr))
|
||||||
|
serverSocket = ss
|
||||||
|
acceptThread = thread(name = "tcp-accept") { acceptLoop(ss) }
|
||||||
|
return "$bindAddr:${ss.localPort}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
closed = true
|
||||||
|
runCatching { serverSocket?.close() }
|
||||||
|
dropClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(data: ByteArray): Boolean {
|
||||||
|
val out = clientOut ?: return false
|
||||||
|
return try {
|
||||||
|
out.write(data)
|
||||||
|
out.flush()
|
||||||
|
true
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, "client write failed: ${e.message}")
|
||||||
|
dropClient()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acceptLoop(ss: ServerSocket) {
|
||||||
|
while (!closed) {
|
||||||
|
val sock = try {
|
||||||
|
ss.accept()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
if (!closed) Log.w(TAG, "accept failed: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sock.tcpNoDelay = true
|
||||||
|
dropClient()
|
||||||
|
client = sock
|
||||||
|
clientOut = sock.getOutputStream()
|
||||||
|
val peer = "${sock.inetAddress.hostAddress}:${sock.port}"
|
||||||
|
onClient(peer)
|
||||||
|
onEvent("Client connected: $peer")
|
||||||
|
thread(name = "tcp-read") { readLoop(sock) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLoop(sock: Socket) {
|
||||||
|
val buf = ByteArray(4096)
|
||||||
|
try {
|
||||||
|
val input = sock.getInputStream()
|
||||||
|
while (!closed) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
if (n > 0) onData(buf.copyOf(n))
|
||||||
|
}
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// Fall through to cleanup.
|
||||||
|
}
|
||||||
|
if (client === sock) {
|
||||||
|
dropClient()
|
||||||
|
if (!closed) onEvent("Client disconnected")
|
||||||
|
} else {
|
||||||
|
runCatching { sock.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropClient() {
|
||||||
|
val c = client ?: return
|
||||||
|
client = null
|
||||||
|
clientOut = null
|
||||||
|
runCatching { c.close() }
|
||||||
|
onClient(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/src/main/res/drawable/ic_launcher.xml
Normal file
13
app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1A73E8"
|
||||||
|
android:pathData="M0,0h24v24h-24z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M14.88,16.29L13,18.17V14.41M13,5.83L14.88,7.71L13,9.58M17.71,7.71L12,2H11V9.58L6.41,5L5,6.41L10.59,12L5,17.58L6.41,19L11,14.41V22H12L17.71,16.29L13.41,12L17.71,7.71Z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_notif.xml
Normal file
10
app/src/main/res/drawable/ic_notif.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M14.88,16.29L13,18.17V14.41M13,5.83L14.88,7.71L13,9.58M17.71,7.71L12,2H11V9.58L6.41,5L5,6.41L10.59,12L5,17.58L6.41,19L11,14.41V22H12L17.71,16.29L13.41,12L17.71,7.71Z" />
|
||||||
|
</vector>
|
||||||
97
app/src/main/res/layout/activity_main.xml
Normal file
97
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?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:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:text="@string/status_placeholder" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/port"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/port_hint"
|
||||||
|
android:inputType="number"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/allow_lan"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/allow_lan" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/service_uuid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/service_uuid_hint"
|
||||||
|
android:inputType="textNoSuggestions"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/write_uuid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/write_uuid_hint"
|
||||||
|
android:inputType="textNoSuggestions"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/notify_uuid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/notify_uuid_hint"
|
||||||
|
android:inputType="textNoSuggestions"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/scan"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scan" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/stop"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/stop_bridge" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ListView
|
||||||
|
android:id="@+id/devices"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginTop="8dp" />
|
||||||
|
</LinearLayout>
|
||||||
20
app/src/main/res/values/strings.xml
Normal file
20
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">BLE Serial Bridge</string>
|
||||||
|
<string name="notif_channel">Bridge status</string>
|
||||||
|
<string name="scan">Scan</string>
|
||||||
|
<string name="stop_scan">Stop scan</string>
|
||||||
|
<string name="stop">Stop</string>
|
||||||
|
<string name="stop_bridge">Stop bridge</string>
|
||||||
|
<string name="port_hint">TCP port</string>
|
||||||
|
<string name="allow_lan">Allow LAN</string>
|
||||||
|
<string name="service_uuid_hint">Service UUID</string>
|
||||||
|
<string name="write_uuid_hint">Write (TX to device) characteristic UUID</string>
|
||||||
|
<string name="notify_uuid_hint">Notify (RX from device) characteristic UUID</string>
|
||||||
|
<string name="perms_needed">Bluetooth permissions are required to scan and connect.</string>
|
||||||
|
<string name="bt_disabled">Bluetooth is disabled. Enable it and try again.</string>
|
||||||
|
<string name="bad_port">Port must be between 1 and 65535.</string>
|
||||||
|
<string name="status_placeholder">Idle.</string>
|
||||||
|
<string name="status_idle">Idle. %1$s</string>
|
||||||
|
<string name="status_running">Device: %1$s\nBLE: %2$s\nListening: %3$s\nClient: %4$s\nBLE→TCP: %5$d B TCP→BLE: %6$d B\n%7$s</string>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.BleSerial" parent="Theme.Material3.DayNight" />
|
||||||
|
</resources>
|
||||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
|
}
|
||||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "ble-serial"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user