mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
feat: add SdkRadioControllerImpl and SdkStateBridge for SDK hard cutover
Replace the legacy AIDL-based AndroidRadioControllerImpl with an SDK-backed implementation that delegates all operations through RadioClient: SdkRadioControllerImpl: - Full RadioController interface impl (~35 methods) - Local admin ops → SDK AdminApi/TelemetryApi/RoutingApi - Remote admin ops → raw MeshPacket via RadioClient.send() - Registered as @Single(binds = [RadioController::class]) in Koin SdkStateBridge: - Bridges SDK reactive flows (connection, nodes, packets, events) into ServiceRepository and NodeManager for legacy UI compatibility - Connection state mapping (SDK states → app ConnectionState enum) - Node snapshot/added/updated/removed → NodeManager - Inbound telemetry/position/user packets → NodeManager handlers - Events (reboot, security warnings, drops) → notification layer AndroidRadioControllerImpl: - @Single annotation disabled (commented out) to prevent Koin conflict - Class retained for reference/fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -46,7 +46,10 @@ import org.meshtastic.sdk.transport.tcp.TcpTransport
|
||||
* service can react to connection changes with `flatMapLatest`.
|
||||
*/
|
||||
@Single(binds = [SdkClientLifecycle::class])
|
||||
class RadioClientProvider(private val context: Context, private val radioPrefs: RadioPrefs) : SdkClientLifecycle {
|
||||
class RadioClientProvider(
|
||||
private val context: Context,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
) : SdkClientLifecycle {
|
||||
private val _client = MutableStateFlow<RadioClient?>(null)
|
||||
|
||||
/** Active [RadioClient], or `null` when disconnected or between connections. */
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.sdk.AdminResult
|
||||
import org.meshtastic.sdk.ChannelIndex
|
||||
import org.meshtastic.sdk.NodeId
|
||||
import org.meshtastic.sdk.RadioClient
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* [RadioController] implementation that delegates all operations through the meshtastic-sdk.
|
||||
*
|
||||
* This replaces [org.meshtastic.core.service.AndroidRadioControllerImpl] in the hard-cutover POC. Feature modules
|
||||
* continue injecting [RadioController] and get SDK-backed behavior without code changes.
|
||||
*
|
||||
* **Command dispatch:** All admin, telemetry, and routing operations go through [RadioClient.admin],
|
||||
* [RadioClient.telemetry], and [RadioClient.routing] respectively.
|
||||
*
|
||||
* **State distribution:** Handled separately by [SdkStateBridge], which feeds SDK flows back into
|
||||
* [ServiceRepository] and [org.meshtastic.core.repository.NodeManager].
|
||||
*/
|
||||
@Single(binds = [RadioController::class])
|
||||
@Suppress("TooManyFunctions", "LongParameterList")
|
||||
class SdkRadioControllerImpl(
|
||||
private val provider: RadioClientProvider,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val locationManager: MeshLocationManager,
|
||||
) : RadioController {
|
||||
|
||||
private val packetIdCounter = AtomicInteger(1)
|
||||
|
||||
private val client: RadioClient?
|
||||
get() = provider.client.value
|
||||
|
||||
private fun requireClient(): RadioClient {
|
||||
return client ?: run {
|
||||
Logger.w { "SdkRadioControllerImpl: no active RadioClient" }
|
||||
throw IllegalStateException("RadioClient not connected")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Observable state ────────────────────────────────────────────────────
|
||||
|
||||
override val connectionState: StateFlow<ConnectionState>
|
||||
get() = serviceRepository.connectionState
|
||||
|
||||
override val clientNotification: StateFlow<ClientNotification?>
|
||||
get() = serviceRepository.clientNotification
|
||||
|
||||
override fun clearClientNotification() {
|
||||
serviceRepository.clearClientNotification()
|
||||
}
|
||||
|
||||
// ── Messaging ───────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun sendMessage(packet: DataPacket) {
|
||||
val c = client ?: run {
|
||||
Logger.w { "sendMessage: no client, dropping packet" }
|
||||
return
|
||||
}
|
||||
val destNum = when (packet.to) {
|
||||
null, DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
|
||||
else -> DataPacket.idToDefaultNodeNum(packet.to?.removePrefix("!")) ?: DataPacket.NODENUM_BROADCAST
|
||||
}
|
||||
val meshPacket = MeshPacket(
|
||||
to = destNum,
|
||||
channel = packet.channel,
|
||||
decoded = Data(
|
||||
portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP,
|
||||
payload = packet.bytes ?: ByteString.EMPTY,
|
||||
want_response = false,
|
||||
),
|
||||
)
|
||||
c.send(meshPacket)
|
||||
}
|
||||
|
||||
// ── Node operations ─────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun favoriteNode(nodeNum: Int) {
|
||||
val c = requireClient()
|
||||
val node = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
|
||||
val currentlyFavorite = node.isFavorite
|
||||
c.admin.setFavorite(NodeId(nodeNum), !currentlyFavorite)
|
||||
}
|
||||
|
||||
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
|
||||
val c = client ?: return false
|
||||
val node = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
|
||||
val contact = SharedContact(
|
||||
node_num = node.num,
|
||||
user = node.user,
|
||||
manually_verified = node.manuallyVerified,
|
||||
)
|
||||
return when (c.admin.addContact(contact)) {
|
||||
is AdminResult.Success -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local config ────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun setLocalConfig(config: Config) {
|
||||
val c = requireClient()
|
||||
c.admin.setConfig(config)
|
||||
}
|
||||
|
||||
override suspend fun setLocalChannel(channel: Channel) {
|
||||
val c = requireClient()
|
||||
c.admin.setChannel(channel)
|
||||
}
|
||||
|
||||
// ── Remote admin (config/owner/channel) ─────────────────────────────────
|
||||
|
||||
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.setOwner(user)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(set_owner = user))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.setConfig(config)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(set_config = config))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.setModuleConfig(config)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(set_module_config = config))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.setChannel(channel)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(set_channel = channel))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setFixedPosition(destNum: Int, position: Position) {
|
||||
val c = requireClient()
|
||||
val protoPos = org.meshtastic.proto.Position(
|
||||
latitude_i = Position.degI(position.latitude),
|
||||
longitude_i = Position.degI(position.longitude),
|
||||
altitude = position.altitude,
|
||||
time = position.time,
|
||||
)
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.setFixedPosition(protoPos)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(set_fixed_position = protoPos))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRingtone(destNum: Int, ringtone: String) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.setRingtone(ringtone)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(set_ringtone_message = ringtone))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setCannedMessages(destNum: Int, messages: String) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.setCannedMessages(messages)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(set_canned_message_module_messages = messages))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote admin (getters) ──────────────────────────────────────────────
|
||||
|
||||
override suspend fun getOwner(destNum: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getOwner()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_owner_request = true), wantResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
val type = AdminMessage.ConfigType.fromValue(configType) ?: return
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getConfig(type)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_config_request = type), wantResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType) ?: return
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getModuleConfig(type)
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_module_config_request = type), wantResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getChannel(ChannelIndex(index))
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_channel_request = index + 1), wantResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRingtone(destNum: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getRingtone()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_ringtone_request = true), wantResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getCannedMessages()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_canned_message_module_messages_request = true), wantResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getDeviceConnectionStatus()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_device_connection_status_request = true), wantResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle commands ───────────────────────────────────────────────────
|
||||
|
||||
override suspend fun reboot(destNum: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.reboot()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(reboot_seconds = 0))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun rebootToDfu(nodeNum: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(nodeNum)) {
|
||||
c.admin.enterDfuMode()
|
||||
} else {
|
||||
sendRemoteAdmin(c, nodeNum, AdminMessage(enter_dfu_mode_request = true))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.rebootOta()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(reboot_ota_seconds = 0))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun shutdown(destNum: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.shutdown()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(shutdown_seconds = 0))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun factoryReset(destNum: Int, packetId: Int) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.factoryReset()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(factory_reset_config = 1))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
|
||||
val c = requireClient()
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.nodeDbReset()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(nodedb_reset = true))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
|
||||
val c = requireClient()
|
||||
c.admin.removeNode(NodeId(nodeNum))
|
||||
}
|
||||
|
||||
// ── Data requests ───────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
|
||||
val c = client ?: return
|
||||
val posBytes = org.meshtastic.proto.Position(
|
||||
latitude_i = Position.degI(currentPosition.latitude),
|
||||
longitude_i = Position.degI(currentPosition.longitude),
|
||||
altitude = currentPosition.altitude,
|
||||
time = currentPosition.time,
|
||||
).encode()
|
||||
c.send(
|
||||
portnum = PortNum.POSITION_APP,
|
||||
payload = posBytes,
|
||||
to = NodeId(destNum),
|
||||
wantAck = true,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun requestUserInfo(destNum: Int) {
|
||||
val c = client ?: return
|
||||
// Send an empty NODEINFO_APP packet with want_response to request user info
|
||||
c.send(
|
||||
MeshPacket(
|
||||
to = destNum,
|
||||
want_ack = true,
|
||||
decoded = Data(
|
||||
portnum = PortNum.NODEINFO_APP,
|
||||
payload = byteArrayOf().toByteString(),
|
||||
want_response = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
|
||||
val c = requireClient()
|
||||
c.routing.traceRoute(NodeId(destNum))
|
||||
}
|
||||
|
||||
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
|
||||
val c = requireClient()
|
||||
val node = NodeId(destNum)
|
||||
// TelemetryType enum values: 0=DEVICE, 1=ENVIRONMENT, 2=AIR_QUALITY, 3=POWER, 4=LOCAL_STATS, 5=HEALTH
|
||||
when (typeValue) {
|
||||
0 -> c.telemetry.requestDevice(node)
|
||||
1 -> c.telemetry.requestEnvironment(node)
|
||||
2 -> c.telemetry.requestAirQuality(node)
|
||||
3 -> c.telemetry.requestPower(node)
|
||||
4 -> c.telemetry.requestLocalStats()
|
||||
5 -> c.telemetry.requestHealth(node)
|
||||
6 -> c.telemetry.requestHost(node)
|
||||
7 -> c.telemetry.requestTrafficManagement(node)
|
||||
else -> Logger.w { "Unknown telemetry type: $typeValue" }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
|
||||
val c = requireClient()
|
||||
c.routing.requestNeighborInfo(NodeId(destNum))
|
||||
}
|
||||
|
||||
// ── Edit settings (transactional) ───────────────────────────────────────
|
||||
|
||||
override suspend fun beginEditSettings(destNum: Int) {
|
||||
val c = client ?: return
|
||||
// Send raw begin_edit_settings admin message for compatibility with the split begin/commit pattern
|
||||
val msg = AdminMessage(begin_edit_settings = true)
|
||||
val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum)
|
||||
sendRemoteAdmin(c, target.raw, msg)
|
||||
}
|
||||
|
||||
override suspend fun commitEditSettings(destNum: Int) {
|
||||
val c = client ?: return
|
||||
val msg = AdminMessage(commit_edit_settings = true)
|
||||
val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum)
|
||||
sendRemoteAdmin(c, target.raw, msg)
|
||||
}
|
||||
|
||||
// ── Utility ─────────────────────────────────────────────────────────────
|
||||
|
||||
override fun getPacketId(): Int = packetIdCounter.getAndIncrement()
|
||||
|
||||
override fun startProvideLocation() {
|
||||
// Location provision is managed at the app level; no-op until bridge wires it
|
||||
}
|
||||
|
||||
override fun stopProvideLocation() {
|
||||
locationManager.stop()
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(address: String) {
|
||||
// Changing device address requires rebuilding the SDK client connection
|
||||
provider.rebuildAndConnectAsync()
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private fun isLocalNode(destNum: Int): Boolean {
|
||||
if (destNum == 0) return true
|
||||
val ownNum = client?.ownNode?.value?.num ?: return true
|
||||
return destNum == ownNum
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a raw admin message to a remote node via the SDK's send path.
|
||||
* Used for remote-admin operations where destNum != local node.
|
||||
*/
|
||||
private suspend fun sendRemoteAdmin(
|
||||
c: RadioClient,
|
||||
destNum: Int,
|
||||
adminMsg: AdminMessage,
|
||||
wantResponse: Boolean = false,
|
||||
) {
|
||||
val payload = AdminMessage.ADAPTER.encode(adminMsg).toByteString()
|
||||
c.send(
|
||||
MeshPacket(
|
||||
to = destNum,
|
||||
want_ack = true,
|
||||
decoded = Data(
|
||||
portnum = PortNum.ADMIN_APP,
|
||||
payload = payload,
|
||||
want_response = wantResponse,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
178
app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt
Normal file
178
app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState as AppConnectionState
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
import org.meshtastic.sdk.ConnectionState as SdkConnectionState
|
||||
import org.meshtastic.sdk.MeshEvent
|
||||
import org.meshtastic.sdk.NodeChange
|
||||
|
||||
/**
|
||||
* Bridges SDK reactive flows into the legacy repository layer.
|
||||
*
|
||||
* The SDK owns the transport and all state; this bridge maps SDK emissions into [ServiceRepository]
|
||||
* and [NodeManager] so that existing feature-module UI code (which observes those repositories)
|
||||
* continues to work without modification.
|
||||
*
|
||||
* **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client]
|
||||
* and starts/stops collection as clients come and go. No explicit lifecycle management needed.
|
||||
*
|
||||
* **Mapping policy:**
|
||||
* - SDK `Connected` → app `Connected`
|
||||
* - SDK `Connecting` / `Configuring` → app `Connecting`
|
||||
* - SDK `Reconnecting` → app `DeviceSleep` (firmware went to sleep or transport dropped)
|
||||
* - SDK `Disconnected` → app `Disconnected`
|
||||
*/
|
||||
@Single
|
||||
class SdkStateBridge(
|
||||
private val provider: RadioClientProvider,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeManager: NodeManager,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
init {
|
||||
startBridge()
|
||||
}
|
||||
|
||||
private fun startBridge() {
|
||||
|
||||
// ── Connection state bridge ─────────────────────────────────────────
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) }
|
||||
.onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) }
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Node updates bridge ─────────────────────────────────────────────
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.nodes ?: flowOf() }
|
||||
.onEach { change ->
|
||||
when (change) {
|
||||
is NodeChange.Snapshot -> {
|
||||
nodeManager.clear()
|
||||
change.nodes.forEach { (_, nodeInfo) ->
|
||||
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
|
||||
}
|
||||
nodeManager.setNodeDbReady(true)
|
||||
}
|
||||
is NodeChange.Added -> {
|
||||
nodeManager.installNodeInfo(change.node, withBroadcast = true)
|
||||
}
|
||||
is NodeChange.Updated -> {
|
||||
nodeManager.installNodeInfo(change.node, withBroadcast = true)
|
||||
}
|
||||
is NodeChange.Removed -> {
|
||||
nodeManager.removeByNodenum(change.nodeId.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Own node identity bridge ────────────────────────────────────────
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.ownNode ?: flowOf(null) }
|
||||
.onEach { ownNode ->
|
||||
if (ownNode != null) {
|
||||
nodeManager.setMyNodeNum(ownNode.num)
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Inbound packet bridge (telemetry, position, user updates) ───────
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.packets ?: flowOf() }
|
||||
.onEach { packet ->
|
||||
val decoded = packet.decoded ?: return@onEach
|
||||
val fromNum = packet.from
|
||||
val myNodeNum = nodeManager.myNodeNum.value ?: 0
|
||||
val payloadBytes = decoded.payload?.toByteArray() ?: return@onEach
|
||||
|
||||
when (decoded.portnum) {
|
||||
PortNum.TELEMETRY_APP -> {
|
||||
runCatching { Telemetry.ADAPTER.decode(payloadBytes) }
|
||||
.onSuccess { nodeManager.handleReceivedTelemetry(fromNum, it) }
|
||||
}
|
||||
PortNum.POSITION_APP -> {
|
||||
runCatching { ProtoPosition.ADAPTER.decode(payloadBytes) }
|
||||
.onSuccess { nodeManager.handleReceivedPosition(fromNum, myNodeNum, it, packet.rx_time.toLong()) }
|
||||
}
|
||||
PortNum.NODEINFO_APP -> {
|
||||
runCatching { User.ADAPTER.decode(payloadBytes) }
|
||||
.onSuccess { nodeManager.handleReceivedUser(fromNum, it, packet.channel) }
|
||||
}
|
||||
else -> {
|
||||
// Other port types are handled directly by feature VMs via SDK flows
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Events bridge (notifications, warnings) ─────────────────────────
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.events ?: flowOf() }
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is MeshEvent.DeviceRebooted -> {
|
||||
Logger.i { "[SdkBridge] Device rebooted" }
|
||||
serviceRepository.setClientNotification(
|
||||
ClientNotification(message = "Device rebooted"),
|
||||
)
|
||||
}
|
||||
is MeshEvent.SecurityWarning -> {
|
||||
Logger.w { "[SdkBridge] Security warning: $event" }
|
||||
}
|
||||
is MeshEvent.PacketsDropped -> {
|
||||
Logger.w { "[SdkBridge] Packets dropped: ${event.count} from ${event.flow}" }
|
||||
}
|
||||
else -> {
|
||||
Logger.d { "[SdkBridge] Event: $event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
Logger.i { "SdkStateBridge started" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Map SDK connection states to the app's legacy connection states. */
|
||||
fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) {
|
||||
is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected
|
||||
is SdkConnectionState.Connecting -> AppConnectionState.Connecting
|
||||
is SdkConnectionState.Configuring -> AppConnectionState.Connecting
|
||||
is SdkConnectionState.Connected -> AppConnectionState.Connected
|
||||
is SdkConnectionState.Reconnecting -> AppConnectionState.DeviceSleep
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Position
|
||||
@@ -40,7 +39,7 @@ import org.meshtastic.proto.User
|
||||
* All radio commands are forwarded through [AndroidServiceRepository.meshService]. If the service is not yet bound,
|
||||
* commands are silently dropped with a warning log.
|
||||
*/
|
||||
@Single
|
||||
// @Single — disabled for SDK hard-cutover POC; SdkRadioControllerImpl provides RadioController instead
|
||||
@Suppress("TooManyFunctions")
|
||||
class AndroidRadioControllerImpl(
|
||||
private val context: Context,
|
||||
|
||||
Reference in New Issue
Block a user