refactor(service): harden KMP service layer — database init, connection reliability, handler decomposition (#4992)

This commit is contained in:
James Rich 2026-04-04 13:07:44 -05:00 committed by GitHub
parent e111b61e4e
commit 6af3ad6f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 3808 additions and 735 deletions

View file

@ -21,8 +21,9 @@ package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
@ -36,7 +37,6 @@ import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
@ -84,6 +84,7 @@ internal fun computeReconnectBackoffMs(consecutiveFailures: Int): Long {
private const val CCCD_SETTLE_MS = 50L
private val SCAN_TIMEOUT = 5.seconds
private val GATT_CLEANUP_TIMEOUT = 5.seconds
/**
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
@ -157,7 +158,7 @@ class BleRadioInterface(
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
Logger.i { "[$address] Device not found in bonded list, scanning" }
repeat(SCAN_RETRY_COUNT) { attempt ->
try {
@ -169,7 +170,7 @@ class BleRadioInterface(
}
if (d != null) return d
} catch (e: Exception) {
Logger.v(e) { "Scan attempt failed or timed out" }
Logger.v(e) { "[$address] Scan attempt failed or timed out" }
}
if (attempt < SCAN_RETRY_COUNT - 1) {
@ -182,106 +183,107 @@ class BleRadioInterface(
@Suppress("LongMethod")
private fun connect() {
connectionJob = connectionScope.launch {
while (isActive) {
try {
// Allow any pending background disconnects to complete and the Android BLE stack
// to settle before we attempt a new connection.
@Suppress("MagicNumber")
val connectDelayMs = 1000L
delay(connectDelayMs)
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
val device = findDevice()
// Ensure the device is bonded before connecting. On Android, the
// firmware may require an encrypted link (pairing mode != NO_PIN).
// Without an explicit bond the GATT connection will fail with
// insufficient-authentication (status 5) or the dreaded status 133.
// On Desktop/JVM this is a no-op since the OS handles pairing during
// the GATT connection when the peripheral requires it.
if (!bluetoothRepository.isBonded(address)) {
Logger.i { "[$address] Device not bonded, initiating bonding..." }
@Suppress("TooGenericExceptionCaught")
try {
bluetoothRepository.bond(device)
Logger.i { "[$address] Bonding successful" }
} catch (e: Exception) {
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
}
}
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
// Kable on Android occasionally fails the first connection attempt with
// NotConnectedException if the previous peripheral wasn't fully cleaned
// up by the OS. A quick retry resolves it.
Logger.w { "[$address] First connection attempt failed, retrying in 1.5s..." }
connectionJob =
connectionScope.launch {
while (isActive) {
try {
// Allow any pending background disconnects to complete and the Android BLE stack
// to settle before we attempt a new connection.
@Suppress("MagicNumber")
delay(1500L)
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
}
val connectDelayMs = 1000L
delay(connectDelayMs)
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
// Connection succeeded — reset failure counter
consecutiveFailures = 0
isFullyConnected = true
onConnected()
val device = findDevice()
// Use coroutineScope so that the connectionState listener is scoped to this
// iteration only. When the inner scope exits (on disconnect), the listener is
// cancelled automatically before the next reconnect cycle starts a fresh one.
coroutineScope {
bleConnection.connectionState
.onEach { s ->
if (s is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
onDisconnected()
}
// Ensure the device is bonded before connecting. On Android, the
// firmware may require an encrypted link (pairing mode != NO_PIN).
// Without an explicit bond the GATT connection will fail with
// insufficient-authentication (status 5) or the dreaded status 133.
// On Desktop/JVM this is a no-op since the OS handles pairing during
// the GATT connection when the peripheral requires it.
if (!bluetoothRepository.isBonded(address)) {
Logger.i { "[$address] Device not bonded, initiating bonding" }
@Suppress("TooGenericExceptionCaught")
try {
bluetoothRepository.bond(device)
Logger.i { "[$address] Bonding successful" }
} catch (e: Exception) {
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
}
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" } }
.launchIn(this)
}
discoverServicesAndSetupCharacteristics()
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
if (state !is BleConnectionState.Connected) {
// Kable on Android occasionally fails the first connection attempt with
// NotConnectedException if the previous peripheral wasn't fully cleaned
// up by the OS. A quick retry resolves it.
Logger.d { "[$address] First connection attempt failed, retrying in 1.5s" }
@Suppress("MagicNumber")
delay(1500L)
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
}
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
// Connection succeeded — reset failure counter
consecutiveFailures = 0
isFullyConnected = true
onConnected()
// Use coroutineScope so that the connectionState listener is scoped to this
// iteration only. When the inner scope exits (on disconnect), the listener is
// cancelled automatically before the next reconnect cycle starts a fresh one.
coroutineScope {
bleConnection.connectionState
.onEach { s ->
if (s is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
onDisconnected()
}
}
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } }
.launchIn(this)
discoverServicesAndSetupCharacteristics()
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
}
Logger.i { "[$address] BLE connection dropped, preparing to reconnect" }
} catch (e: kotlinx.coroutines.CancellationException) {
Logger.d { "[$address] BLE connection coroutine cancelled" }
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
consecutiveFailures++
Logger.w(e) {
"[$address] Failed to connect to device after ${failureTime}ms " +
"(consecutive failures: $consecutiveFailures)"
}
// At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can
// start its sleep timeout. Use == (not >=) to fire exactly once; repeated
// onDisconnect signals would reset upstream state machines unnecessarily.
if (consecutiveFailures == RECONNECT_FAILURE_THRESHOLD) {
handleFailure(e)
}
// Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
// Reduces BLE stack pressure and battery drain when the device is genuinely
// out of range, while still recovering quickly from transient drops.
val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
delay(backoffMs)
}
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
} catch (e: kotlinx.coroutines.CancellationException) {
Logger.d { "[$address] BLE connection coroutine cancelled" }
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
consecutiveFailures++
Logger.w(e) {
"[$address] Failed to connect to device after ${failureTime}ms " +
"(consecutive failures: $consecutiveFailures)"
}
// At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can
// start its sleep timeout. Use == (not >=) to fire exactly once; repeated
// onDisconnect signals would reset upstream state machines unnecessarily.
if (consecutiveFailures == RECONNECT_FAILURE_THRESHOLD) {
handleFailure(e)
}
// Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
// Reduces BLE stack pressure and battery drain when the device is genuinely
// out of range, while still recovering quickly from transient drops.
val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
delay(backoffMs)
}
}
}
}
private suspend fun onConnected() {
@ -304,8 +306,8 @@ class BleRadioInterface(
} else {
0
}
Logger.w {
"[$address] BLE disconnected, " +
Logger.i {
"[$address] BLE disconnected - " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
@ -324,7 +326,7 @@ class BleRadioInterface(
// Wire up notifications
radioService.fromRadio
.onEach { packet ->
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
@ -335,7 +337,7 @@ class BleRadioInterface(
radioService.logRadio
.onEach { packet ->
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
@ -393,10 +395,9 @@ class BleRadioInterface(
retryBleOperation(tag = address) { currentService.sendToRadio(p) }
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Successfully wrote packet #$packetsSent " +
"to toRadioCharacteristic - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
Logger.v {
"[$address] Wrote packet #$packetsSent " +
"to toRadio (${p.size} bytes, total TX: $bytesSent bytes)"
}
} catch (e: Exception) {
Logger.w(e) {
@ -422,7 +423,7 @@ class BleRadioInterface(
// Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the
// firmware's per-connection duplicate-write filter from silently dropping it.
val nonce = heartbeatNonce.fetchAndAdd(1)
Logger.d { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode())
}
@ -437,19 +438,18 @@ class BleRadioInterface(
}
// Cancel the connection scope to break the while(isActive) reconnect loop.
connectionScope.cancel("close() called")
// GATT cleanup must run regardless of serviceScope lifecycle. SharedRadioInterfaceService
// cancels serviceScope immediately after calling close(), so launching on serviceScope is
// not reliable — the coroutine may never start. We use withContext(NonCancellable) inside
// a serviceScope.launch to guarantee cleanup completes even if the scope is cancelled
// mid-flight, preventing leaked BluetoothGatt objects (GATT 133 errors).
// GATT cleanup must survive serviceScope cancellation. SharedRadioInterfaceService calls
// close() and then immediately cancels serviceScope — a coroutine launched on serviceScope
// may never be dispatched, leaving the BluetoothGatt object leaked (causes GATT 133 on the
// next connect attempt). GlobalScope is the correct tool here: the cleanup is short-lived,
// fire-and-forget, and must outlive any application-managed scope.
// onDisconnect is handled by SharedRadioInterfaceService.stopInterfaceLocked() directly.
serviceScope.launch {
withContext(NonCancellable) {
try {
bleConnection.disconnect()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in close()" }
}
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch {
try {
withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in close()" }
}
}
}
@ -457,9 +457,9 @@ class BleRadioInterface(
private fun dispatchPacket(packet: ByteArray) {
packetsReceived++
bytesReceived += packet.size
Logger.d {
"[$address] Dispatching packet to service.handleFromRadio() - " +
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
Logger.v {
"[$address] Dispatching packet #$packetsReceived " +
"(${packet.size} bytes, total RX: $bytesReceived bytes)"
}
service.handleFromRadio(packet)
}

View file

@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
@ -34,12 +33,14 @@ import java.io.OutputStream
import java.net.InetAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Shared JVM TCP transport for Meshtastic radios.
*
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff, heartbeat) and uses [StreamFrameCodec]
* for the START1/START2 stream framing protocol.
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff) and uses [StreamFrameCodec] for the
* START1/START2 stream framing protocol. Heartbeat scheduling is owned by [SharedRadioInterfaceService]; this class
* only exposes [sendHeartbeat] for external callers.
*
* Used by Android and Desktop via the shared `SharedRadioInterfaceService`.
*/
@ -69,18 +70,24 @@ class TcpTransport(
const val MAX_BACKOFF_MILLIS = 5 * 60 * 1_000L
const val SOCKET_TIMEOUT_MS = 5_000
const val SOCKET_RETRIES = 18 // 18 * 5s = 90s inactivity before disconnect
const val HEARTBEAT_INTERVAL_MILLIS = 30_000L
const val TIMEOUT_LOG_INTERVAL = 5
private const val MILLIS_PER_SECOND = 1_000L
}
private val codec = StreamFrameCodec(onPacketReceived = { listener.onPacketReceived(it) }, logTag = logTag)
private val codec =
StreamFrameCodec(
onPacketReceived = {
packetsReceived++
listener.onPacketReceived(it)
},
logTag = logTag,
)
// TCP socket state
private var socket: Socket? = null
private var outStream: OutputStream? = null
private var connectionJob: Job? = null
private var heartbeatJob: Job? = null
private var currentAddress: String? = null
// Metrics
private var connectionStartTime: Long = 0
@ -101,6 +108,7 @@ class TcpTransport(
*/
fun start(address: String) {
stop()
currentAddress = address
connectionJob = scope.handledLaunch { connectWithRetry(address) }
}
@ -109,6 +117,7 @@ class TcpTransport(
connectionJob?.cancel()
connectionJob = null
disconnectSocket()
currentAddress = null
}
/**
@ -134,14 +143,25 @@ class TcpTransport(
var backoff = MIN_BACKOFF_MILLIS
while (retryCount <= MAX_RECONNECT_RETRIES) {
try {
connectAndRead(address)
} catch (ex: IOException) {
Logger.w { "$logTag: [$address] TCP connection error - ${ex.message}" }
disconnectSocket()
} catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) {
Logger.e(ex) { "$logTag: [$address] TCP exception - ${ex.message}" }
disconnectSocket()
val hadData =
try {
connectAndRead(address)
} catch (ex: IOException) {
Logger.w { "$logTag: [$address] TCP connection error" }
disconnectSocket()
false
} catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) {
Logger.e(ex) { "$logTag: [$address] TCP exception" }
disconnectSocket()
false
}
// Reset backoff after a connection that successfully exchanged data,
// so transient firmware-side disconnects recover quickly.
if (hadData) {
Logger.d { "$logTag: [$address] Resetting backoff after successful data exchange" }
retryCount = 1
backoff = MIN_BACKOFF_MILLIS
}
val delaySec = backoff / MILLIS_PER_SECOND
@ -152,13 +172,17 @@ class TcpTransport(
}
}
/**
* Connect to the given address, read data until the connection is lost, and return whether any bytes were
* successfully received (used by [connectWithRetry] to decide whether to reset backoff).
*/
@Suppress("NestedBlockDepth")
private suspend fun connectAndRead(address: String) = withContext(dispatchers.io) {
private suspend fun connectAndRead(address: String): Boolean = withContext(dispatchers.io) {
val parts = address.split(":", limit = 2)
val host = parts[0]
val port = parts.getOrNull(1)?.toIntOrNull() ?: StreamFrameCodec.DEFAULT_TCP_PORT
Logger.i { "$logTag: [$address] Connecting to $host:$port..." }
Logger.i { "$logTag: [$address] Connecting to $host:$port" }
val attemptStart = nowMillis
Socket(InetAddress.getByName(host), port).use { sock ->
@ -181,7 +205,6 @@ class TcpTransport(
// Send wake bytes and signal connected
sendBytesRaw(StreamFrameCodec.WAKE_BYTES)
listener.onConnected()
startHeartbeat(address)
// Read loop
var timeoutCount = 0
@ -189,7 +212,7 @@ class TcpTransport(
try {
val c = input.read()
if (c == -1) {
Logger.w { "$logTag: [$address] EOF after $packetsReceived packets" }
Logger.i { "$logTag: [$address] EOF after $packetsReceived packets" }
break
}
timeoutCount = 0
@ -209,27 +232,25 @@ class TcpTransport(
}
}
}
val hadData = bytesReceived > 0
disconnectSocket()
hadData
}
}
// Guards against recursive disconnects triggered by listener callbacks.
private var isDisconnecting: Boolean = false
private val isDisconnecting = AtomicBoolean(false)
private fun disconnectSocket() {
if (isDisconnecting) return
if (!isDisconnecting.compareAndSet(false, true)) return
isDisconnecting = true
try {
heartbeatJob?.cancel()
heartbeatJob = null
val s = socket
val hadConnection = s != null || outStream != null
if (s != null) {
val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
Logger.i {
"$logTag: Disconnecting - Uptime: ${uptime}ms, " +
"$logTag: [$currentAddress] Disconnecting - Uptime: ${uptime}ms, " +
"RX: $packetsReceived ($bytesReceived bytes), " +
"TX: $packetsSent ($bytesSent bytes)"
}
@ -247,7 +268,7 @@ class TcpTransport(
listener.onDisconnected()
}
} finally {
isDisconnecting = false
isDisconnecting.set(false)
}
}
@ -259,7 +280,7 @@ class TcpTransport(
val stream =
outStream
?: run {
Logger.w { "$logTag: Cannot send ${p.size} bytes: not connected" }
Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" }
return
}
packetsSent++
@ -267,7 +288,7 @@ class TcpTransport(
try {
stream.write(p)
} catch (ex: IOException) {
Logger.w(ex) { "$logTag: TCP write error: ${ex.message}" }
Logger.w(ex) { "$logTag: [$currentAddress] TCP write error" }
disconnectSocket()
}
}
@ -277,28 +298,13 @@ class TcpTransport(
try {
stream.flush()
} catch (ex: IOException) {
Logger.w(ex) { "$logTag: TCP flush error: ${ex.message}" }
Logger.w(ex) { "$logTag: [$currentAddress] TCP flush error" }
disconnectSocket()
}
}
// endregion
// region Heartbeat
private fun startHeartbeat(address: String) {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (true) {
delay(HEARTBEAT_INTERVAL_MILLIS)
Logger.d { "$logTag: [$address] Sending heartbeat" }
sendHeartbeat()
}
}
}
// endregion
private fun resetMetrics() {
packetsReceived = 0
packetsSent = 0

View file

@ -25,12 +25,17 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.meshtastic.core.network.radio.StreamInterface
import org.meshtastic.core.repository.RadioInterfaceService
import java.io.File
/**
* JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamInterface] for START1/START2 packet
* framing.
*
* Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read
* loop is started.
*/
class SerialTransport(
class SerialTransport
private constructor(
private val portName: String,
private val baudRate: Int = DEFAULT_BAUD_RATE,
service: RadioInterfaceService,
@ -39,7 +44,7 @@ class SerialTransport(
private var readJob: Job? = null
/** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */
fun startConnection(): Boolean {
private fun startConnection(): Boolean {
return try {
val port = SerialPort.getCommPort(portName) ?: return false
port.setComPortParameters(baudRate, DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY)
@ -48,20 +53,23 @@ class SerialTransport(
serialPort = port
port.setDTR()
port.setRTS()
Logger.i { "[$portName] Serial port opened (baud=$baudRate)" }
super.connect() // Sends WAKE_BYTES and signals service.onConnect()
startReadLoop(port)
true
} else {
Logger.w { "[$portName] Serial port openPort() returned false" }
false
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Serial connection failed" }
Logger.w(e) { "[$portName] Serial connection failed" }
false
}
}
@Suppress("CyclomaticComplexMethod")
private fun startReadLoop(port: SerialPort) {
Logger.d { "[$portName] Starting serial read loop" }
readJob =
service.serviceScope.launch(Dispatchers.IO) {
val input = port.inputStream
@ -84,9 +92,9 @@ class SerialTransport(
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
if (isActive) {
Logger.e(e) { "Serial read IOException: ${e.message}" }
Logger.w(e) { "[$portName] Serial read error" }
} else {
Logger.d { "Serial read interrupted by cancellation: ${e.message}" }
Logger.d { "[$portName] Serial read interrupted by cancellation" }
}
reading = false
}
@ -95,11 +103,12 @@ class SerialTransport(
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
if (isActive) {
Logger.e(e) { "Serial read loop outer error: ${e.message}" }
Logger.w(e) { "[$portName] Serial read loop outer error" }
} else {
Logger.d { "Serial read loop outer interrupted by cancellation: ${e.message}" }
Logger.d { "[$portName] Serial read loop interrupted by cancellation" }
}
} finally {
Logger.d { "[$portName] Serial read loop exiting" }
try {
input.close()
} catch (_: Exception) {
@ -137,6 +146,7 @@ class SerialTransport(
}
override fun close() {
Logger.d { "[$portName] Closing serial transport" }
readJob?.cancel()
readJob = null
closePortResources()
@ -149,10 +159,64 @@ class SerialTransport(
private const val READ_BUFFER_SIZE = 1024
private const val READ_TIMEOUT_MS = 100
/**
* Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
* disconnect to the [service] and returns the (non-connected) instance.
*/
fun open(portName: String, baudRate: Int = DEFAULT_BAUD_RATE, service: RadioInterfaceService): SerialTransport {
val transport = SerialTransport(portName, baudRate, service)
if (!transport.startConnection()) {
val errorMessage = diagnoseOpenFailure(portName)
Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
service.onDisconnect(isPermanent = true, errorMessage = errorMessage)
}
return transport
}
/**
* Discovers and returns a list of available serial ports. Returns a list of the system port names (e.g.,
* "COM3", "/dev/ttyUSB0").
*/
fun getAvailablePorts(): List<String> = SerialPort.getCommPorts().map { it.systemPortName }
/**
* Diagnoses why a serial port could not be opened and returns a user-facing error message. On Linux, checks
* file permissions and suggests the appropriate group fix.
*/
@Suppress("ReturnCount")
private fun diagnoseOpenFailure(portName: String): String {
val osName = System.getProperty("os.name", "").lowercase()
if (!osName.contains("linux")) {
return "Could not open serial port: $portName"
}
// jSerialComm resolves bare names like "ttyUSB0" to "/dev/ttyUSB0"
val devPath = if (portName.startsWith("/")) portName else "/dev/$portName"
val portFile = File(devPath)
if (!portFile.exists()) {
return "Serial port $portName not found. Is the device still connected?"
}
if (!portFile.canRead() || !portFile.canWrite()) {
val group = detectSerialGroup(devPath)
val user = System.getProperty("user.name", "your_user")
return "Permission denied for $devPath. " +
"Run: sudo usermod -aG $group $user — then log out and back in."
}
return "Could not open serial port: $portName"
}
/**
* Attempts to detect the group that owns the serial device file. Falls back to "dialout" (Debian/Ubuntu
* default) if detection fails.
*/
@Suppress("SwallowedException", "TooGenericExceptionCaught")
private fun detectSerialGroup(devPath: String): String = try {
val process = ProcessBuilder("stat", "-c", "%G", devPath).redirectErrorStream(true).start()
val group = process.inputStream.bufferedReader().readText().trim()
process.waitFor()
group.ifEmpty { "dialout" }
} catch (e: Exception) {
"dialout"
}
}
}