mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: service extraction (#4828)
This commit is contained in:
parent
0d0bdf9172
commit
807db83f53
76 changed files with 309 additions and 257 deletions
|
|
@ -0,0 +1,397 @@
|
|||
/*
|
||||
* 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,382 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.retryBleOperation
|
||||
import org.meshtastic.core.ble.toMeshtasticRadioProfile
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.RadioNotConnectedException
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 1000L
|
||||
private const val CONNECTION_TIMEOUT_MS = 15_000L
|
||||
private val SCAN_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
|
||||
*
|
||||
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
|
||||
* - Bonding and discovery.
|
||||
* - Automatic reconnection logic.
|
||||
* - MTU and connection parameter monitoring.
|
||||
* - Routing raw byte packets between the radio and [RadioInterfaceService].
|
||||
*
|
||||
* @param serviceScope The coroutine scope to use for launching coroutines.
|
||||
* @param scanner The BLE scanner.
|
||||
* @param bluetoothRepository The Bluetooth repository.
|
||||
* @param connectionFactory The BLE connection factory.
|
||||
* @param service The [RadioInterfaceService] to use for handling radio events.
|
||||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BleRadioInterface(
|
||||
private val serviceScope: CoroutineScope,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val service: RadioInterfaceService,
|
||||
val address: String,
|
||||
) : RadioTransport {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
serviceScope.launch {
|
||||
try {
|
||||
bleConnection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
|
||||
}
|
||||
}
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
|
||||
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
private var connectionStartTime: Long = 0
|
||||
private var packetsReceived: Int = 0
|
||||
private var packetsSent: Int = 0
|
||||
private var bytesReceived: Long = 0
|
||||
private var bytesSent: Long = 0
|
||||
|
||||
@Volatile private var isFullyConnected = false
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
// --- Connection & Discovery Logic ---
|
||||
|
||||
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
|
||||
private suspend fun findDevice(): BleDevice {
|
||||
bluetoothRepository.state.value.bondedDevices
|
||||
.firstOrNull { it.address == address }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning..." }
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
try {
|
||||
val d =
|
||||
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
|
||||
scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
|
||||
it.address == address
|
||||
}
|
||||
}
|
||||
if (d != null) return d
|
||||
} catch (e: Exception) {
|
||||
Logger.v(e) { "Scan attempt failed or timed out" }
|
||||
}
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
delay(SCAN_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
throw RadioNotConnectedException("Device not found at address $address")
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
connectionScope.launch {
|
||||
val device = findDevice()
|
||||
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
if (state is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
onDisconnected(state)
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
|
||||
while (isActive) {
|
||||
try {
|
||||
// Add a delay to allow any pending background disconnects (from a previous close() call)
|
||||
// to complete and the Android BLE stack to settle before we attempt a new connection.
|
||||
@Suppress("MagicNumber")
|
||||
val connectDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(connectDelayMs)
|
||||
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
|
||||
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..." }
|
||||
@Suppress("MagicNumber")
|
||||
val retryDelayMs = 1500L
|
||||
kotlinx.coroutines.delay(retryDelayMs)
|
||||
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
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
|
||||
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
|
||||
handleFailure(e)
|
||||
|
||||
// Wait before retrying to prevent hot loops
|
||||
@Suppress("MagicNumber")
|
||||
kotlinx.coroutines.delay(5000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onConnected() {
|
||||
try {
|
||||
bleConnection.deviceFlow.first()?.let { device ->
|
||||
val rssi = retryBleOperation(tag = address) { device.readRssi() }
|
||||
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
|
||||
radioService = null
|
||||
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] BLE disconnected, " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
|
||||
// Note: Disconnected state in commonMain doesn't currently carry a reason.
|
||||
// We might want to add that later if needed.
|
||||
service.onDisconnect(false, errorMessage = "Disconnected")
|
||||
}
|
||||
|
||||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
try {
|
||||
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
|
||||
val radioService = service.toMeshtasticRadioProfile()
|
||||
|
||||
// Wire up notifications
|
||||
radioService.fromRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in fromRadio flow" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
radioService.logRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in logRadio flow" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
// Store reference for handleSendToRadio
|
||||
this@BleRadioInterface.radioService = radioService
|
||||
|
||||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
|
||||
// Log negotiated MTU for diagnostics
|
||||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
|
||||
|
||||
this@BleRadioInterface.service.onConnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
|
||||
bleConnection.disconnect()
|
||||
handleFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
|
||||
|
||||
// --- RadioTransport Implementation ---
|
||||
|
||||
/**
|
||||
* Sends a packet to the radio with retry support.
|
||||
*
|
||||
* @param p The packet to send.
|
||||
*/
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
val currentService = radioService
|
||||
if (currentService != null) {
|
||||
connectionScope.launch {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
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)"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to write packet to toRadioCharacteristic after " +
|
||||
"$packetsSent successful writes"
|
||||
}
|
||||
handleFailure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
Logger.d { "[$address] BLE keepAlive" }
|
||||
}
|
||||
|
||||
/** Closes the connection to the device. */
|
||||
override fun close() {
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.i {
|
||||
"[$address] Disconnecting. " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
connectionScope.launch {
|
||||
bleConnection.disconnect()
|
||||
service.onDisconnect(true)
|
||||
connectionScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
service.handleFromRadio(packet)
|
||||
}
|
||||
|
||||
private fun handleFailure(throwable: Throwable) {
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
|
||||
val isPermanent =
|
||||
this::class.simpleName == "BluetoothUnavailableException" ||
|
||||
this::class.simpleName == "ManagerClosedException"
|
||||
val msg =
|
||||
when {
|
||||
this is RadioNotConnectedException -> this.message ?: "Device not found"
|
||||
this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
|
||||
this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
|
||||
else -> this.message ?: this::class.simpleName ?: "Unknown"
|
||||
}
|
||||
return Pair(isPermanent, msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `BleRadioInterface` instances. */
|
||||
@Single
|
||||
class BleRadioInterfaceFactory(
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
) {
|
||||
fun create(rest: String, service: RadioInterfaceService): BleRadioInterface = BleRadioInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = rest,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Bluetooth backend implementation. */
|
||||
@Single
|
||||
class BleRadioInterfaceSpec(private val factory: BleRadioInterfaceFactory) : InterfaceSpec<BleRadioInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): BleRadioInterface =
|
||||
factory.create(rest, service)
|
||||
|
||||
/** Return true if this address is still acceptable. For Kable we don't strictly require prior bonding. */
|
||||
override fun addressValid(rest: String): Boolean {
|
||||
// We no longer strictly require the device to be in the bonded list before attempting connection,
|
||||
// as Kable and Android will handle bonding seamlessly during connection/characteristic access if needed.
|
||||
return rest.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
/**
|
||||
* Entry point for create radio backend instances given a specific address.
|
||||
*
|
||||
* This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest"
|
||||
* of the address (which varies per implementation).
|
||||
*/
|
||||
@Single
|
||||
class InterfaceFactory(
|
||||
private val nopInterfaceFactory: NopInterfaceFactory,
|
||||
private val bluetoothSpec: Lazy<BleRadioInterfaceSpec>,
|
||||
private val mockSpec: Lazy<MockInterfaceSpec>,
|
||||
private val serialSpec: Lazy<SerialInterfaceSpec>,
|
||||
private val tcpSpec: Lazy<TCPInterfaceSpec>,
|
||||
) {
|
||||
internal val nopInterface by lazy { nopInterfaceFactory.create("") }
|
||||
|
||||
private val specMap: Map<InterfaceId, InterfaceSpec<*>>
|
||||
get() =
|
||||
mapOf(
|
||||
InterfaceId.BLUETOOTH to bluetoothSpec.value,
|
||||
InterfaceId.MOCK to mockSpec.value,
|
||||
InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory),
|
||||
InterfaceId.SERIAL to serialSpec.value,
|
||||
InterfaceId.TCP to tcpSpec.value,
|
||||
)
|
||||
|
||||
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
||||
fun createInterface(address: String, service: RadioInterfaceService): RadioTransport {
|
||||
val (spec, rest) = splitAddress(address)
|
||||
return spec?.createInterface(rest, service) ?: nopInterface
|
||||
}
|
||||
|
||||
fun addressValid(address: String?): Boolean = address?.let {
|
||||
val (spec, rest) = splitAddress(it)
|
||||
spec?.addressValid(rest)
|
||||
} ?: false
|
||||
|
||||
private fun splitAddress(address: String): Pair<InterfaceSpec<*>?, String> {
|
||||
val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] }
|
||||
val rest = address.substring(1)
|
||||
return Pair(c, rest)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* 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 co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.network.repository.SerialConnection
|
||||
import org.meshtastic.core.network.repository.SerialConnectionListener
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** An interface that assumes we are talking to a meshtastic device via USB serial */
|
||||
class SerialInterface(
|
||||
service: RadioInterfaceService,
|
||||
private val usbRepository: UsbRepository,
|
||||
private val address: String,
|
||||
) : StreamInterface(service) {
|
||||
private var connRef = AtomicReference<SerialConnection?>()
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnect(waitForStopped: Boolean) {
|
||||
connRef.get()?.close(waitForStopped)
|
||||
super.onDeviceDisconnect(waitForStopped)
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
val deviceMap = usbRepository.serialDevices.value
|
||||
val device =
|
||||
if (deviceMap.containsKey(address)) {
|
||||
deviceMap[address]!!
|
||||
} else {
|
||||
deviceMap.map { (_, driver) -> driver }.firstOrNull()
|
||||
}
|
||||
if (device == null) {
|
||||
Logger.e { "[$address] Serial device not found at address" }
|
||||
} else {
|
||||
val connectStart = nowMillis
|
||||
Logger.i { "[$address] Opening serial device: $device" }
|
||||
|
||||
var packetsReceived = 0
|
||||
var bytesReceived = 0L
|
||||
var connectionStartTime = 0L
|
||||
|
||||
val onConnect: () -> Unit = {
|
||||
connectionStartTime = nowMillis
|
||||
val connectionTime = connectionStartTime - connectStart
|
||||
Logger.i { "[$address] Serial device connected in ${connectionTime}ms" }
|
||||
super.connect()
|
||||
}
|
||||
|
||||
usbRepository
|
||||
.createSerialConnection(
|
||||
device,
|
||||
object : SerialConnectionListener {
|
||||
override fun onMissingPermission() {
|
||||
Logger.e {
|
||||
"[$address] Serial connection failed - missing USB permissions for device: $device"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnected() {
|
||||
onConnect.invoke()
|
||||
}
|
||||
|
||||
override fun onDataReceived(bytes: ByteArray) {
|
||||
packetsReceived++
|
||||
bytesReceived += bytes.size
|
||||
Logger.d {
|
||||
"[$address] Serial received packet #$packetsReceived - " +
|
||||
"${bytes.size} byte(s) (Total RX: $bytesReceived bytes)"
|
||||
}
|
||||
bytes.forEach(::readChar)
|
||||
}
|
||||
|
||||
override fun onDisconnected(thrown: Exception?) {
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
thrown?.let { e ->
|
||||
// USB errors are common when unplugging; log as warning to avoid Crashlytics noise
|
||||
Logger.w(e) { "[$address] Serial error after ${uptime}ms: ${e.message}" }
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] Serial device disconnected - " +
|
||||
"Device: $device, " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes)"
|
||||
}
|
||||
onDeviceDisconnect(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.also { conn ->
|
||||
connRef.set(conn)
|
||||
conn.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
Logger.d { "[$address] Serial keepAlive" }
|
||||
}
|
||||
|
||||
override fun sendBytes(p: ByteArray) {
|
||||
val conn = connRef.get()
|
||||
if (conn != null) {
|
||||
Logger.d { "[$address] Serial sending ${p.size} bytes" }
|
||||
conn.sendBytes(p)
|
||||
} else {
|
||||
Logger.w { "[$address] Serial connection not available, cannot send ${p.size} bytes" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `SerialInterface` instances. */
|
||||
@Single
|
||||
class SerialInterfaceFactory(private val usbRepository: UsbRepository) {
|
||||
fun create(rest: String, service: RadioInterfaceService): SerialInterface =
|
||||
SerialInterface(service, usbRepository, rest)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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.hardware.usb.UsbManager
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Serial/USB interface backend implementation. */
|
||||
@Single
|
||||
class SerialInterfaceSpec(
|
||||
private val factory: SerialInterfaceFactory,
|
||||
private val usbManager: UsbManager,
|
||||
private val usbRepository: UsbRepository,
|
||||
) : InterfaceSpec<SerialInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): SerialInterface =
|
||||
factory.create(rest, service)
|
||||
|
||||
override fun addressValid(rest: String): Boolean {
|
||||
usbRepository.serialDevices.value.filterValues { usbManager.hasPermission(it.device) }
|
||||
findSerial(rest)?.let { d ->
|
||||
return usbManager.hasPermission(d.device)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun findSerial(rest: String): UsbSerialDriver? {
|
||||
val deviceMap = usbRepository.serialDevices.value
|
||||
return if (deviceMap.containsKey(rest)) {
|
||||
deviceMap[rest]!!
|
||||
} else {
|
||||
deviceMap.map { (_, driver) -> driver }.firstOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* 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 co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.network.transport.StreamFrameCodec
|
||||
import org.meshtastic.core.network.transport.TcpTransport
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
/**
|
||||
* Android TCP radio interface — thin adapter over the shared [TcpTransport] from `core:network`.
|
||||
*
|
||||
* Manages the mapping between the Android-specific [StreamInterface]/[RadioTransport] contract and the shared transport
|
||||
* layer.
|
||||
*/
|
||||
open class TCPInterface(
|
||||
service: RadioInterfaceService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val address: String,
|
||||
) : StreamInterface(service) {
|
||||
|
||||
companion object {
|
||||
const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT
|
||||
}
|
||||
|
||||
private val transport =
|
||||
TcpTransport(
|
||||
dispatchers = dispatchers,
|
||||
scope = service.serviceScope,
|
||||
listener =
|
||||
object : TcpTransport.Listener {
|
||||
override fun onConnected() {
|
||||
super@TCPInterface.connect()
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
// Transport already performed teardown; only propagate lifecycle to StreamInterface.
|
||||
super@TCPInterface.onDeviceDisconnect(false)
|
||||
}
|
||||
|
||||
override fun onPacketReceived(bytes: ByteArray) {
|
||||
service.handleFromRadio(bytes)
|
||||
}
|
||||
},
|
||||
logTag = "TCPInterface[$address]",
|
||||
)
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
override fun sendBytes(p: ByteArray) {
|
||||
// Direct byte sending is handled by the transport; this is used by StreamInterface for serial compat
|
||||
Logger.d { "[$address] TCPInterface.sendBytes delegated to transport" }
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnect(waitForStopped: Boolean) {
|
||||
transport.stop()
|
||||
super.onDeviceDisconnect(waitForStopped)
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
transport.start(address)
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
Logger.d { "[$address] TCP keepAlive" }
|
||||
service.serviceScope.handledLaunch { transport.sendHeartbeat() }
|
||||
}
|
||||
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
service.serviceScope.handledLaunch { transport.sendPacket(p) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `TCPInterface` instances. */
|
||||
@Single
|
||||
class TCPInterfaceFactory(private val dispatchers: CoroutineDispatchers) {
|
||||
fun create(rest: String, service: RadioInterfaceService): TCPInterface = TCPInterface(service, dispatchers, rest)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** TCP interface backend implementation. */
|
||||
@Single
|
||||
class TCPInterfaceSpec(private val factory: TCPInterfaceFactory) : InterfaceSpec<TCPInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): TCPInterface =
|
||||
factory.create(rest, service)
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal fun ConnectivityManager.networkAvailable(): Flow<Boolean> =
|
||||
observeNetworks().map { activeNetworksList -> activeNetworksList.isNotEmpty() }.distinctUntilChanged()
|
||||
|
||||
internal fun ConnectivityManager.observeNetworks(
|
||||
networkRequest: NetworkRequest = NetworkRequest.Builder().build(),
|
||||
): Flow<List<Network>> = callbackFlow {
|
||||
// Keep track of the current active networks
|
||||
val activeNetworks = mutableSetOf<Network>()
|
||||
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
activeNetworks.add(network)
|
||||
trySend(activeNetworks.toList())
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
activeNetworks.remove(network)
|
||||
trySend(activeNetworks.toList())
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
if (activeNetworks.contains(network)) {
|
||||
trySend(activeNetworks.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerNetworkCallback(networkRequest, callback)
|
||||
|
||||
awaitClose { unregisterNetworkCallback(callback) }
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@Single
|
||||
class NetworkRepository(
|
||||
private val nsdManager: NsdManager,
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
) {
|
||||
|
||||
val networkAvailable: Flow<Boolean> by lazy {
|
||||
connectivityManager
|
||||
.networkAvailable()
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
.shareIn(
|
||||
scope = processLifecycle.coroutineScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 1,
|
||||
)
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
val resolvedList: Flow<List<NsdServiceInfo>> by lazy {
|
||||
nsdManager
|
||||
.serviceList(NetworkConstants.SERVICE_TYPE)
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
.shareIn(
|
||||
scope = processLifecycle.coroutineScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 1,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun NsdServiceInfo.toAddressString() = buildString {
|
||||
@Suppress("DEPRECATION")
|
||||
append(host.hostAddress)
|
||||
if (serviceType.trim('.') == NetworkConstants.SERVICE_TYPE && port != NetworkConstants.SERVICE_PORT) {
|
||||
append(":$port")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:Suppress("SwallowedException")
|
||||
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal fun NsdManager.serviceList(serviceType: String): Flow<List<NsdServiceInfo>> =
|
||||
discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } }
|
||||
|
||||
private fun NsdManager.discoverServices(
|
||||
serviceType: String,
|
||||
protocolType: Int = NsdManager.PROTOCOL_DNS_SD,
|
||||
): Flow<List<NsdServiceInfo>> = callbackFlow {
|
||||
val serviceList = CopyOnWriteArrayList<NsdServiceInfo>()
|
||||
val discoveryListener =
|
||||
object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
cancel("Start Discovery failed: Error code: $errorCode")
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
cancel("Stop Discovery failed: Error code: $errorCode")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStarted(serviceType: String) {
|
||||
Logger.d { "NSD Service discovery started" }
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Logger.d { "NSD Service discovery stopped" }
|
||||
close()
|
||||
}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
Logger.d { "NSD Service found: $serviceInfo" }
|
||||
serviceList += serviceInfo
|
||||
trySend(serviceList)
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
Logger.d { "NSD Service lost: $serviceInfo" }
|
||||
serviceList.removeAll { it.serviceName == serviceInfo.serviceName }
|
||||
trySend(serviceList)
|
||||
}
|
||||
}
|
||||
trySend(emptyList()) // Emit an initial empty list
|
||||
discoverServices(serviceType, protocolType, discoveryListener)
|
||||
|
||||
awaitClose {
|
||||
try {
|
||||
stopServiceDiscovery(discoveryListener)
|
||||
} catch (ex: IllegalArgumentException) {
|
||||
// ignore if discovery is already stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private suspend fun NsdManager.resolveService(serviceInfo: NsdServiceInfo): NsdServiceInfo? =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val isResumed = AtomicBoolean(false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val callback =
|
||||
object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
if (isResumed.compareAndSet(false, true)) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceUpdated(updatedServiceInfo: NsdServiceInfo) {
|
||||
if (updatedServiceInfo.hostAddresses.isNotEmpty()) {
|
||||
if (isResumed.compareAndSet(false, true)) {
|
||||
continuation.resume(updatedServiceInfo)
|
||||
try {
|
||||
unregisterServiceInfoCallback(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w(e) { "Already unregistered" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
if (isResumed.compareAndSet(false, true)) {
|
||||
continuation.resume(null)
|
||||
try {
|
||||
unregisterServiceInfoCallback(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w(e) { "Already unregistered" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
// No op
|
||||
}
|
||||
}
|
||||
registerServiceInfoCallback(serviceInfo, Dispatchers.Main.asExecutor(), callback)
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
unregisterServiceInfoCallback(callback)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w(e) { "Already unregistered" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val listener =
|
||||
object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
if (isResumed.compareAndSet(false, true)) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
if (isResumed.compareAndSet(false, true)) {
|
||||
continuation.resume(serviceInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
resolveService(serviceInfo, listener)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import com.hoho.android.usbserial.driver.CdcAcmSerialDriver
|
||||
import com.hoho.android.usbserial.driver.ProbeTable
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/**
|
||||
* Creates a probe table for the USB driver. This augments the default device-to-driver mappings with additional known
|
||||
* working configurations. See this package's README for more info.
|
||||
*/
|
||||
@Single
|
||||
class ProbeTableProvider {
|
||||
fun get(): ProbeTable = UsbSerialProber.getDefaultProbeTable().apply {
|
||||
// RAK 4631:
|
||||
addProduct(9114, 32809, CdcAcmSerialDriver::class.java)
|
||||
// LilyGo TBeam v1.1:
|
||||
addProduct(6790, 21972, CdcAcmSerialDriver::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
/** USB serial connection. */
|
||||
interface SerialConnection : AutoCloseable {
|
||||
/** Called to initiate the serial connection. */
|
||||
fun connect()
|
||||
|
||||
/**
|
||||
* Send data (asynchronously) to the serial device. If the connection is not presently established then the data
|
||||
* provided is ignored / dropped.
|
||||
*/
|
||||
fun sendBytes(bytes: ByteArray)
|
||||
|
||||
/**
|
||||
* Close the USB serial connection.
|
||||
*
|
||||
* @param waitForStopped if true, waits for the connection to terminate before returning
|
||||
*/
|
||||
fun close(waitForStopped: Boolean)
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import android.hardware.usb.UsbManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||
import com.hoho.android.usbserial.util.SerialInputOutputManager
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import java.nio.BufferOverflowException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
internal class SerialConnectionImpl(
|
||||
private val usbManagerLazy: Lazy<UsbManager?>,
|
||||
private val device: UsbSerialDriver,
|
||||
private val listener: SerialConnectionListener,
|
||||
) : SerialConnection {
|
||||
private val port = device.ports[0] // Most devices have just one port (port 0)
|
||||
private val closedLatch = CountDownLatch(1)
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val ioRef = AtomicReference<SerialInputOutputManager>()
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun sendBytes(bytes: ByteArray) {
|
||||
ioRef.get()?.let {
|
||||
Logger.d { "writing ${bytes.size} byte(s)" }
|
||||
try {
|
||||
it.writeAsync(bytes)
|
||||
} catch (e: BufferOverflowException) {
|
||||
Logger.w(e) { "Buffer overflow while writing to serial port" }
|
||||
} catch (e: Exception) {
|
||||
// USB disconnections often cause IOExceptions here; log as warning to avoid Crashlytics noise
|
||||
Logger.w(e) { "Failed to write to serial port (likely disconnected)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close(waitForStopped: Boolean) {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
ignoreException(silent = true) { ioRef.get()?.stop() }
|
||||
ignoreException(silent = true) {
|
||||
port.close() // This will cause the reader thread to exit
|
||||
}
|
||||
}
|
||||
|
||||
// Allow a short amount of time for the manager to quit (so the port can be cleanly closed)
|
||||
if (waitForStopped) {
|
||||
Logger.d { "Waiting for USB manager to stop..." }
|
||||
ignoreException(silent = true) { closedLatch.await(1, TimeUnit.SECONDS) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
close(true)
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
// We shouldn't be able to get this far without a USB subsystem so explode if that isn't true
|
||||
val usbManager = usbManagerLazy.value!!
|
||||
|
||||
val usbDeviceConnection = usbManager.openDevice(device.device)
|
||||
if (usbDeviceConnection == null) {
|
||||
listener.onMissingPermission()
|
||||
closed.set(true)
|
||||
return
|
||||
}
|
||||
|
||||
port.open(usbDeviceConnection)
|
||||
port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
|
||||
port.dtr = true
|
||||
port.rts = true
|
||||
|
||||
Logger.d { "Starting serial reader thread" }
|
||||
val io =
|
||||
SerialInputOutputManager(
|
||||
port,
|
||||
object : SerialInputOutputManager.Listener {
|
||||
override fun onNewData(data: ByteArray) {
|
||||
listener.onDataReceived(data)
|
||||
}
|
||||
|
||||
override fun onRunError(e: Exception?) {
|
||||
closed.set(true)
|
||||
// Connection is already failing, don't try to set DTR/RTS as it will just throw more
|
||||
// IOExceptions
|
||||
ignoreException(silent = true) { port.close() }
|
||||
closedLatch.countDown()
|
||||
listener.onDisconnected(e)
|
||||
}
|
||||
},
|
||||
)
|
||||
.apply {
|
||||
readTimeout = 200 // To save battery we only timeout ever so often
|
||||
ioRef.set(this)
|
||||
}
|
||||
|
||||
io.start()
|
||||
listener.onConnected()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
/** Callbacks indicating state changes in the USB serial connection. */
|
||||
interface SerialConnectionListener {
|
||||
/** Unable to initiate the connection due to missing permissions. This is a terminal state. */
|
||||
fun onMissingPermission() {}
|
||||
|
||||
/** Called when a connection has been established. */
|
||||
fun onConnected() {}
|
||||
|
||||
/** Called when serial data is received. */
|
||||
fun onDataReceived(bytes: ByteArray) {}
|
||||
|
||||
/** Called when the connection has been terminated. */
|
||||
fun onDisconnected(thrown: Exception?) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.exceptionReporter
|
||||
import org.meshtastic.core.common.util.getParcelableExtraCompat
|
||||
|
||||
/** A helper class to call onChanged when bluetooth is enabled or disabled or when permissions are changed. */
|
||||
@Single
|
||||
class UsbBroadcastReceiver(private val usbRepository: UsbRepository) : BroadcastReceiver() {
|
||||
// Can be used for registering
|
||||
internal val intentFilter
|
||||
get() =
|
||||
IntentFilter().apply {
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
|
||||
val device: UsbDevice? = intent.getParcelableExtraCompat(UsbManager.EXTRA_DEVICE)
|
||||
val deviceName: String = device?.deviceName ?: "unknown"
|
||||
|
||||
when (intent.action) {
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||
Logger.d { "USB device '$deviceName' was detached" }
|
||||
usbRepository.refreshState()
|
||||
}
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
Logger.d { "USB device '$deviceName' was attached" }
|
||||
usbRepository.refreshState()
|
||||
}
|
||||
UsbManager.EXTRA_PERMISSION_GRANTED -> {
|
||||
Logger.d { "USB device '$deviceName' was granted permission" }
|
||||
usbRepository.refreshState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.meshtastic.core.common.util.registerReceiverCompat
|
||||
|
||||
private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
|
||||
|
||||
internal fun UsbManager.requestPermission(context: Context, device: UsbDevice): Flow<Boolean> = callbackFlow {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (ACTION_USB_PERMISSION == intent.action) {
|
||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
trySend(granted)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val permissionIntent =
|
||||
PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
|
||||
0,
|
||||
true,
|
||||
)
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
context.registerReceiverCompat(receiver, filter)
|
||||
requestPermission(device, permissionIntent)
|
||||
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.registerReceiverCompat
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
/** Repository responsible for maintaining and updating the state of USB connectivity. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Single
|
||||
class UsbRepository(
|
||||
private val application: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val usbBroadcastReceiverLazy: Lazy<UsbBroadcastReceiver>,
|
||||
private val usbManagerLazy: Lazy<UsbManager?>,
|
||||
private val usbSerialProberLazy: Lazy<UsbSerialProber>,
|
||||
) {
|
||||
private val _serialDevices = MutableStateFlow(emptyMap<String, UsbDevice>())
|
||||
|
||||
val serialDevices =
|
||||
_serialDevices
|
||||
.mapLatest { serialDevices ->
|
||||
val serialProber = usbSerialProberLazy.value
|
||||
buildMap {
|
||||
serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
|
||||
}
|
||||
}
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
init {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
refreshStateInternal()
|
||||
usbBroadcastReceiverLazy.value.let { receiver ->
|
||||
application.registerReceiverCompat(receiver, receiver.intentFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a USB serial connection to the specified USB device. State changes and data arrival result in async
|
||||
* callbacks on the supplied listener.
|
||||
*/
|
||||
fun createSerialConnection(device: UsbSerialDriver, listener: SerialConnectionListener): SerialConnection =
|
||||
SerialConnectionImpl(usbManagerLazy, device, listener)
|
||||
|
||||
fun requestPermission(device: UsbDevice): Flow<Boolean> =
|
||||
usbManagerLazy.value?.requestPermission(application, device) ?: emptyFlow()
|
||||
|
||||
fun refreshState() {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
|
||||
}
|
||||
|
||||
private suspend fun refreshStateInternal() =
|
||||
withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* 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.app.repository.radio
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
/**
|
||||
* Radio interface factory service provider interface. Each radio backend implementation needs to have a factory to
|
||||
* create new instances. These instances are specific to a particular address. This interface defines a common API
|
||||
* across all radio interfaces for obtaining implementation instances.
|
||||
*
|
||||
* This is primarily used in conjunction with Dagger assisted injection for each backend interface type.
|
||||
*/
|
||||
interface InterfaceFactorySpi<T : RadioTransport> {
|
||||
fun create(rest: String): T
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
/** This interface defines the contract that all radio backend implementations must adhere to. */
|
||||
interface InterfaceSpec<T : RadioTransport> {
|
||||
fun createInterface(rest: String, service: RadioInterfaceService): T
|
||||
|
||||
/** Return true if this address is still acceptable. For BLE that means, still bonded */
|
||||
fun addressValid(rest: String): Boolean = true
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
/*
|
||||
* 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 co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.delay
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.getInitials
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.Neighbor
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.QueueStatus
|
||||
import org.meshtastic.proto.Routing
|
||||
import org.meshtastic.proto.StatusMessage
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.random.Random
|
||||
import org.meshtastic.proto.Channel as ProtoChannel
|
||||
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.TW)
|
||||
|
||||
private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY)
|
||||
|
||||
/** A simulated interface that is used for testing in the simulator */
|
||||
@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber")
|
||||
class MockInterface(private val service: RadioInterfaceService, val address: String) : RadioTransport {
|
||||
|
||||
companion object {
|
||||
private const val MY_NODE = 0x42424242
|
||||
}
|
||||
|
||||
private var currentPacketId = 50
|
||||
|
||||
// an infinite sequence of ints
|
||||
private val packetIdSequence = generateSequence { currentPacketId++ }.iterator()
|
||||
|
||||
init {
|
||||
Logger.i { "Starting the mock interface" }
|
||||
service.onConnect() // Tell clients they can use the API
|
||||
}
|
||||
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
val pr = ToRadio.ADAPTER.decode(p)
|
||||
val packet = pr.packet
|
||||
if (packet != null) {
|
||||
sendQueueStatus(packet.id)
|
||||
}
|
||||
|
||||
val data = packet?.decoded
|
||||
|
||||
when {
|
||||
(pr.want_config_id ?: 0) != 0 -> sendConfigResponse(pr.want_config_id ?: 0)
|
||||
data != null && data.portnum == PortNum.ADMIN_APP ->
|
||||
handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload))
|
||||
packet != null && packet.want_ack == true -> sendFakeAck(pr)
|
||||
else -> Logger.i { "Ignoring data sent to mock interface $pr" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAdminPacket(pr: ToRadio, d: AdminMessage) {
|
||||
val packet = pr.packet ?: return
|
||||
when {
|
||||
d.get_config_request == AdminMessage.ConfigType.LORA_CONFIG ->
|
||||
sendAdmin(packet.to, packet.from, packet.id) {
|
||||
copy(get_config_response = Config(lora = defaultLoRaConfig))
|
||||
}
|
||||
|
||||
(d.get_channel_request ?: 0) != 0 ->
|
||||
sendAdmin(packet.to, packet.from, packet.id) {
|
||||
copy(
|
||||
get_channel_response =
|
||||
ProtoChannel(
|
||||
index = (d.get_channel_request ?: 0) - 1, // 0 based on the response
|
||||
settings = if (d.get_channel_request == 1) Channel.default.settings else null,
|
||||
role =
|
||||
if (d.get_channel_request == 1) {
|
||||
ProtoChannel.Role.PRIMARY
|
||||
} else {
|
||||
ProtoChannel.Role.DISABLED
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
d.get_module_config_request == AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG ->
|
||||
sendAdmin(packet.to, packet.from, packet.id) {
|
||||
copy(
|
||||
get_module_config_response =
|
||||
ModuleConfig(
|
||||
statusmessage =
|
||||
ModuleConfig.StatusMessageConfig(node_status = "Going to the farm.. to grow wheat."),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> Logger.i { "Ignoring admin sent to mock interface $d" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
Logger.i { "Closing the mock interface" }
|
||||
}
|
||||
|
||||
// / Generate a fake text message from a node
|
||||
private fun makeTextMessage(numIn: Int) = FromRadio(
|
||||
packet =
|
||||
MeshPacket(
|
||||
id = packetIdSequence.next(),
|
||||
from = numIn,
|
||||
to = 0xffffffff.toInt(), // broadcast
|
||||
rx_time = nowSeconds.toInt(),
|
||||
rx_snr = 1.5f,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = "This simulated node sends Hi!".encodeUtf8(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun makeNeighborInfo(numIn: Int) = FromRadio(
|
||||
packet =
|
||||
MeshPacket(
|
||||
id = packetIdSequence.next(),
|
||||
from = numIn,
|
||||
to = 0xffffffff.toInt(), // broadcast
|
||||
rx_time = nowSeconds.toInt(),
|
||||
rx_snr = 1.5f,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.NEIGHBORINFO_APP,
|
||||
payload =
|
||||
NeighborInfo(
|
||||
node_id = numIn,
|
||||
last_sent_by_id = numIn,
|
||||
node_broadcast_interval_secs = 60,
|
||||
neighbors =
|
||||
listOf(
|
||||
Neighbor(
|
||||
node_id = numIn + 1,
|
||||
snr = 10.0f,
|
||||
last_rx_time = nowSeconds.toInt(),
|
||||
node_broadcast_interval_secs = 60,
|
||||
),
|
||||
Neighbor(
|
||||
node_id = numIn + 2,
|
||||
snr = 12.0f,
|
||||
last_rx_time = nowSeconds.toInt(),
|
||||
node_broadcast_interval_secs = 60,
|
||||
),
|
||||
),
|
||||
)
|
||||
.encode()
|
||||
.toByteString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun makePosition(numIn: Int) = FromRadio(
|
||||
packet =
|
||||
MeshPacket(
|
||||
id = packetIdSequence.next(),
|
||||
from = numIn,
|
||||
to = 0xffffffff.toInt(), // broadcast
|
||||
rx_time = nowSeconds.toInt(),
|
||||
rx_snr = 1.5f,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.POSITION_APP,
|
||||
payload =
|
||||
ProtoPosition(
|
||||
latitude_i = org.meshtastic.core.model.Position.degI(32.776665),
|
||||
longitude_i = org.meshtastic.core.model.Position.degI(-96.796989),
|
||||
altitude = 150,
|
||||
time = nowSeconds.toInt(),
|
||||
precision_bits = 15,
|
||||
)
|
||||
.encode()
|
||||
.toByteString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun makeTelemetry(numIn: Int) = FromRadio(
|
||||
packet =
|
||||
MeshPacket(
|
||||
id = packetIdSequence.next(),
|
||||
from = numIn,
|
||||
to = 0xffffffff.toInt(), // broadcast
|
||||
rx_time = nowSeconds.toInt(),
|
||||
rx_snr = 1.5f,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.TELEMETRY_APP,
|
||||
payload =
|
||||
Telemetry(
|
||||
time = nowSeconds.toInt(),
|
||||
device_metrics =
|
||||
DeviceMetrics(
|
||||
battery_level = 85,
|
||||
voltage = 4.1f,
|
||||
channel_utilization = 0.12f,
|
||||
air_util_tx = 0.05f,
|
||||
uptime_seconds = 123456,
|
||||
),
|
||||
)
|
||||
.encode()
|
||||
.toByteString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun makeNodeStatus(numIn: Int) = FromRadio(
|
||||
packet =
|
||||
MeshPacket(
|
||||
id = packetIdSequence.next(),
|
||||
from = numIn,
|
||||
to = 0xffffffff.toInt(), // broadcast
|
||||
rx_time = nowSeconds.toInt(),
|
||||
rx_snr = 1.5f,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.NODE_STATUS_APP,
|
||||
payload =
|
||||
StatusMessage(status = "Going to the farm.. to grow wheat.").encode().toByteString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun makeDataPacket(fromIn: Int, toIn: Int, data: Data) = FromRadio(
|
||||
packet =
|
||||
MeshPacket(
|
||||
id = packetIdSequence.next(),
|
||||
from = fromIn,
|
||||
to = toIn,
|
||||
rx_time = nowSeconds.toInt(),
|
||||
rx_snr = 1.5f,
|
||||
decoded = data,
|
||||
),
|
||||
)
|
||||
|
||||
private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = makeDataPacket(
|
||||
fromIn,
|
||||
toIn,
|
||||
Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId),
|
||||
)
|
||||
|
||||
private fun sendQueueStatus(msgId: Int) = service.handleFromRadio(
|
||||
FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(),
|
||||
)
|
||||
|
||||
private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) {
|
||||
val adminMsg = AdminMessage().initFn()
|
||||
val p =
|
||||
makeDataPacket(
|
||||
fromIn,
|
||||
toIn,
|
||||
Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId),
|
||||
)
|
||||
service.handleFromRadio(p.encode())
|
||||
}
|
||||
|
||||
// / Send a fake ack packet back if the sender asked for want_ack
|
||||
private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch {
|
||||
val packet = pr.packet ?: return@handledLaunch
|
||||
delay(2000)
|
||||
service.handleFromRadio(makeAck(MY_NODE + 1, packet.from ?: 0, packet.id).encode())
|
||||
}
|
||||
|
||||
private fun sendConfigResponse(configId: Int) {
|
||||
Logger.d { "Sending mock config response" }
|
||||
|
||||
// / Generate a fake node info entry
|
||||
@Suppress("MagicNumber")
|
||||
fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio(
|
||||
node_info =
|
||||
NodeInfo(
|
||||
num = numIn,
|
||||
user =
|
||||
User(
|
||||
id = DataPacket.nodeNumToDefaultId(numIn),
|
||||
long_name = "Sim " + Integer.toHexString(numIn),
|
||||
short_name = getInitials("Sim " + Integer.toHexString(numIn)),
|
||||
hw_model = HardwareModel.ANDROID_SIM,
|
||||
),
|
||||
position =
|
||||
ProtoPosition(
|
||||
latitude_i = org.meshtastic.core.model.Position.degI(lat),
|
||||
longitude_i = org.meshtastic.core.model.Position.degI(lon),
|
||||
altitude = 35,
|
||||
time = nowSeconds.toInt(),
|
||||
precision_bits = Random.nextInt(10, 19),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Simulated network data to feed to our app
|
||||
val packets =
|
||||
arrayOf(
|
||||
// MyNodeInfo
|
||||
FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)),
|
||||
FromRadio(
|
||||
metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM),
|
||||
),
|
||||
|
||||
// Fake NodeDB
|
||||
makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas
|
||||
makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson
|
||||
FromRadio(config = Config(lora = defaultLoRaConfig)),
|
||||
FromRadio(config = Config(lora = defaultLoRaConfig)),
|
||||
FromRadio(channel = defaultChannel),
|
||||
FromRadio(config_complete_id = configId),
|
||||
|
||||
// Done with config response, now pretend to receive some text messages
|
||||
makeTextMessage(MY_NODE + 1),
|
||||
makeNeighborInfo(MY_NODE + 1),
|
||||
makePosition(MY_NODE + 1),
|
||||
makeTelemetry(MY_NODE + 1),
|
||||
makeNodeStatus(MY_NODE + 1),
|
||||
)
|
||||
|
||||
packets.forEach { p -> service.handleFromRadio(p.encode()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `MockInterface` instances. */
|
||||
@Single
|
||||
class MockInterfaceFactory {
|
||||
fun create(rest: String, service: RadioInterfaceService): MockInterface = MockInterface(service, rest)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Mock interface backend implementation. */
|
||||
@Single
|
||||
class MockInterfaceSpec(private val factory: MockInterfaceFactory) : InterfaceSpec<MockInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): MockInterface =
|
||||
factory.create(rest, service)
|
||||
|
||||
/** Return true if this address is still acceptable. For BLE that means, still bonded */
|
||||
override fun addressValid(rest: String): Boolean = true
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
class NopInterface(val address: String) : RadioTransport {
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
|
||||
/** Factory for creating `NopInterface` instances. */
|
||||
@Single
|
||||
class NopInterfaceFactory {
|
||||
fun create(rest: String): NopInterface = NopInterface(rest)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** No-op interface backend implementation. */
|
||||
@Single
|
||||
class NopInterfaceSpec(private val factory: NopInterfaceFactory) : InterfaceSpec<NopInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): NopInterface = factory.create(rest)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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 co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.network.transport.StreamFrameCodec
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
/**
|
||||
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
|
||||
* probably).
|
||||
*
|
||||
* Delegates framing logic to [StreamFrameCodec] from `core:network`.
|
||||
*/
|
||||
abstract class StreamInterface(protected val service: RadioInterfaceService) : RadioTransport {
|
||||
|
||||
private val codec = StreamFrameCodec(onPacketReceived = { service.handleFromRadio(it) }, logTag = "StreamInterface")
|
||||
|
||||
override fun close() {
|
||||
Logger.d { "Closing stream for good" }
|
||||
onDeviceDisconnect(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell MeshService our device has gone away, but wait for it to come back
|
||||
*
|
||||
* @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the
|
||||
* manager callbacks
|
||||
*/
|
||||
protected open fun onDeviceDisconnect(waitForStopped: Boolean) {
|
||||
service.onDisconnect(
|
||||
isPermanent = true,
|
||||
) // if USB device disconnects it is definitely permanently gone, not sleeping)
|
||||
}
|
||||
|
||||
protected open fun connect() {
|
||||
// Before telling mesh service, send a few START1s to wake a sleeping device
|
||||
sendBytes(StreamFrameCodec.WAKE_BYTES)
|
||||
|
||||
// Now tell clients they can (finally use the api)
|
||||
service.onConnect()
|
||||
}
|
||||
|
||||
abstract fun sendBytes(p: ByteArray)
|
||||
|
||||
// If subclasses need to flush at the end of a packet they can implement
|
||||
open fun flushBytes() {}
|
||||
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
// This method is called from a continuation and it might show up late, so check for uart being null
|
||||
service.serviceScope.launch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) }
|
||||
}
|
||||
|
||||
/** Process a single incoming byte through the stream framing state machine. */
|
||||
protected fun readChar(c: Byte) {
|
||||
codec.processInputByte(c)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
object NetworkConstants {
|
||||
const val SERVICE_PORT = 4403
|
||||
const val SERVICE_TYPE = "_meshtastic._tcp"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue