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:
2026-06-09 16:44:53 -07:00
commit 26146ec8c3
18 changed files with 1235 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.gradle/
build/
local.properties
.idea/
.vscode/
*.iml
.DS_Store
.kotlin/

117
README.md Normal file
View 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 MTU3, 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
View 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")
}

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

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

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

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

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

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

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

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

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

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

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

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