mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(service): harden KMP service layer — database init, connection reliability, handler decomposition (#4992)
This commit is contained in:
parent
e111b61e4e
commit
6af3ad6f0c
62 changed files with 3808 additions and 735 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue