commit 26146ec8c3a8d2802910434e1536ddd27ad2232c Author: Kyle Isom Date: Tue Jun 9 16:44:53 2026 -0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a9e757 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +local.properties +.idea/ +.vscode/ +*.iml +.DS_Store +.kotlin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..30b1445 --- /dev/null +++ b/README.md @@ -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::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::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). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..cb695d3 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fa7530b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/dev/wntrmute/bleserial/BleLink.kt b/app/src/main/java/dev/wntrmute/bleserial/BleLink.kt new file mode 100644 index 0000000..7653d30 --- /dev/null +++ b/app/src/main/java/dev/wntrmute/bleserial/BleLink.kt @@ -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() + + 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()) + } + } +} diff --git a/app/src/main/java/dev/wntrmute/bleserial/BridgeService.kt b/app/src/main/java/dev/wntrmute/bleserial/BridgeService.kt new file mode 100644 index 0000000..54d88d2 --- /dev/null +++ b/app/src/main/java/dev/wntrmute/bleserial/BridgeService.kt @@ -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()) + } +} diff --git a/app/src/main/java/dev/wntrmute/bleserial/BridgeState.kt b/app/src/main/java/dev/wntrmute/bleserial/BridgeState.kt new file mode 100644 index 0000000..519e6af --- /dev/null +++ b/app/src/main/java/dev/wntrmute/bleserial/BridgeState.kt @@ -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 = mutable.asStateFlow() + + fun update(transform: (BridgeStatus) -> BridgeStatus) = mutable.update(transform) + + fun reset() = mutable.update { BridgeStatus(message = it.message) } +} diff --git a/app/src/main/java/dev/wntrmute/bleserial/MainActivity.kt b/app/src/main/java/dev/wntrmute/bleserial/MainActivity.kt new file mode 100644 index 0000000..21c8a40 --- /dev/null +++ b/app/src/main/java/dev/wntrmute/bleserial/MainActivity.kt @@ -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 + + private val devices = mutableListOf() + 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 = + 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) + } +} diff --git a/app/src/main/java/dev/wntrmute/bleserial/Prefs.kt b/app/src/main/java/dev/wntrmute/bleserial/Prefs.kt new file mode 100644 index 0000000..ee151f4 --- /dev/null +++ b/app/src/main/java/dev/wntrmute/bleserial/Prefs.kt @@ -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" +} diff --git a/app/src/main/java/dev/wntrmute/bleserial/TcpServer.kt b/app/src/main/java/dev/wntrmute/bleserial/TcpServer.kt new file mode 100644 index 0000000..70a6295 --- /dev/null +++ b/app/src/main/java/dev/wntrmute/bleserial/TcpServer.kt @@ -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) + } +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..e6d8162 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_notif.xml b/app/src/main/res/drawable/ic_notif.xml new file mode 100644 index 0000000..5cd1182 --- /dev/null +++ b/app/src/main/res/drawable/ic_notif.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..a738118 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + +