mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
fix(conductor): Apply review suggestions for track 'extract_radio_interface_kmp_20260320'
This commit is contained in:
parent
ef8c5878ff
commit
eeeeb11df4
12 changed files with 468 additions and 665 deletions
|
|
@ -10,5 +10,5 @@ This file tracks all major tracks for the project. Each track has its own detail
|
|||
- [x] **Track: Extract DatabaseManager to KMP**
|
||||
*Link: [./tracks/extract_database_manager_kmp_20260320/](./tracks/extract_database_manager_kmp_20260320/)*
|
||||
|
||||
- [ ] **Track: Extract RadioInterfaceService to KMP**
|
||||
- [x] **Track: Extract RadioInterfaceService to KMP**
|
||||
*Link: [./tracks/extract_radio_interface_kmp_20260320/](./tracks/extract_radio_interface_kmp_20260320/)*
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"id": "extract_radio_interface_kmp_20260320",
|
||||
"name": "Extract RadioInterfaceService to KMP",
|
||||
"description": "Unify the connection orchestration lifecycle (TCP, Serial, BLE) into a shared multiplatform service.",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"tags": ["core", "service", "kmp", "desktop", "radio", "connection"],
|
||||
"created_at": "2026-03-20T12:00:00Z"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import android.app.Application
|
||||
import android.provider.Settings
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.util.BinaryLogFile
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toRemoteExceptions
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
/**
|
||||
* Handles the bluetooth link with a mesh radio device. Does not cache any device state, just does bluetooth comms
|
||||
* etc...
|
||||
*
|
||||
* This service is not exposed outside of this process.
|
||||
*
|
||||
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
|
||||
* can be stubbed out with a simulated version as needed.
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class AndroidRadioInterfaceService(
|
||||
private val context: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val interfaceFactory: Lazy<InterfaceFactory>,
|
||||
private val analytics: PlatformAnalytics,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
|
||||
listOf(
|
||||
org.meshtastic.core.model.DeviceType.BLE,
|
||||
org.meshtastic.core.model.DeviceType.TCP,
|
||||
org.meshtastic.core.model.DeviceType.USB,
|
||||
)
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
|
||||
// Thread-safe StateFlow for tracking device address changes
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val logSends = false
|
||||
private val logReceives = false
|
||||
private lateinit var sentPacketsLog: BinaryLogFile
|
||||
private lateinit var receivedPacketsLog: BinaryLogFile
|
||||
|
||||
val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
|
||||
|
||||
override val serviceScope: CoroutineScope
|
||||
get() = _serviceScope
|
||||
|
||||
/** We recreate this scope each time we stop an interface */
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
private var radioIf: RadioTransport = NopInterface("")
|
||||
|
||||
/**
|
||||
* true if we have started our interface
|
||||
*
|
||||
* Note: an interface may be started without necessarily yet having a connection
|
||||
*/
|
||||
private var isStarted = false
|
||||
|
||||
@Volatile private var listenersInitialized = false
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized) return
|
||||
synchronized(this) {
|
||||
if (listenersInitialized) return
|
||||
listenersInitialized = true
|
||||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterface()
|
||||
}
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (radioIf is BleRadioInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
if (state) {
|
||||
startInterface()
|
||||
} else if (radioIf is TCPInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
|
||||
}
|
||||
|
||||
private var lastHeartbeatMillis = 0L
|
||||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
|
||||
if (radioIf is SerialInterface) {
|
||||
Logger.i { "Sending ToRadio heartbeat" }
|
||||
val heartbeat = ToRadio(heartbeat = Heartbeat())
|
||||
handleSendToRadio(heartbeat.encode())
|
||||
} else {
|
||||
// For BLE and TCP this will check if the connection is still alive
|
||||
radioIf.keepAlive()
|
||||
}
|
||||
lastHeartbeatMillis = now
|
||||
}
|
||||
}
|
||||
|
||||
/** Constructs a full radio address for the specific interface type. */
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
|
||||
|
||||
override fun isMockInterface(): Boolean =
|
||||
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
|
||||
override fun getDeviceAddress(): String? {
|
||||
// If the user has unpaired our device, treat things as if we don't have one
|
||||
return _currentDeviceAddressFlow.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Like getDeviceAddress, but filtered to return only devices we are currently bonded with
|
||||
*
|
||||
* at
|
||||
*
|
||||
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
|
||||
* path)
|
||||
*/
|
||||
fun getBondedDeviceAddress(): String? {
|
||||
// If the user has unpaired our device, treat things as if we don't have one
|
||||
val address = getDeviceAddress()
|
||||
return if (interfaceFactory.value.addressValid(address)) {
|
||||
address
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastConnectionChanged(newState: ConnectionState) {
|
||||
Logger.d { "Broadcasting connection state change to $newState" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newState) }
|
||||
}
|
||||
|
||||
// Send a packet/command out the radio link, this routine can block if it needs to
|
||||
private fun handleSendToRadio(p: ByteArray) {
|
||||
radioIf.handleSendToRadio(p)
|
||||
emitSendActivity()
|
||||
}
|
||||
|
||||
// Handle an incoming packet from the radio, broadcasts it as an android intent
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
if (logReceives) {
|
||||
try {
|
||||
receivedPacketsLog.write(bytes)
|
||||
receivedPacketsLog.flush()
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(t) { "Failed to write receive log in handleFromRadio" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
|
||||
emitReceiveActivity()
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
broadcastConnectionChanged(ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
if (errorMessage != null) {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
|
||||
}
|
||||
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newTargetState) {
|
||||
broadcastConnectionChanged(newTargetState)
|
||||
}
|
||||
}
|
||||
|
||||
/** Start our configured interface (if it isn't already running) */
|
||||
private fun startInterface() {
|
||||
if (radioIf !is NopInterface) {
|
||||
// Already running
|
||||
return
|
||||
}
|
||||
|
||||
val isTestLab = Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
val address =
|
||||
getBondedDeviceAddress()
|
||||
?: if (isTestLab) {
|
||||
mockInterfaceAddress
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (address == null) {
|
||||
Logger.w { "No bonded mesh radio, can't start interface" }
|
||||
} else {
|
||||
Logger.i { "Starting radio ${address.anonymize}" }
|
||||
isStarted = true
|
||||
|
||||
if (logSends) {
|
||||
sentPacketsLog = BinaryLogFile(context, "sent_log.pb")
|
||||
}
|
||||
if (logReceives) {
|
||||
receivedPacketsLog = BinaryLogFile(context, "receive_log.pb")
|
||||
}
|
||||
|
||||
radioIf = interfaceFactory.value.createInterface(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
private var heartbeatJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob =
|
||||
serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
val r = radioIf
|
||||
Logger.i { "stopping interface $r" }
|
||||
isStarted = false
|
||||
radioIf = interfaceFactory.value.nopInterface
|
||||
r.close()
|
||||
|
||||
// cancel any old jobs and get ready for the new ones
|
||||
_serviceScope.cancel("stopping interface")
|
||||
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
if (logSends) {
|
||||
sentPacketsLog.close()
|
||||
}
|
||||
if (logReceives) {
|
||||
receivedPacketsLog.close()
|
||||
}
|
||||
|
||||
// Don't broadcast disconnects if we were just using the nop device
|
||||
if (r !is NopInterface) {
|
||||
onDisconnect(isPermanent = true) // Tell any clients we are now offline
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change to a new device
|
||||
*
|
||||
* @return true if the device changed, false if no change
|
||||
*/
|
||||
private fun setBondedDeviceAddress(address: String?): Boolean =
|
||||
if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "Ignoring setBondedDevice ${address.anonymize}, because we are already using that device" }
|
||||
false
|
||||
} else {
|
||||
// Record that this use has configured a new radio
|
||||
analytics.track("mesh_bond")
|
||||
|
||||
// Ignore any errors that happen while closing old device
|
||||
ignoreException { stopInterface() }
|
||||
|
||||
// The device address "n" can be used to mean none
|
||||
|
||||
Logger.d { "Setting bonded device to ${address.anonymize}" }
|
||||
|
||||
// Stores the address if non-null, otherwise removes the pref
|
||||
radioPrefs.setDevAddr(address)
|
||||
_currentDeviceAddressFlow.value = address
|
||||
|
||||
// Force the service to reconnect
|
||||
startInterface()
|
||||
true
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
|
||||
setBondedDeviceAddress(deviceAddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
|
||||
* will not connect to a radio until this call is received.
|
||||
*/
|
||||
override fun connect() = toRemoteExceptions {
|
||||
// We don't start actually talking to our device until MeshService binds to us - this prevents
|
||||
// broadcasting connection events before MeshService is ready to receive them
|
||||
startInterface()
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
// Do this in the IO thread because it might take a while (and we don't care about the result code)
|
||||
_serviceScope.handledLaunch { handleSendToRadio(bytes) }
|
||||
}
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private fun emitSendActivity() {
|
||||
_meshActivity.tryEmit(MeshActivity.Send)
|
||||
}
|
||||
|
||||
private fun emitReceiveActivity() {
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
/**
|
||||
* Android implementation of [RadioTransportFactory] delegating to the legacy [InterfaceFactory].
|
||||
*/
|
||||
@Single
|
||||
class AndroidRadioTransportFactory(
|
||||
private val context: Context,
|
||||
private val interfaceFactory: Lazy<InterfaceFactory>,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
) : RadioTransportFactory {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType> =
|
||||
listOf(
|
||||
DeviceType.BLE,
|
||||
DeviceType.TCP,
|
||||
DeviceType.USB,
|
||||
)
|
||||
|
||||
override fun isMockInterface(): Boolean =
|
||||
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
|
||||
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport {
|
||||
return interfaceFactory.value.createInterface(address, service)
|
||||
}
|
||||
|
||||
override fun isAddressValid(address: String?): Boolean {
|
||||
return interfaceFactory.value.addressValid(address)
|
||||
}
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String {
|
||||
return interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
|
||||
/**
|
||||
* Creates [RadioTransport] instances for specific device addresses.
|
||||
*
|
||||
* Implemented per-platform to provide the correct hardware transport (BLE, Serial, TCP).
|
||||
*/
|
||||
interface RadioTransportFactory {
|
||||
/** The device types supported by this factory. */
|
||||
val supportedDeviceTypes: List<DeviceType>
|
||||
|
||||
/** Whether we are currently forced into using a mock interface (e.g., Firebase Test Lab). */
|
||||
fun isMockInterface(): Boolean
|
||||
|
||||
/** Creates a transport for the given [address], or a NOP implementation if invalid/unsupported. */
|
||||
fun createTransport(address: String, service: RadioInterfaceService): RadioTransport
|
||||
|
||||
/** Checks if the given [address] represents a valid, supported transport type. */
|
||||
fun isAddressValid(address: String?): Boolean
|
||||
|
||||
/** Constructs a full radio address for the specific [interfaceId] and [rest] identifier. */
|
||||
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String
|
||||
}
|
||||
|
|
@ -32,14 +32,19 @@ kotlin {
|
|||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(projects.core.repository)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.network)
|
||||
implementation(projects.core.ble)
|
||||
implementation(projects.core.prefs)
|
||||
implementation(projects.core.proto)
|
||||
|
||||
implementation(libs.jetbrains.lifecycle.runtime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
/**
|
||||
* Shared multiplatform connection orchestrator for Meshtastic radios.
|
||||
*
|
||||
* Manages the connection lifecycle (connect, active, disconnect, reconnect loop), device address state flows,
|
||||
* and hardware state observability (BLE/Network toggles). Delegates the actual raw byte transport mapping to
|
||||
* a platform-specific [RadioTransportFactory].
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class SharedRadioInterfaceService(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val networkRepository: NetworkRepository,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val transportFactory: RadioTransportFactory,
|
||||
private val analytics: PlatformAnalytics,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType>
|
||||
get() = transportFactory.supportedDeviceTypes
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
|
||||
override val serviceScope: CoroutineScope
|
||||
get() = _serviceScope
|
||||
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
private var radioIf: RadioTransport? = null
|
||||
private var isStarted = false
|
||||
@Volatile private var listenersInitialized = false
|
||||
private var heartbeatJob: kotlinx.coroutines.Job? = null
|
||||
private var lastHeartbeatMillis = 0L
|
||||
|
||||
companion object {
|
||||
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
|
||||
}
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized) return
|
||||
synchronized(this) {
|
||||
if (listenersInitialized) return
|
||||
listenersInitialized = true
|
||||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterface()
|
||||
}
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
if (state) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
startInterface()
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
override fun isMockInterface(): Boolean = transportFactory.isMockInterface()
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
transportFactory.toInterfaceAddress(interfaceId, rest)
|
||||
|
||||
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
|
||||
|
||||
private fun getBondedDeviceAddress(): String? {
|
||||
val address = getDeviceAddress()
|
||||
return if (transportFactory.isAddressValid(address)) {
|
||||
address
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean {
|
||||
val sanitized = if (deviceAddr == "n" || deviceAddr.isNullOrBlank()) null else deviceAddr
|
||||
|
||||
if (getBondedDeviceAddress() == sanitized && isStarted && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "Ignoring setBondedDevice ${sanitized?.anonymize}, already using that device" }
|
||||
return false
|
||||
}
|
||||
|
||||
analytics.track("mesh_bond")
|
||||
ignoreException { stopInterface() }
|
||||
|
||||
Logger.d { "Setting bonded device to ${sanitized?.anonymize}" }
|
||||
radioPrefs.setDevAddr(sanitized)
|
||||
_currentDeviceAddressFlow.value = sanitized
|
||||
startInterface()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun startInterface() {
|
||||
if (radioIf != null) return
|
||||
|
||||
val address = getBondedDeviceAddress()
|
||||
?: if (isMockInterface()) transportFactory.toInterfaceAddress(InterfaceId.MOCK, "") else null
|
||||
|
||||
if (address == null) {
|
||||
Logger.w { "No valid address to connect to." }
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i { "Starting radio interface for ${address.anonymize}" }
|
||||
isStarted = true
|
||||
radioIf = transportFactory.createTransport(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
val currentIf = radioIf
|
||||
Logger.i { "Stopping interface $currentIf" }
|
||||
isStarted = false
|
||||
radioIf = null
|
||||
currentIf?.close()
|
||||
|
||||
_serviceScope.cancel("stopping interface")
|
||||
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
if (currentIf != null) {
|
||||
onDisconnect(isPermanent = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
|
||||
radioIf?.keepAlive()
|
||||
lastHeartbeatMillis = now
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
_serviceScope.handledLaunch {
|
||||
radioIf?.handleSendToRadio(bytes)
|
||||
_meshActivity.tryEmit(MeshActivity.Send)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
try {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
Logger.d { "Broadcasting connection state change to Connected" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
_connectionState.emit(ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
if (errorMessage != null) {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
|
||||
}
|
||||
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newTargetState) {
|
||||
Logger.d { "Broadcasting connection state change to $newTargetState" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newTargetState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,10 +38,10 @@ import org.meshtastic.core.repository.MeshServiceNotifications
|
|||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.MessageQueue
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.desktop.radio.DesktopRadioInterfaceService
|
||||
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
|
||||
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
|
||||
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
|
||||
import org.meshtastic.desktop.stub.NoopLocationRepository
|
||||
|
|
@ -112,10 +112,9 @@ fun desktopModule() = module {
|
|||
@Suppress("LongMethod")
|
||||
private fun desktopPlatformStubsModule() = module {
|
||||
single<ServiceRepository> { org.meshtastic.core.service.ServiceRepositoryImpl() }
|
||||
single<RadioInterfaceService> {
|
||||
DesktopRadioInterfaceService(
|
||||
single<RadioTransportFactory> {
|
||||
DesktopRadioTransportFactory(
|
||||
dispatchers = get(),
|
||||
radioPrefs = get(),
|
||||
scanner = get(),
|
||||
bluetoothRepository = get(),
|
||||
connectionFactory = get(),
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ import org.meshtastic.proto.ChannelSet
|
|||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
|
||||
|
|
|
|||
|
|
@ -1,260 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.transport.TcpTransport
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
|
||||
/**
|
||||
* Desktop implementation of [RadioInterfaceService] with real TCP transport.
|
||||
*
|
||||
* Delegates all TCP socket management, stream framing, reconnect logic, and heartbeat to the shared [TcpTransport] from
|
||||
* `core:network`. Desktop supports TCP and BLE connections.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class DesktopRadioInterfaceService(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val scanner: org.meshtastic.core.ble.BleScanner,
|
||||
private val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository,
|
||||
private val connectionFactory: org.meshtastic.core.ble.BleConnectionFactory,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
|
||||
listOf(
|
||||
org.meshtastic.core.model.DeviceType.TCP,
|
||||
org.meshtastic.core.model.DeviceType.BLE,
|
||||
org.meshtastic.core.model.DeviceType.USB,
|
||||
)
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
override var serviceScope: CoroutineScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
private set
|
||||
|
||||
private var transport: TcpTransport? = null
|
||||
private var bleTransport: DesktopBleInterface? = null
|
||||
private var serialTransport: org.meshtastic.core.network.SerialTransport? = null
|
||||
|
||||
init {
|
||||
// Observe radioPrefs to handle asynchronous loads from DataStore
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
}
|
||||
// Auto-connect if we have a valid address and are disconnected
|
||||
if (addr != null && _connectionState.value == ConnectionState.Disconnected) {
|
||||
Logger.i { "DesktopRadio: Auto-connecting to saved address ${addr.anonymize}" }
|
||||
startConnection(addr)
|
||||
}
|
||||
}
|
||||
.launchIn(serviceScope)
|
||||
}
|
||||
|
||||
override fun isMockInterface(): Boolean = false
|
||||
|
||||
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
|
||||
|
||||
// region RadioInterfaceService Implementation
|
||||
|
||||
override fun connect() {
|
||||
val address = getDeviceAddress()
|
||||
if (address.isNullOrBlank() || address == "n") {
|
||||
Logger.w { "DesktopRadio: No address configured, skipping connect" }
|
||||
return
|
||||
}
|
||||
startConnection(address)
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean {
|
||||
val sanitized = if (deviceAddr == "n" || deviceAddr.isNullOrBlank()) null else deviceAddr
|
||||
|
||||
if (_currentDeviceAddressFlow.value == sanitized && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "DesktopRadio: Already connected to ${sanitized?.anonymize}, ignoring" }
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.i { "DesktopRadio: Setting device address to ${sanitized?.anonymize}" }
|
||||
|
||||
// Stop any existing connection
|
||||
stopInterface()
|
||||
|
||||
// Persist and update address
|
||||
radioPrefs.setDevAddr(sanitized)
|
||||
_currentDeviceAddressFlow.value = sanitized
|
||||
|
||||
// Start connection if we have a valid address
|
||||
if (sanitized != null && sanitized != "n") {
|
||||
startConnection(sanitized)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
serviceScope.handledLaunch {
|
||||
transport?.sendPacket(bytes)
|
||||
bleTransport?.handleSendToRadio(bytes)
|
||||
serialTransport?.handleSendToRadio(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
Logger.i { "DesktopRadio: Connected" }
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
val newState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newState) {
|
||||
Logger.i { "DesktopRadio: Disconnected (permanent=$isPermanent, error=$errorMessage)" }
|
||||
_connectionState.value = newState
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
serviceScope.launch(dispatchers.io) {
|
||||
_receivedData.emit(bytes)
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Connection Management
|
||||
|
||||
private fun startConnection(address: String) {
|
||||
if (address.startsWith("t")) {
|
||||
startTcpConnection(address.removePrefix("t"))
|
||||
} else if (address.startsWith("s")) {
|
||||
startSerialConnection(address.removePrefix("s"))
|
||||
} else if (address.startsWith("x")) {
|
||||
startBleConnection(address.removePrefix("x"))
|
||||
} else {
|
||||
// Assume BLE if no prefix, or prefix is not supported
|
||||
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
|
||||
startBleConnection(stripped)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSerialConnection(portName: String) {
|
||||
transport?.stop()
|
||||
bleTransport?.close()
|
||||
serialTransport?.close()
|
||||
|
||||
val serial = org.meshtastic.core.network.SerialTransport(portName = portName, service = this)
|
||||
serialTransport = serial
|
||||
if (!serial.startConnection()) {
|
||||
onDisconnect(isPermanent = true, errorMessage = "Failed to connect to $portName")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBleConnection(address: String) {
|
||||
transport?.stop()
|
||||
bleTransport?.close()
|
||||
|
||||
bleTransport =
|
||||
DesktopBleInterface(
|
||||
serviceScope = serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = this,
|
||||
address = address,
|
||||
)
|
||||
}
|
||||
|
||||
private fun startTcpConnection(address: String) {
|
||||
transport?.stop()
|
||||
|
||||
val tcpTransport =
|
||||
TcpTransport(
|
||||
dispatchers = dispatchers,
|
||||
scope = serviceScope,
|
||||
listener =
|
||||
object : TcpTransport.Listener {
|
||||
override fun onConnected() {
|
||||
onConnect()
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
onDisconnect(isPermanent = true)
|
||||
}
|
||||
|
||||
override fun onPacketReceived(bytes: ByteArray) {
|
||||
handleFromRadio(bytes)
|
||||
}
|
||||
},
|
||||
logTag = "DesktopRadio",
|
||||
)
|
||||
transport = tcpTransport
|
||||
tcpTransport.start(address)
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
transport?.stop()
|
||||
transport = null
|
||||
|
||||
bleTransport?.close()
|
||||
bleTransport = null
|
||||
|
||||
serialTransport?.close()
|
||||
serialTransport = null
|
||||
|
||||
// Recreate the service scope
|
||||
serviceScope.cancel("stopping interface")
|
||||
serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.radio
|
||||
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.network.SerialTransport
|
||||
import org.meshtastic.core.network.radio.TCPInterface
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
class DesktopRadioTransportFactory(
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : RadioTransportFactory {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(
|
||||
DeviceType.TCP,
|
||||
DeviceType.BLE,
|
||||
DeviceType.USB,
|
||||
)
|
||||
|
||||
override fun isMockInterface(): Boolean = false
|
||||
|
||||
override fun isAddressValid(address: String?): Boolean {
|
||||
val spec = address?.getOrNull(0) ?: return false
|
||||
return spec == InterfaceId.TCP.id ||
|
||||
spec == InterfaceId.SERIAL.id ||
|
||||
spec == InterfaceId.BLUETOOTH.id ||
|
||||
address.startsWith("!")
|
||||
}
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
||||
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport {
|
||||
return if (address.startsWith(InterfaceId.TCP.id)) {
|
||||
TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString()))
|
||||
} else if (address.startsWith(InterfaceId.SERIAL.id)) {
|
||||
SerialTransport(address.removePrefix(InterfaceId.SERIAL.id.toString()), service)
|
||||
} else if (address.startsWith(InterfaceId.BLUETOOTH.id)) {
|
||||
DesktopBleInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString())
|
||||
)
|
||||
} else {
|
||||
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
|
||||
DesktopBleInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = stripped
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue