diff --git a/app/src/main/java/com/geeksville/mesh/service/BLEException.kt b/app/src/main/java/com/geeksville/mesh/service/BLEException.kt deleted file mode 100644 index f434231a1..000000000 --- a/app/src/main/java/com/geeksville/mesh/service/BLEException.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2025 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 . - */ - -package com.geeksville.mesh.service - -import android.os.RemoteException -import java.util.UUID - -open class BLEException(msg: String) : RemoteException(msg) - -open class BLECharacteristicNotFoundException(uuid: UUID) : - BLEException("Can't get characteristic $uuid") - -/// Our interface is being shut down -open class BLEConnectionClosing : BLEException("Connection closing ") \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt similarity index 94% rename from app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt rename to app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt index 34b269d50..577dea881 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt @@ -24,7 +24,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class MeshServiceConnectionStateHolder @Inject constructor() { +class ConnectionStateHandler @Inject constructor() { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState = _connectionState.asStateFlow() diff --git a/app/src/main/java/com/geeksville/mesh/service/Constants.kt b/app/src/main/java/com/geeksville/mesh/service/Constants.kt index 06e18531b..f84cc4079 100644 --- a/app/src/main/java/com/geeksville/mesh/service/Constants.kt +++ b/app/src/main/java/com/geeksville/mesh/service/Constants.kt @@ -17,21 +17,26 @@ package com.geeksville.mesh.service -const val prefix = "com.geeksville.mesh" +const val PREFIX = "com.geeksville.mesh" +const val ACTION_NODE_CHANGE = "$PREFIX.NODE_CHANGE" +const val ACTION_MESH_CONNECTED = "$PREFIX.MESH_CONNECTED" +const val ACTION_MESSAGE_STATUS = "$PREFIX.MESSAGE_STATUS" + +fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum" // // standard EXTRA bundle definitions // // a bool true means now connected, false means not -const val EXTRA_CONNECTED = "$prefix.Connected" -const val EXTRA_PROGRESS = "$prefix.Progress" +const val EXTRA_CONNECTED = "$PREFIX.Connected" +const val EXTRA_PROGRESS = "$PREFIX.Progress" -/// a bool true means we expect this condition to continue until, false means device might come back -const val EXTRA_PERMANENT = "$prefix.Permanent" +// / a bool true means we expect this condition to continue until, false means device might come back +const val EXTRA_PERMANENT = "$PREFIX.Permanent" -const val EXTRA_PAYLOAD = "$prefix.Payload" -const val EXTRA_NODEINFO = "$prefix.NodeInfo" -const val EXTRA_PACKET_ID = "$prefix.PacketId" -const val EXTRA_STATUS = "$prefix.Status" +const val EXTRA_PAYLOAD = "$PREFIX.Payload" +const val EXTRA_NODEINFO = "$PREFIX.NodeInfo" +const val EXTRA_PACKET_ID = "$PREFIX.PacketId" +const val EXTRA_STATUS = "$PREFIX.Status" diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt new file mode 100644 index 000000000..f5321e367 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import co.touchlab.kermit.Logger +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.proto.MeshProtos +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Dispatches non-packet [MeshProtos.FromRadio] variants to their respective handlers. This class is stateless and + * handles routing for config, metadata, and specialized system messages. + */ +@Singleton +class FromRadioPacketHandler +@Inject +constructor( + private val serviceRepository: ServiceRepository, + private val router: MeshRouter, + private val mqttManager: MeshMqttManager, + private val packetHandler: PacketHandler, +) { + @Suppress("CyclomaticComplexMethod") + fun handleFromRadio(proto: MeshProtos.FromRadio) { + when (proto.payloadVariantCase) { + MeshProtos.FromRadio.PayloadVariantCase.MY_INFO -> router.configFlowManager.handleMyInfo(proto.myInfo) + MeshProtos.FromRadio.PayloadVariantCase.METADATA -> + router.configFlowManager.handleLocalMetadata(proto.metadata) + MeshProtos.FromRadio.PayloadVariantCase.NODE_INFO -> { + router.configFlowManager.handleNodeInfo(proto.nodeInfo) + serviceRepository.setStatusMessage("Nodes (${router.configFlowManager.newNodeCount})") + } + MeshProtos.FromRadio.PayloadVariantCase.CONFIG_COMPLETE_ID -> + router.configFlowManager.handleConfigComplete(proto.configCompleteId) + MeshProtos.FromRadio.PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> + mqttManager.handleMqttProxyMessage(proto.mqttClientProxyMessage) + MeshProtos.FromRadio.PayloadVariantCase.QUEUESTATUS -> packetHandler.handleQueueStatus(proto.queueStatus) + MeshProtos.FromRadio.PayloadVariantCase.CONFIG -> router.configHandler.handleDeviceConfig(proto.config) + MeshProtos.FromRadio.PayloadVariantCase.MODULECONFIG -> + router.configHandler.handleModuleConfig(proto.moduleConfig) + MeshProtos.FromRadio.PayloadVariantCase.CHANNEL -> router.configHandler.handleChannel(proto.channel) + MeshProtos.FromRadio.PayloadVariantCase.CLIENTNOTIFICATION -> { + serviceRepository.setClientNotification(proto.clientNotification) + packetHandler.removeResponse(proto.clientNotification.replyId, complete = false) + } + // Logging-only variants are handled by MeshMessageProcessor before dispatching here + MeshProtos.FromRadio.PayloadVariantCase.PACKET, + MeshProtos.FromRadio.PayloadVariantCase.LOG_RECORD, + MeshProtos.FromRadio.PayloadVariantCase.REBOOTED, + MeshProtos.FromRadio.PayloadVariantCase.XMODEMPACKET, + MeshProtos.FromRadio.PayloadVariantCase.DEVICEUICONFIG, + MeshProtos.FromRadio.PayloadVariantCase.FILEINFO, + -> { + /* No specialized routing needed here */ + } + + else -> Logger.d { "Dispatcher ignoring ${proto.payloadVariantCase}" } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt new file mode 100644 index 000000000..d7b8c75d3 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.util.ignoreException +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.meshtastic.core.analytics.DataPair +import org.meshtastic.core.analytics.platform.PlatformAnalytics +import org.meshtastic.core.data.repository.PacketRepository +import org.meshtastic.core.database.DatabaseManager +import org.meshtastic.core.database.entity.ReactionEntity +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Position +import org.meshtastic.core.prefs.mesh.MeshPrefs +import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.core.service.ServiceAction +import org.meshtastic.proto.AdminProtos +import org.meshtastic.proto.ChannelProtos +import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.ModuleConfigProtos +import org.meshtastic.proto.Portnums +import org.meshtastic.proto.user +import javax.inject.Inject +import javax.inject.Singleton + +@Suppress("LongParameterList", "TooManyFunctions") +@Singleton +class MeshActionHandler +@Inject +constructor( + private val nodeManager: MeshNodeManager, + private val commandSender: MeshCommandSender, + private val packetRepository: Lazy, + private val serviceBroadcasts: MeshServiceBroadcasts, + private val dataHandler: MeshDataHandler, + private val analytics: PlatformAnalytics, + private val meshPrefs: MeshPrefs, + private val databaseManager: DatabaseManager, + private val serviceNotifications: MeshServiceNotifications, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + fun start(scope: CoroutineScope) { + this.scope = scope + } + + companion object { + private const val DEFAULT_REBOOT_DELAY = 5 + } + + fun onServiceAction(action: ServiceAction) { + ignoreException { + val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException + when (action) { + is ServiceAction.Favorite -> { + val node = action.node + commandSender.sendAdmin(myNodeNum) { + if (node.isFavorite) removeFavoriteNode = node.num else setFavoriteNode = node.num + } + nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite } + } + is ServiceAction.Ignore -> { + val node = action.node + commandSender.sendAdmin(myNodeNum) { + if (node.isIgnored) removeIgnoredNode = node.num else setIgnoredNode = node.num + } + nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored } + } + is ServiceAction.Reaction -> { + val channel = action.contactKey[0].digitToInt() + val destId = action.contactKey.substring(1) + val dataPacket = + org.meshtastic.core.model.DataPacket( + to = destId, + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = action.emoji.encodeToByteArray(), + channel = channel, + replyId = action.replyId, + wantAck = false, + ) + commandSender.sendData(dataPacket) + rememberReaction(action) + } + is ServiceAction.ImportContact -> { + val verifiedContact = action.contact.toBuilder().setManuallyVerified(true).build() + commandSender.sendAdmin(myNodeNum) { addContact = verifiedContact } + nodeManager.handleReceivedUser( + verifiedContact.nodeNum, + verifiedContact.user, + manuallyVerified = true, + ) + } + is ServiceAction.SendContact -> { + commandSender.sendAdmin(myNodeNum) { addContact = action.contact } + } + is ServiceAction.GetDeviceMetadata -> { + commandSender.sendAdmin(action.destNum, wantResponse = true) { getDeviceMetadataRequest = true } + } + } + } + } + + private fun rememberReaction(action: ServiceAction.Reaction) { + scope.handledLaunch { + val reaction = + ReactionEntity( + replyId = action.replyId, + userId = DataPacket.ID_LOCAL, + emoji = action.emoji, + timestamp = System.currentTimeMillis(), + snr = 0f, + rssi = 0, + hopsAway = 0, + ) + packetRepository.get().insertReaction(reaction) + } + } + + fun handleSetOwner(u: org.meshtastic.core.model.MeshUser, myNodeNum: Int) { + commandSender.sendAdmin(myNodeNum) { + setOwner = user { + id = u.id + longName = u.longName + shortName = u.shortName + isLicensed = u.isLicensed + } + } + nodeManager.handleReceivedUser( + myNodeNum, + user { + id = u.id + longName = u.longName + shortName = u.shortName + isLicensed = u.isLicensed + }, + ) + } + + fun handleSend(p: DataPacket, myNodeNum: Int) { + commandSender.sendData(p) + serviceBroadcasts.broadcastMessageStatus(p) + dataHandler.rememberDataPacket(p, myNodeNum, false) + val bytes = p.bytes ?: ByteArray(0) + analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) + } + + fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { + if (destNum != myNodeNum) { + val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum) + val currentPosition = + when { + provideLocation && position.isValid() -> position + else -> + nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } + } + currentPosition?.let { commandSender.requestPosition(destNum, it) } + } + } + + fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) { + nodeManager.removeByNodenum(nodeNum) + commandSender.sendAdmin(myNodeNum, requestId) { removeByNodenum = nodeNum } + } + + fun handleSetRemoteOwner(id: Int, payload: ByteArray, myNodeNum: Int) { + val u = MeshProtos.User.parseFrom(payload) + commandSender.sendAdmin(myNodeNum, id) { setOwner = u } + } + + fun handleGetRemoteOwner(id: Int, destNum: Int) { + commandSender.sendAdmin(destNum, id, wantResponse = true) { getOwnerRequest = true } + } + + fun handleSetConfig(payload: ByteArray, myNodeNum: Int) { + val c = ConfigProtos.Config.parseFrom(payload) + commandSender.sendAdmin(myNodeNum) { setConfig = c } + } + + fun handleSetRemoteConfig(id: Int, num: Int, payload: ByteArray) { + val c = ConfigProtos.Config.parseFrom(payload) + commandSender.sendAdmin(num, id) { setConfig = c } + } + + fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { + commandSender.sendAdmin(destNum, id, wantResponse = true) { + if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { + getDeviceMetadataRequest = true + } else { + getConfigRequestValue = config + } + } + } + + fun handleSetModuleConfig(id: Int, num: Int, payload: ByteArray) { + val c = ModuleConfigProtos.ModuleConfig.parseFrom(payload) + commandSender.sendAdmin(num, id) { setModuleConfig = c } + } + + fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) { + commandSender.sendAdmin(destNum, id, wantResponse = true) { getModuleConfigRequestValue = config } + } + + fun handleSetRingtone(destNum: Int, ringtone: String) { + commandSender.sendAdmin(destNum) { setRingtoneMessage = ringtone } + } + + fun handleGetRingtone(id: Int, destNum: Int) { + commandSender.sendAdmin(destNum, id, wantResponse = true) { getRingtoneRequest = true } + } + + fun handleSetCannedMessages(destNum: Int, messages: String) { + commandSender.sendAdmin(destNum) { setCannedMessageModuleMessages = messages } + } + + fun handleGetCannedMessages(id: Int, destNum: Int) { + commandSender.sendAdmin(destNum, id, wantResponse = true) { getCannedMessageModuleMessagesRequest = true } + } + + fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) { + if (payload != null) { + val c = ChannelProtos.Channel.parseFrom(payload) + commandSender.sendAdmin(myNodeNum) { setChannel = c } + } + } + + fun handleSetRemoteChannel(id: Int, num: Int, payload: ByteArray?) { + if (payload != null) { + val c = ChannelProtos.Channel.parseFrom(payload) + commandSender.sendAdmin(num, id) { setChannel = c } + } + } + + fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) { + commandSender.sendAdmin(destNum, id, wantResponse = true) { getChannelRequest = index + 1 } + } + + fun handleRequestNeighborInfo(requestId: Int, destNum: Int) { + commandSender.requestNeighborInfo(requestId, destNum) + } + + fun handleBeginEditSettings(myNodeNum: Int) { + commandSender.sendAdmin(myNodeNum) { beginEditSettings = true } + } + + fun handleCommitEditSettings(myNodeNum: Int) { + commandSender.sendAdmin(myNodeNum) { commitEditSettings = true } + } + + fun handleRebootToDfu(myNodeNum: Int) { + commandSender.sendAdmin(myNodeNum) { enterDfuModeRequest = true } + } + + fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) { + commandSender.requestTelemetry(requestId, destNum, type) + } + + fun handleRequestShutdown(requestId: Int, destNum: Int) { + commandSender.sendAdmin(destNum, requestId) { shutdownSeconds = DEFAULT_REBOOT_DELAY } + } + + fun handleRequestReboot(requestId: Int, destNum: Int) { + commandSender.sendAdmin(destNum, requestId) { rebootSeconds = DEFAULT_REBOOT_DELAY } + } + + fun handleRequestFactoryReset(requestId: Int, destNum: Int) { + commandSender.sendAdmin(destNum, requestId) { factoryResetDevice = 1 } + } + + fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) { + commandSender.sendAdmin(destNum, requestId) { nodedbReset = preserveFavorites } + } + + fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) { + commandSender.sendAdmin(destNum, requestId, wantResponse = true) { getDeviceConnectionStatusRequest = true } + } + + fun handleUpdateLastAddress(deviceAddr: String?) { + val currentAddr = meshPrefs.deviceAddress + if (deviceAddr != currentAddr) { + meshPrefs.deviceAddress = deviceAddr + scope.handledLaunch { + nodeManager.clear() + databaseManager.switchActiveDatabase(deviceAddr) + serviceNotifications.clearNotifications() + nodeManager.loadCachedNodeDB() + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt new file mode 100644 index 000000000..44083ad4d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -0,0 +1,426 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import android.os.RemoteException +import androidx.annotation.VisibleForTesting +import co.touchlab.kermit.Logger +import com.google.protobuf.ByteString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.service.ConnectionState +import org.meshtastic.proto.AdminProtos +import org.meshtastic.proto.AppOnlyProtos.ChannelSet +import org.meshtastic.proto.LocalOnlyProtos.LocalConfig +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.Portnums +import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.position +import org.meshtastic.proto.telemetry +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.absoluteValue +import kotlin.time.Duration.Companion.hours + +@Suppress("TooManyFunctions") +@Singleton +class MeshCommandSender +@Inject +constructor( + private val packetHandler: PacketHandler?, + private val nodeManager: MeshNodeManager?, + private val connectionStateHolder: ConnectionStateHandler?, + private val radioConfigRepository: RadioConfigRepository?, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val currentPacketId = AtomicLong(java.util.Random(System.currentTimeMillis()).nextLong().absoluteValue) + private val sessionPasskey = AtomicReference(ByteString.EMPTY) + private val offlineSentPackets = CopyOnWriteArrayList() + val tracerouteStartTimes = ConcurrentHashMap() + val neighborInfoStartTimes = ConcurrentHashMap() + + private val localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) + private val channelSet = MutableStateFlow(ChannelSet.getDefaultInstance()) + + @Volatile var lastNeighborInfo: MeshProtos.NeighborInfo? = null + + private val rememberDataType = + setOf( + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + Portnums.PortNum.ALERT_APP_VALUE, + Portnums.PortNum.WAYPOINT_APP_VALUE, + ) + + fun start(scope: CoroutineScope) { + this.scope = scope + radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope) + radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope) + } + + @VisibleForTesting internal constructor() : this(null, null, null, null) + + fun getCurrentPacketId(): Long = currentPacketId.get() + + fun generatePacketId(): Int { + val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1) + val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK + return ((next % numPacketIds) + 1L).toInt() + } + + fun setSessionPasskey(key: ByteString) { + sessionPasskey.set(key) + } + + private fun getHopLimit(): Int = localConfig.value.lora.hopLimit + + private fun getAdminChannelIndex(toNum: Int): Int { + val myNum = nodeManager?.myNodeNum ?: return 0 + val myNode = nodeManager.nodeDBbyNodeNum[myNum] + val destNode = nodeManager.nodeDBbyNodeNum[toNum] + + val adminChannelIndex = + when { + myNum == toNum -> 0 + myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX + else -> + channelSet.value.settingsList + .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) } + .coerceAtLeast(0) + } + return adminChannelIndex + } + + fun sendData(p: DataPacket) { + if (p.id == 0) p.id = generatePacketId() + val bytes = p.bytes ?: ByteArray(0) + if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN_VALUE) { + p.status = MessageStatus.ERROR + throw RemoteException("Message too long") + } else { + p.status = MessageStatus.QUEUED + } + + if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) { + try { + sendNow(p) + } catch (ex: IOException) { + Logger.e(ex) { "Error sending message, so enqueueing" } + enqueueForSending(p) + } + } else { + enqueueForSending(p) + } + } + + private fun sendNow(p: DataPacket) { + val meshPacket = + newMeshPacketTo(p.to ?: DataPacket.ID_BROADCAST).buildMeshPacket( + id = p.id, + wantAck = p.wantAck, + hopLimit = if (p.hopLimit > 0) p.hopLimit else getHopLimit(), + channel = p.channel, + ) { + portnumValue = p.dataType + payload = ByteString.copyFrom(p.bytes ?: ByteArray(0)) + p.replyId?.let { if (it != 0) replyId = it } + } + p.time = System.currentTimeMillis() + packetHandler?.sendToRadio(meshPacket) + } + + private fun enqueueForSending(p: DataPacket) { + if (p.dataType in rememberDataType) { + offlineSentPackets.add(p) + } + } + + fun processQueuedPackets() { + val sentPackets = mutableListOf() + offlineSentPackets.forEach { p -> + try { + sendNow(p) + sentPackets.add(p) + } catch (ex: IOException) { + Logger.e(ex) { "Error sending queued message:" } + } + } + offlineSentPackets.removeAll(sentPackets) + } + + fun sendAdmin( + destNum: Int, + requestId: Int = generatePacketId(), + wantResponse: Boolean = false, + initFn: AdminProtos.AdminMessage.Builder.() -> Unit, + ) { + val packet = + newMeshPacketTo(destNum).buildAdminPacket(id = requestId, wantResponse = wantResponse, initFn = initFn) + packetHandler?.sendToRadio(packet) + } + + fun sendPosition(pos: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) { + val myNum = nodeManager?.myNodeNum ?: return + val idNum = destNum ?: myNum + Logger.d { "Sending our position/time to=$idNum ${Position(pos)}" } + + if (!localConfig.value.position.fixedPosition) { + nodeManager.handleReceivedPosition(myNum, myNum, pos) + } + + packetHandler?.sendToRadio( + newMeshPacketTo(idNum).buildMeshPacket( + channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, + priority = MeshPacket.Priority.BACKGROUND, + ) { + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + payload = pos.toByteString() + this.wantResponse = wantResponse + }, + ) + } + + fun requestPosition(destNum: Int, currentPosition: Position) { + val meshPosition = position { + latitudeI = Position.degI(currentPosition.latitude) + longitudeI = Position.degI(currentPosition.longitude) + altitude = currentPosition.altitude + time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt() + } + packetHandler?.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + priority = MeshPacket.Priority.BACKGROUND, + ) { + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + payload = meshPosition.toByteString() + wantResponse = true + }, + ) + } + + fun setFixedPosition(destNum: Int, pos: Position) { + val meshPos = position { + latitudeI = Position.degI(pos.latitude) + longitudeI = Position.degI(pos.longitude) + altitude = pos.altitude + } + sendAdmin(destNum) { + if (pos != Position(0.0, 0.0, 0)) { + setFixedPosition = meshPos + } else { + removeFixedPosition = true + } + } + nodeManager?.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos) + } + + fun requestUserInfo(destNum: Int) { + val myNum = nodeManager?.myNodeNum ?: return + val myNode = nodeManager.getOrCreateNodeInfo(myNum) + packetHandler?.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket(channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0) { + portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE + wantResponse = true + payload = myNode.user.toByteString() + }, + ) + } + + fun requestTraceroute(requestId: Int, destNum: Int) { + tracerouteStartTimes[requestId] = System.currentTimeMillis() + packetHandler?.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + wantAck = true, + id = requestId, + channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE + wantResponse = true + }, + ) + } + + fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { + val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE + val telemetryRequest = telemetry { + when (type) { + TelemetryType.ENVIRONMENT -> + environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance() + TelemetryType.AIR_QUALITY -> airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance() + TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance() + TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance() + TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance() + } + } + packetHandler?.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + id = requestId, + channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE + payload = telemetryRequest.toByteString() + wantResponse = true + }, + ) + } + + fun requestNeighborInfo(requestId: Int, destNum: Int) { + neighborInfoStartTimes[requestId] = System.currentTimeMillis() + val myNum = nodeManager?.myNodeNum ?: 0 + if (destNum == myNum) { + val neighborInfoToSend = + lastNeighborInfo + ?: run { + val oneHour = 1.hours.inWholeMinutes.toInt() + Logger.d { "No stored neighbor info from connected radio, sending dummy data" } + MeshProtos.NeighborInfo.newBuilder() + .setNodeId(myNum) + .setLastSentById(myNum) + .setNodeBroadcastIntervalSecs(oneHour) + .addNeighbors( + MeshProtos.Neighbor.newBuilder() + .setNodeId(0) // Dummy node ID that can be intercepted + .setSnr(0f) + .setLastRxTime((System.currentTimeMillis() / TIME_MS_TO_S).toInt()) + .setNodeBroadcastIntervalSecs(oneHour) + .build(), + ) + .build() + } + + // Send the neighbor info from our connected radio to ourselves (simulated) + packetHandler?.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + wantAck = true, + id = requestId, + channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE + payload = neighborInfoToSend.toByteString() + wantResponse = true + }, + ) + } else { + // Send request to remote + packetHandler?.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + wantAck = true, + id = requestId, + channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE + wantResponse = true + }, + ) + } + } + + @VisibleForTesting + internal fun resolveNodeNum(toId: String): Int = when (toId) { + DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST + else -> { + val numericNum = + if (toId.startsWith(NODE_ID_PREFIX)) { + toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt() + } else { + null + } + numericNum + ?: nodeManager?.nodeDBbyID?.get(toId)?.num + ?: throw IllegalArgumentException("Unknown node ID $toId") + } + } + + private fun newMeshPacketTo(toId: String): MeshPacket.Builder { + val destNum = resolveNodeNum(toId) + return newMeshPacketTo(destNum) + } + + private fun newMeshPacketTo(destNum: Int): MeshPacket.Builder = MeshPacket.newBuilder().apply { to = destNum } + + private fun MeshPacket.Builder.buildMeshPacket( + wantAck: Boolean = false, + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one + hopLimit: Int = 0, + channel: Int = 0, + priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, + initFn: MeshProtos.Data.Builder.() -> Unit, + ): MeshPacket { + this.id = id + this.wantAck = wantAck + this.hopLimit = if (hopLimit > 0) hopLimit else getHopLimit() + this.priority = priority + + if (channel == DataPacket.PKC_CHANNEL_INDEX) { + pkiEncrypted = true + nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.publicKey?.let { publicKey = it } + } else { + this.channel = channel + } + + this.decoded = MeshProtos.Data.newBuilder().apply(initFn).build() + return build() + } + + private fun MeshPacket.Builder.buildAdminPacket( + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one + wantResponse: Boolean = false, + initFn: AdminProtos.AdminMessage.Builder.() -> Unit, + ): MeshPacket = + buildMeshPacket( + id = id, + wantAck = true, + channel = getAdminChannelIndex(to), + priority = MeshPacket.Priority.RELIABLE, + ) { + this.wantResponse = wantResponse + portnumValue = Portnums.PortNum.ADMIN_APP_VALUE + payload = + AdminProtos.AdminMessage.newBuilder() + .apply(initFn) + .setSessionPasskey(sessionPasskey.get()) + .build() + .toByteString() + } + + companion object { + private const val PACKET_ID_MASK = 0xffffffffL + private const val PACKET_ID_SHIFT_BITS = 32 + private const val TIME_MS_TO_S = 1000L + + private const val ADMIN_CHANNEL_NAME = "admin" + private const val NODE_ID_PREFIX = "!" + private const val NODE_ID_START_INDEX = 1 + private const val HEX_RADIX = 16 + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt new file mode 100644 index 000000000..6cc34ea8b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import co.touchlab.kermit.Logger +import com.geeksville.mesh.concurrent.handledLaunch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import org.meshtastic.core.analytics.platform.PlatformAnalytics +import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.database.entity.MetadataEntity +import org.meshtastic.core.database.entity.MyNodeEntity +import org.meshtastic.core.service.ConnectionState +import org.meshtastic.proto.MeshProtos +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Suppress("LongParameterList") +@Singleton +class MeshConfigFlowManager +@Inject +constructor( + private val nodeManager: MeshNodeManager, + private val connectionManager: MeshConnectionManager, + private val nodeRepository: NodeRepository, + private val radioConfigRepository: RadioConfigRepository, + private val connectionStateHolder: ConnectionStateHandler, + private val serviceBroadcasts: MeshServiceBroadcasts, + private val analytics: PlatformAnalytics, + private val commandSender: MeshCommandSender, + private val packetHandler: PacketHandler, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val configOnlyNonce = 69420 + private val nodeInfoNonce = 69421 + private val wantConfigDelay = 100L + + fun start(scope: CoroutineScope) { + this.scope = scope + } + + private val newNodes = mutableListOf() + val newNodeCount: Int + get() = newNodes.size + + private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null + private var newMyNodeInfo: MyNodeEntity? = null + private var myNodeInfo: MyNodeEntity? = null + + fun handleConfigComplete(configCompleteId: Int) { + when (configCompleteId) { + configOnlyNonce -> handleConfigOnlyComplete() + nodeInfoNonce -> handleNodeInfoComplete() + else -> Logger.w { "Config complete id mismatch: $configCompleteId" } + } + } + + private fun handleConfigOnlyComplete() { + Logger.i { "Config-only complete" } + if (newMyNodeInfo == null) { + Logger.e { "Did not receive a valid config - newMyNodeInfo is null" } + } else { + myNodeInfo = newMyNodeInfo + Logger.i { "myNodeInfo committed successfully" } + } + + scope.handledLaunch { + delay(wantConfigDelay) + sendHeartbeat() + delay(wantConfigDelay) + connectionManager.startNodeInfoOnly() + } + } + + private fun sendHeartbeat() { + try { + packetHandler.sendToRadio( + MeshProtos.ToRadio.newBuilder().apply { heartbeat = MeshProtos.Heartbeat.getDefaultInstance() }, + ) + Logger.d { "Heartbeat sent between nonce stages" } + } catch (ex: IOException) { + Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" } + } + } + + private fun handleNodeInfoComplete() { + Logger.i { "NodeInfo complete" } + val entities = + newNodes.map { info -> + nodeManager.installNodeInfo(info, withBroadcast = false) + nodeManager.nodeDBbyNodeNum[info.num]!! + } + newNodes.clear() + + scope.handledLaunch { + myNodeInfo?.let { + nodeRepository.installConfig(it, entities) + sendAnalytics(it) + } + nodeManager.isNodeDbReady.value = true + nodeManager.allowNodeDbWrites.value = true + connectionStateHolder.setState(ConnectionState.Connected) + serviceBroadcasts.broadcastConnection() + connectionManager.onHasSettings() + } + } + + private fun sendAnalytics(mi: MyNodeEntity) { + analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown") + } + + fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { + Logger.i { "MyNodeInfo received: ${myInfo.myNodeNum}" } + rawMyNodeInfo = myInfo + nodeManager.myNodeNum = myInfo.myNodeNum + regenMyNodeInfo() + + scope.handledLaunch { + radioConfigRepository.clearChannelSet() + radioConfigRepository.clearLocalConfig() + radioConfigRepository.clearLocalModuleConfig() + } + } + + fun handleLocalMetadata(metadata: MeshProtos.DeviceMetadata) { + Logger.i { "Local Metadata received" } + regenMyNodeInfo(metadata) + } + + fun handleNodeInfo(info: MeshProtos.NodeInfo) { + newNodes.add(info) + } + + private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata? = MeshProtos.DeviceMetadata.getDefaultInstance()) { + val myInfo = rawMyNodeInfo + if (myInfo != null) { + val mi = + with(myInfo) { + MyNodeEntity( + myNodeNum = myNodeNum, + model = + when (val hwModel = metadata?.hwModel) { + null, + MeshProtos.HardwareModel.UNSET, + -> null + else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() + }, + firmwareVersion = metadata?.firmwareVersion, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL, + messageTimeoutMsec = 300000, + minAppVersion = minAppVersion, + maxChannels = 8, + hasWifi = metadata?.hasWifi == true, + deviceId = deviceId.toStringUtf8(), + ) + } + if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) { + scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) } + } + newMyNodeInfo = mi + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt new file mode 100644 index 000000000..a975a4c7a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import com.geeksville.mesh.concurrent.handledLaunch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.proto.ChannelProtos +import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.LocalOnlyProtos.LocalConfig +import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig +import org.meshtastic.proto.ModuleConfigProtos +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MeshConfigHandler +@Inject +constructor( + private val radioConfigRepository: RadioConfigRepository, + private val serviceRepository: ServiceRepository, + private val nodeManager: MeshNodeManager, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) + val localConfig = _localConfig.asStateFlow() + + private val _moduleConfig = MutableStateFlow(LocalModuleConfig.getDefaultInstance()) + val moduleConfig = _moduleConfig.asStateFlow() + + private val configTotal = ConfigProtos.Config.getDescriptor().fields.size + private val moduleTotal = ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size + + fun start(scope: CoroutineScope) { + this.scope = scope + radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope) + + radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope) + } + + fun handleDeviceConfig(config: ConfigProtos.Config) { + scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } + val configCount = _localConfig.value.allFields.size + serviceRepository.setStatusMessage("Device config ($configCount / $configTotal)") + } + + fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { + scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } + val moduleCount = _moduleConfig.value.allFields.size + serviceRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)") + } + + fun handleChannel(ch: ChannelProtos.Channel) { + // We always want to save channel settings we receive from the radio + scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } + + // Update status message if we have node info, otherwise use a generic one + val mi = nodeManager.getMyNodeInfo() + if (mi != null) { + serviceRepository.setStatusMessage("Channels (${ch.index + 1} / ${mi.maxChannels})") + } else { + serviceRepository.setStatusMessage("Channels (${ch.index + 1})") + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt new file mode 100644 index 000000000..1b57e6aec --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import android.app.Notification +import co.touchlab.kermit.Logger +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.meshtastic.core.strings.getString +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.analytics.DataPair +import org.meshtastic.core.analytics.platform.PlatformAnalytics +import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.prefs.ui.UiPrefs +import org.meshtastic.core.service.ConnectionState +import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.connected_count +import org.meshtastic.core.strings.connecting +import org.meshtastic.core.strings.device_sleeping +import org.meshtastic.core.strings.disconnected +import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.MeshProtos.ToRadio +import org.meshtastic.proto.TelemetryProtos +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +@Suppress("LongParameterList", "TooManyFunctions") +@Singleton +class MeshConnectionManager +@Inject +constructor( + private val radioInterfaceService: RadioInterfaceService, + private val connectionStateHolder: ConnectionStateHandler, + private val serviceBroadcasts: MeshServiceBroadcasts, + private val serviceNotifications: MeshServiceNotifications, + private val uiPrefs: UiPrefs, + private val packetHandler: PacketHandler, + private val nodeRepository: NodeRepository, + private val locationManager: MeshLocationManager, + private val mqttManager: MeshMqttManager, + private val historyManager: MeshHistoryManager, + private val radioConfigRepository: RadioConfigRepository, + private val commandSender: MeshCommandSender, + private val nodeManager: MeshNodeManager, + private val analytics: PlatformAnalytics, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var sleepTimeout: Job? = null + private var locationRequestsJob: Job? = null + private var connectTimeMsec = 0L + + fun start(scope: CoroutineScope) { + this.scope = scope + radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) + + nodeRepository.myNodeInfo + .onEach { myNodeEntity -> + locationRequestsJob?.cancel() + if (myNodeEntity != null) { + locationRequestsJob = + uiPrefs + .shouldProvideNodeLocation(myNodeEntity.myNodeNum) + .onEach { shouldProvide -> + if (shouldProvide) { + locationManager.start(scope) { pos -> commandSender.sendPosition(pos) } + } else { + locationManager.stop() + } + } + .launchIn(scope) + } + } + .launchIn(scope) + } + + private fun onRadioConnectionState(newState: ConnectionState) { + scope.handledLaunch { + val localConfig = radioConfigRepository.localConfigFlow.first() + val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER + val lsEnabled = localConfig.power.isPowerSaving || isRouter + + val effectiveState = + when (newState) { + is ConnectionState.Connected -> ConnectionState.Connected + is ConnectionState.DeviceSleep -> + if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected + is ConnectionState.Connecting -> ConnectionState.Connecting + is ConnectionState.Disconnected -> ConnectionState.Disconnected + } + onConnectionChanged(effectiveState) + } + } + + private fun onConnectionChanged(c: ConnectionState) { + if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return + Logger.d { "onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c" } + + sleepTimeout?.cancel() + sleepTimeout = null + + when (c) { + is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting) + is ConnectionState.Connected -> handleConnected() + is ConnectionState.DeviceSleep -> handleDeviceSleep() + is ConnectionState.Disconnected -> handleDisconnected() + } + updateStatusNotification() + } + + private fun handleConnected() { + connectionStateHolder.setState(ConnectionState.Connecting) + serviceBroadcasts.broadcastConnection() + Logger.d { "Starting connect" } + connectTimeMsec = System.currentTimeMillis() + startConfigOnly() + } + + private fun handleDeviceSleep() { + connectionStateHolder.setState(ConnectionState.DeviceSleep) + packetHandler.stopPacketQueue() + locationManager.stop() + mqttManager.stop() + + if (connectTimeMsec != 0L) { + val now = System.currentTimeMillis() + val duration = now - connectTimeMsec + connectTimeMsec = 0L + analytics.track( + EVENT_CONNECTED_SECONDS, + DataPair(EVENT_CONNECTED_SECONDS, duration / MILLISECONDS_IN_SECOND), + ) + } + + sleepTimeout = + scope.handledLaunch { + try { + val localConfig = radioConfigRepository.localConfigFlow.first() + val timeout = (localConfig.power?.lsSecs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS + Logger.d { "Waiting for sleeping device, timeout=$timeout secs" } + delay(timeout.seconds) + Logger.w { "Device timeout out, setting disconnected" } + onConnectionChanged(ConnectionState.Disconnected) + } catch (_: CancellationException) { + Logger.d { "device sleep timeout cancelled" } + } + } + + serviceBroadcasts.broadcastConnection() + } + + private fun handleDisconnected() { + connectionStateHolder.setState(ConnectionState.Disconnected) + packetHandler.stopPacketQueue() + locationManager.stop() + mqttManager.stop() + + analytics.track( + EVENT_MESH_DISCONNECT, + DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size), + DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }), + ) + analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size)) + + serviceBroadcasts.broadcastConnection() + } + + fun startConfigOnly() { + packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = CONFIG_ONLY_NONCE }) + } + + fun startNodeInfoOnly() { + packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = NODE_INFO_NONCE }) + } + + fun onHasSettings() { + commandSender.processQueuedPackets() + + // Start MQTT if enabled + scope.handledLaunch { + val moduleConfig = radioConfigRepository.moduleConfigFlow.first() + mqttManager.start(scope, moduleConfig.mqtt.enabled, moduleConfig.mqtt.proxyToClientEnabled) + } + + reportConnection() + + val myNodeNum = nodeManager.myNodeNum ?: 0 + // Request history + scope.handledLaunch { + val moduleConfig = radioConfigRepository.moduleConfigFlow.first() + historyManager.requestHistoryReplay("onHasSettings", myNodeNum, moduleConfig.storeForward, "Unknown") + } + + // Set time + commandSender.sendAdmin(myNodeNum) { + setTimeOnly = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt() + } + } + + private fun reportConnection() { + val myNode = nodeManager.getMyNodeInfo() + val radioModel = DataPair(KEY_RADIO_MODEL, myNode?.model ?: "unknown") + analytics.track( + EVENT_MESH_CONNECT, + DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size), + DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }), + radioModel, + ) + } + + fun updateTelemetry(telemetry: TelemetryProtos.Telemetry) { + updateStatusNotification(telemetry) + } + + fun updateStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification { + val summary = + when (connectionStateHolder.connectionState.value) { + is ConnectionState.Connected -> + getString(Res.string.connected_count) + .format(nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }) + is ConnectionState.Disconnected -> getString(Res.string.disconnected) + is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) + is ConnectionState.Connecting -> getString(Res.string.connecting) + } + return serviceNotifications.updateServiceStateNotification(summary, telemetry = telemetry) + } + + companion object { + private const val CONFIG_ONLY_NONCE = 69420 + private const val NODE_INFO_NONCE = 69421 + private const val MILLISECONDS_IN_SECOND = 1000.0 + private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 + + private const val EVENT_CONNECTED_SECONDS = "connected_seconds" + private const val EVENT_MESH_DISCONNECT = "mesh_disconnect" + private const val EVENT_NUM_NODES = "num_nodes" + private const val EVENT_MESH_CONNECT = "mesh_connect" + + private const val KEY_NUM_NODES = "num_nodes" + private const val KEY_NUM_ONLINE = "num_online" + private const val KEY_RADIO_MODEL = "radio_model" + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt new file mode 100644 index 000000000..b9cc7419b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import android.util.Log +import co.touchlab.kermit.Logger +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.repository.radio.InterfaceId +import com.meshtastic.core.strings.getString +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import org.meshtastic.core.analytics.DataPair +import org.meshtastic.core.analytics.platform.PlatformAnalytics +import org.meshtastic.core.data.repository.PacketRepository +import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.database.entity.Packet +import org.meshtastic.core.database.entity.ReactionEntity +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.prefs.mesh.MeshPrefs +import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.critical_alert +import org.meshtastic.core.strings.error_duty_cycle +import org.meshtastic.core.strings.unknown_username +import org.meshtastic.core.strings.waypoint_received +import org.meshtastic.proto.AdminProtos +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.PaxcountProtos +import org.meshtastic.proto.Portnums +import org.meshtastic.proto.StoreAndForwardProtos +import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.copy +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Suppress("LongParameterList", "TooManyFunctions") +@Singleton +class MeshDataHandler +@Inject +constructor( + private val nodeManager: MeshNodeManager, + private val packetHandler: PacketHandler, + private val serviceRepository: ServiceRepository, + private val packetRepository: Lazy, + private val serviceBroadcasts: MeshServiceBroadcasts, + private val serviceNotifications: MeshServiceNotifications, + private val analytics: PlatformAnalytics, + private val dataMapper: MeshDataMapper, + private val configHandler: MeshConfigHandler, + private val configFlowManager: MeshConfigFlowManager, + private val commandSender: MeshCommandSender, + private val historyManager: MeshHistoryManager, + private val meshPrefs: MeshPrefs, + private val connectionManager: MeshConnectionManager, + private val tracerouteHandler: MeshTracerouteHandler, + private val neighborInfoHandler: MeshNeighborInfoHandler, + private val radioConfigRepository: RadioConfigRepository, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + fun start(scope: CoroutineScope) { + this.scope = scope + } + + private val rememberDataType = + setOf( + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + Portnums.PortNum.ALERT_APP_VALUE, + Portnums.PortNum.WAYPOINT_APP_VALUE, + ) + + fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) { + val dataPacket = dataMapper.toDataPacket(packet) ?: return + val fromUs = myNodeNum == packet.from + dataPacket.status = MessageStatus.RECEIVED + + val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob) + + if (shouldBroadcast) { + serviceBroadcasts.broadcastReceivedData(dataPacket) + } + analytics.track("num_data_receive", DataPair("num_data_receive", 1)) + } + + private fun handleDataPacket( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + fromUs: Boolean, + logUuid: String?, + logInsertJob: Job?, + ): Boolean { + var shouldBroadcast = !fromUs + when (packet.decoded.portnumValue) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleTextMessage(packet, dataPacket, myNodeNum) + Portnums.PortNum.ALERT_APP_VALUE -> rememberDataPacket(dataPacket, myNodeNum) + Portnums.PortNum.WAYPOINT_APP_VALUE -> handleWaypoint(packet, dataPacket, myNodeNum) + Portnums.PortNum.POSITION_APP_VALUE -> handlePosition(packet, dataPacket, myNodeNum) + Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromUs) handleNodeInfo(packet) + Portnums.PortNum.TELEMETRY_APP_VALUE -> handleTelemetry(packet, dataPacket, myNodeNum) + else -> shouldBroadcast = handleSpecializedDataPacket(packet, dataPacket, myNodeNum, logUuid, logInsertJob) + } + return shouldBroadcast + } + + private fun handleSpecializedDataPacket( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + logUuid: String?, + logInsertJob: Job?, + ): Boolean { + var shouldBroadcast = false + when (packet.decoded.portnumValue) { + Portnums.PortNum.TRACEROUTE_APP_VALUE -> { + tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob) + shouldBroadcast = false + } + Portnums.PortNum.ROUTING_APP_VALUE -> { + handleRouting(packet, dataPacket) + shouldBroadcast = true + } + + Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { + handlePaxCounter(packet) + shouldBroadcast = false + } + + Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { + handleStoreAndForward(packet, dataPacket, myNodeNum) + shouldBroadcast = false + } + + Portnums.PortNum.ADMIN_APP_VALUE -> { + handleAdminMessage(packet, myNodeNum) + shouldBroadcast = false + } + + Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> { + neighborInfoHandler.handleNeighborInfo(packet) + shouldBroadcast = true + } + + Portnums.PortNum.RANGE_TEST_APP_VALUE, + Portnums.PortNum.DETECTION_SENSOR_APP_VALUE, + -> { + handleRangeTest(dataPacket, myNodeNum) + shouldBroadcast = false + } + } + return shouldBroadcast + } + + private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) { + val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(u, myNodeNum) + } + + private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + val u = StoreAndForwardProtos.StoreAndForward.parseFrom(packet.decoded.payload) + handleReceivedStoreAndForward(dataPacket, u, myNodeNum) + } + + private fun handlePaxCounter(packet: MeshPacket) { + val p = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload) + nodeManager.handleReceivedPaxcounter(packet.from, p) + } + + private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + val p = MeshProtos.Position.parseFrom(packet.decoded.payload) + nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time) + } + + private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + val u = MeshProtos.Waypoint.parseFrom(packet.decoded.payload) + if (u.lockedTo != 0 && u.lockedTo != packet.from) return + val currentSecond = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt() + rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond) + } + + private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) { + val u = AdminProtos.AdminMessage.parseFrom(packet.decoded.payload) + commandSender.setSessionPasskey(u.sessionPasskey) + + if (packet.from == myNodeNum) { + when (u.payloadVariantCase) { + AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> + configHandler.handleDeviceConfig(u.getConfigResponse) + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> + configHandler.handleChannel(u.getChannelResponse) + + else -> {} + } + } + + if (u.payloadVariantCase == AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE) { + if (packet.from == myNodeNum) { + configFlowManager.handleLocalMetadata(u.getDeviceMetadataResponse) + } else { + nodeManager.insertMetadata(packet.from, u.getDeviceMetadataResponse) + } + } + } + + private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + if (packet.decoded.replyId != 0 && packet.decoded.emoji != 0) { + rememberReaction(packet) + } else { + rememberDataPacket(dataPacket, myNodeNum) + } + } + + private fun handleNodeInfo(packet: MeshPacket) { + val u = + MeshProtos.User.parseFrom(packet.decoded.payload).copy { + if (isLicensed) clearPublicKey() + if (packet.viaMqtt) longName = "$longName (MQTT)" + } + nodeManager.handleReceivedUser(packet.from, u, packet.channel) + } + + private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + val t = + TelemetryProtos.Telemetry.parseFrom(packet.decoded.payload).copy { + if (time == 0) time = (dataPacket.time.milliseconds.inWholeSeconds).toInt() + } + val fromNum = packet.from + val isRemote = (fromNum != myNodeNum) + if (!isRemote) { + connectionManager.updateTelemetry(t) + } + + nodeManager.updateNodeInfo(fromNum) { nodeEntity -> + when { + t.hasDeviceMetrics() -> { + nodeEntity.deviceTelemetry = t + if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) { + if ( + t.deviceMetrics.voltage > BATTERY_PERCENT_UNSUPPORTED && + t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_LOW_THRESHOLD + ) { + if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) { + serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote) + } + } else { + if (batteryPercentCooldowns.containsKey(fromNum)) { + batteryPercentCooldowns.remove(fromNum) + } + serviceNotifications.cancelLowBatteryNotification(nodeEntity) + } + } + } + + t.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = t + t.hasPowerMetrics() -> nodeEntity.powerTelemetry = t + } + } + } + + private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry, myNodeNum: Int): Boolean { + val isRemote = (fromNum != myNodeNum) + var shouldDisplay = false + var forceDisplay = false + when { + t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> { + shouldDisplay = true + forceDisplay = true + } + + t.deviceMetrics.batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true + t.deviceMetrics.batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true + + isRemote -> shouldDisplay = true + } + if (shouldDisplay) { + val now = System.currentTimeMillis() / MILLISECONDS_IN_SECOND + if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0 + if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) { + batteryPercentCooldowns[fromNum] = now + return true + } + } + return false + } + + private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) { + val r = MeshProtos.Routing.parseFrom(packet.decoded.payload) + if (r.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { + serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle)) + } + handleAckNak( + packet.decoded.requestId, + dataMapper.toNodeID(packet.from), + r.errorReasonValue, + dataPacket.relayNode, + ) + packetHandler.removeResponse(packet.decoded.requestId, complete = true) + } + + private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) { + scope.handledLaunch { + val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE + val p = packetRepository.get().getPacketById(requestId) + val m = + when { + isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED + isAck -> MessageStatus.DELIVERED + else -> MessageStatus.ERROR + } + if (p != null && p.data.status != MessageStatus.RECEIVED) { + p.data.status = m + p.routingError = routingError + p.data.relayNode = relayNode + if (isAck) { + p.data.relays += 1 + } + packetRepository.get().update(p) + } + serviceBroadcasts.broadcastMessageStatus(requestId, m) + } + } + + private fun handleReceivedStoreAndForward( + dataPacket: DataPacket, + s: StoreAndForwardProtos.StoreAndForward, + myNodeNum: Int, + ) { + Logger.d { "StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}" } + val transport = currentTransport() + val lastRequest = + if (s.variantCase == StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY) { + s.history.lastRequest + } else { + 0 + } + val baseContext = "transport=$transport from=${dataPacket.from}" + historyLog { "rxStoreForward $baseContext variant=${s.variantCase} rr=${s.rr} lastRequest=$lastRequest" } + when (s.variantCase) { + StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { + val u = + dataPacket.copy( + bytes = s.stats.toString().encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + ) + rememberDataPacket(u, myNodeNum) + } + + StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { + val history = s.history + val historySummary = + "routerHistory $baseContext messages=${history.historyMessages} " + + "window=${history.window} lastRequest=${history.lastRequest}" + historyLog(Log.DEBUG) { historySummary } + val text = + """ + Total messages: ${s.history.historyMessages} + History window: ${s.history.window.milliseconds.inWholeMinutes} min + Last request: ${s.history.lastRequest} + """ + .trimIndent() + val u = + dataPacket.copy( + bytes = text.encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + ) + rememberDataPacket(u, myNodeNum) + historyManager.updateStoreForwardLastRequest("router_history", s.history.lastRequest, transport) + } + + StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> { + if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { + dataPacket.to = DataPacket.ID_BROADCAST + } + val textLog = + "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} " + + "to=${dataPacket.to} decision=remember" + historyLog(Log.DEBUG) { textLog } + val u = + dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(u, myNodeNum) + } + + else -> {} + } + } + + fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) { + if (dataPacket.dataType !in rememberDataType) return + val fromLocal = dataPacket.from == DataPacket.ID_LOCAL + val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST + val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from + + // contactKey: unique contact key filter (channel)+(nodeId) + val contactKey = "${dataPacket.channel}$contactId" + + val packetToSave = + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + packetId = dataPacket.id, + port_num = dataPacket.dataType, + contact_key = contactKey, + received_time = System.currentTimeMillis(), + read = fromLocal, + data = dataPacket, + snr = dataPacket.snr, + rssi = dataPacket.rssi, + hopsAway = dataPacket.hopsAway, + replyId = dataPacket.replyId ?: 0, + ) + scope.handledLaunch { + packetRepository.get().apply { + insert(packetToSave) + val isMuted = getContactSettings(contactKey).isMuted + if (!isMuted) { + if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE) { + serviceNotifications.showAlertNotification( + contactKey, + getSenderName(dataPacket), + dataPacket.alert ?: getString(Res.string.critical_alert), + ) + } else if (updateNotification) { + scope.handledLaunch { updateMessageNotification(contactKey, dataPacket) } + } + } + } + } + } + + private fun getSenderName(packet: DataPacket): String = + nodeManager.nodeDBbyID[packet.from]?.user?.longName ?: getString(Res.string.unknown_username) + + private suspend fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) { + val message = + when (dataPacket.dataType) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! + Portnums.PortNum.WAYPOINT_APP_VALUE -> + getString(Res.string.waypoint_received, dataPacket.waypoint!!.name) + + else -> return + } + + val channelName = + if (dataPacket.to == DataPacket.ID_BROADCAST) { + radioConfigRepository.channelSetFlow.first().settingsList.getOrNull(dataPacket.channel)?.name + } else { + null + } + + serviceNotifications.updateMessageNotification( + contactKey, + getSenderName(dataPacket), + message, + dataPacket.to == DataPacket.ID_BROADCAST, + channelName, + ) + } + + private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch { + val reaction = + ReactionEntity( + replyId = packet.decoded.replyId, + userId = dataMapper.toNodeID(packet.from), + emoji = packet.decoded.payload.toByteArray().decodeToString(), + timestamp = System.currentTimeMillis(), + snr = packet.rxSnr, + rssi = packet.rxRssi, + hopsAway = + if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { + HOPS_AWAY_UNAVAILABLE + } else { + packet.hopStart - packet.hopLimit + }, + ) + packetRepository.get().insertReaction(reaction) + } + + private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) { + InterfaceId.BLUETOOTH.id -> "BLE" + InterfaceId.TCP.id -> "TCP" + InterfaceId.SERIAL.id -> "Serial" + InterfaceId.MOCK.id -> "Mock" + InterfaceId.NOP.id -> "NOP" + else -> "Unknown" + } + + private inline fun historyLog( + priority: Int = Log.INFO, + throwable: Throwable? = null, + crossinline message: () -> String, + ) { + if (!BuildConfig.DEBUG) return + val logger = Logger.withTag("HistoryReplay") + val msg = message() + when (priority) { + Log.VERBOSE -> logger.v(throwable) { msg } + Log.DEBUG -> logger.d(throwable) { msg } + Log.INFO -> logger.i(throwable) { msg } + Log.WARN -> logger.w(throwable) { msg } + Log.ERROR -> logger.e(throwable) { msg } + else -> logger.i(throwable) { msg } + } + } + + companion object { + private const val MILLISECONDS_IN_SECOND = 1000L + private const val HOPS_AWAY_UNAVAILABLE = -1 + + private const val BATTERY_PERCENT_UNSUPPORTED = 0.0 + private const val BATTERY_PERCENT_LOW_THRESHOLD = 20 + private const val BATTERY_PERCENT_LOW_DIVISOR = 5 + private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5 + private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500 + private val batteryPercentCooldowns = ConcurrentHashMap() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt new file mode 100644 index 000000000..c8a810ab1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import org.meshtastic.core.model.DataPacket +import org.meshtastic.proto.MeshProtos.MeshPacket +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManager) { + fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + nodeManager.nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) + } + + fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) { + null + } else { + val data = packet.decoded + DataPacket( + from = toNodeID(packet.from), + to = toNodeID(packet.to), + time = packet.rxTime * 1000L, + id = packet.id, + dataType = data.portnumValue, + bytes = data.payload.toByteArray(), + hopLimit = packet.hopLimit, + channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel, + wantAck = packet.wantAck, + hopStart = packet.hopStart, + snr = packet.rxSnr, + rssi = packet.rxRssi, + replyId = data.replyId, + relayNode = packet.relayNode, + viaMqtt = packet.viaMqtt, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt new file mode 100644 index 000000000..a7208e526 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import android.util.Log +import androidx.annotation.VisibleForTesting +import co.touchlab.kermit.Logger +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.model.NO_DEVICE_SELECTED +import com.google.protobuf.ByteString +import org.meshtastic.core.prefs.mesh.MeshPrefs +import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.ModuleConfigProtos +import org.meshtastic.proto.Portnums +import org.meshtastic.proto.StoreAndForwardProtos +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MeshHistoryManager +@Inject +constructor( + private val meshPrefs: MeshPrefs, + private val packetHandler: PacketHandler, +) { + companion object { + private const val HISTORY_TAG = "HistoryReplay" + private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24 + private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100 + + @VisibleForTesting + internal fun buildStoreForwardHistoryRequest( + lastRequest: Int, + historyReturnWindow: Int, + historyReturnMax: Int, + ): StoreAndForwardProtos.StoreAndForward { + val historyBuilder = StoreAndForwardProtos.StoreAndForward.History.newBuilder() + if (lastRequest > 0) historyBuilder.lastRequest = lastRequest + if (historyReturnWindow > 0) historyBuilder.window = historyReturnWindow + if (historyReturnMax > 0) historyBuilder.historyMessages = historyReturnMax + return StoreAndForwardProtos.StoreAndForward.newBuilder() + .setRr(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY) + .setHistory(historyBuilder) + .build() + } + + @VisibleForTesting + internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair { + val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES + val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES + return resolvedWindow to resolvedMax + } + } + + private fun historyLog(priority: Int = Log.INFO, throwable: Throwable? = null, message: () -> String) { + if (!BuildConfig.DEBUG) return + val logger = Logger.withTag(HISTORY_TAG) + val msg = message() + when (priority) { + Log.VERBOSE -> logger.v(throwable) { msg } + Log.DEBUG -> logger.d(throwable) { msg } + Log.INFO -> logger.i(throwable) { msg } + Log.WARN -> logger.w(throwable) { msg } + Log.ERROR -> logger.e(throwable) { msg } + else -> logger.i(throwable) { msg } + } + } + + private fun activeDeviceAddress(): String? = + meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() } + + fun requestHistoryReplay( + trigger: String, + myNodeNum: Int?, + storeForwardConfig: ModuleConfigProtos.ModuleConfig.StoreForwardConfig?, + transport: String, + ) { + val address = activeDeviceAddress() + if (address == null || myNodeNum == null) { + val reason = if (address == null) "no_addr" else "no_my_node" + historyLog { "requestHistory skipped trigger=$trigger reason=$reason" } + return + } + + val lastRequest = meshPrefs.getStoreForwardLastRequest(address) + val (window, max) = + resolveHistoryRequestParameters( + storeForwardConfig?.historyReturnWindow ?: 0, + storeForwardConfig?.historyReturnMax ?: 0, + ) + + val request = buildStoreForwardHistoryRequest(lastRequest, window, max) + + historyLog { + "requestHistory trigger=$trigger transport=$transport addr=$address " + + "lastRequest=$lastRequest window=$window max=$max" + } + + runCatching { + packetHandler.sendToRadio( + MeshPacket.newBuilder() + .apply { + to = myNodeNum + decoded = + org.meshtastic.proto.MeshProtos.Data.newBuilder() + .apply { + portnumValue = Portnums.PortNum.STORE_FORWARD_APP_VALUE + payload = ByteString.copyFrom(request.toByteArray()) + } + .build() + priority = MeshPacket.Priority.BACKGROUND + } + .build(), + ) + } + .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed" } } + } + + fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) { + if (lastRequest <= 0) return + val address = activeDeviceAddress() ?: return + val current = meshPrefs.getStoreForwardLastRequest(address) + if (lastRequest != current) { + meshPrefs.setStoreForwardLastRequest(address, lastRequest) + historyLog { + "historyMarker updated source=$source transport=$transport " + + "addr=$address from=$current to=$lastRequest" + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt new file mode 100644 index 000000000..026566f31 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import android.annotation.SuppressLint +import android.app.Application +import androidx.core.location.LocationCompat +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.common.hasLocationPermission +import org.meshtastic.core.data.repository.LocationRepository +import org.meshtastic.core.model.Position +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.position +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class MeshLocationManager +@Inject +constructor( + private val context: Application, + private val locationRepository: LocationRepository, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var locationFlow: Job? = null + + @SuppressLint("MissingPermission") + fun start(scope: CoroutineScope, sendPositionFn: (MeshProtos.Position) -> Unit) { + this.scope = scope + if (locationFlow?.isActive == true) return + + if (context.hasLocationPermission()) { + locationFlow = + locationRepository + .getLocations() + .onEach { location -> + sendPositionFn( + position { + latitudeI = Position.degI(location.latitude) + longitudeI = Position.degI(location.longitude) + if (LocationCompat.hasMslAltitude(location)) { + altitude = LocationCompat.getMslAltitudeMeters(location).toInt() + } + altitudeHae = location.altitude.toInt() + time = (location.time.milliseconds.inWholeSeconds).toInt() + groundSpeed = location.speed.toInt() + groundTrack = location.bearing.toInt() + locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL + }, + ) + } + .launchIn(scope) + } + } + + fun stop() { + if (locationFlow?.isActive == true) { + Logger.i { "Stopping location requests" } + locationFlow?.cancel() + locationFlow = null + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt new file mode 100644 index 000000000..6b538fc6e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import android.util.Log +import co.touchlab.kermit.Logger +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.concurrent.handledLaunch +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.data.repository.MeshLogRepository +import org.meshtastic.core.database.entity.MeshLog +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.MeshProtos.FromRadio.PayloadVariantCase +import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.Portnums +import org.meshtastic.proto.fromRadio +import java.util.ArrayDeque +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class MeshMessageProcessor +@Inject +constructor( + private val nodeManager: MeshNodeManager, + private val serviceRepository: ServiceRepository, + private val meshLogRepository: Lazy, + private val router: MeshRouter, + private val fromRadioDispatcher: FromRadioPacketHandler, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val logUuidByPacketId = ConcurrentHashMap() + private val logInsertJobByPacketId = ConcurrentHashMap() + + private val earlyReceivedPackets = ArrayDeque() + private val maxEarlyPacketBuffer = 128 + + fun start(scope: CoroutineScope) { + this.scope = scope + nodeManager.isNodeDbReady + .onEach { ready -> + if (ready) { + flushEarlyReceivedPackets("dbReady") + } + } + .launchIn(scope) + } + + fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) { + runCatching { MeshProtos.FromRadio.parseFrom(bytes) } + .onSuccess { proto -> + if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) { + Logger.w { "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.toHexString()}" } + } + processFromRadio(proto, myNodeNum) + } + .onFailure { primaryException -> + runCatching { + val logRecord = MeshProtos.LogRecord.parseFrom(bytes) + processFromRadio(fromRadio { this.logRecord = logRecord }, myNodeNum) + } + .onFailure { _ -> + Logger.e(primaryException) { + "Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " + + "Not a valid FromRadio or LogRecord." + } + } + } + } + + private fun processFromRadio(proto: MeshProtos.FromRadio, myNodeNum: Int?) { + // Audit log every incoming variant + logVariant(proto) + + if (proto.payloadVariantCase == PayloadVariantCase.PACKET) { + handleReceivedMeshPacket(proto.packet, myNodeNum) + } else { + fromRadioDispatcher.handleFromRadio(proto) + } + } + + private fun logVariant(proto: MeshProtos.FromRadio) { + val (type, message) = + when (proto.payloadVariantCase) { + PayloadVariantCase.LOG_RECORD -> "LogRecord" to proto.logRecord.toString() + PayloadVariantCase.REBOOTED -> "Rebooted" to proto.rebooted.toString() + PayloadVariantCase.XMODEMPACKET -> "XmodemPacket" to proto.xmodemPacket.toString() + PayloadVariantCase.DEVICEUICONFIG -> "DeviceUIConfig" to proto.deviceuiConfig.toString() + PayloadVariantCase.FILEINFO -> "FileInfo" to proto.fileInfo.toString() + else -> return // Other variants (Config, NodeInfo, etc.) are handled by dispatcher but not necessarily + // logged as raw strings here + } + + insertMeshLog( + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = type, + received_date = System.currentTimeMillis(), + raw_message = message, + fromRadio = proto, + ), + ) + } + + fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { + val rxTime = + if (packet.rxTime == 0) (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt() else packet.rxTime + val preparedPacket = packet.toBuilder().setRxTime(rxTime).build() + + if (nodeManager.isNodeDbReady.value) { + processReceivedMeshPacket(preparedPacket, myNodeNum) + } else { + synchronized(earlyReceivedPackets) { + val queueSize = earlyReceivedPackets.size + if (queueSize >= maxEarlyPacketBuffer) { + val dropped = earlyReceivedPackets.removeFirst() + historyLog(Log.WARN) { + val portLabel = + if (dropped.hasDecoded()) { + Portnums.PortNum.forNumber(dropped.decoded.portnumValue)?.name + ?: dropped.decoded.portnumValue.toString() + } else { + "unknown" + } + "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel" + } + } + earlyReceivedPackets.addLast(preparedPacket) + val portLabel = + if (preparedPacket.hasDecoded()) { + Portnums.PortNum.forNumber(preparedPacket.decoded.portnumValue)?.name + ?: preparedPacket.decoded.portnumValue.toString() + } else { + "unknown" + } + historyLog { + "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel" + } + } + } + } + + private fun flushEarlyReceivedPackets(reason: String) { + val packets = + synchronized(earlyReceivedPackets) { + if (earlyReceivedPackets.isEmpty()) return + val list = earlyReceivedPackets.toList() + earlyReceivedPackets.clear() + list + } + historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" } + val myNodeNum = nodeManager.myNodeNum + packets.forEach { processReceivedMeshPacket(it, myNodeNum) } + } + + private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { + if (!packet.hasDecoded()) return + val log = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Packet", + received_date = System.currentTimeMillis(), + raw_message = packet.toString(), + fromNum = packet.from, + portNum = packet.decoded.portnumValue, + fromRadio = fromRadio { this.packet = packet }, + ) + val logJob = insertMeshLog(log) + logInsertJobByPacketId[packet.id] = logJob + logUuidByPacketId[packet.id] = log.uuid + + scope.handledLaunch { serviceRepository.emitMeshPacket(packet) } + + myNodeNum?.let { myNum -> + val isOtherNode = myNum != packet.from + nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) { + it.lastHeard = (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt() + } + nodeManager.updateNodeInfo(packet.from, withBroadcast = false, channel = packet.channel) { + it.lastHeard = packet.rxTime + it.snr = packet.rxSnr + it.rssi = packet.rxRssi + it.hopsAway = + if (packet.decoded.portnumValue == Portnums.PortNum.RANGE_TEST_APP_VALUE) { + 0 + } else if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { + -1 + } else { + packet.hopStart - packet.hopLimit + } + } + + try { + router.dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob) + } finally { + logUuidByPacketId.remove(packet.id) + logInsertJobByPacketId.remove(packet.id) + } + } + } + + private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) } + + private inline fun historyLog( + priority: Int = Log.INFO, + throwable: Throwable? = null, + crossinline message: () -> String, + ) { + if (!BuildConfig.DEBUG) return + val logger = Logger.withTag("HistoryReplay") + val msg = message() + when (priority) { + Log.VERBOSE -> logger.v(throwable) { msg } + Log.DEBUG -> logger.d(throwable) { msg } + Log.INFO -> logger.i(throwable) { msg } + Log.WARN -> logger.w(throwable) { msg } + Log.ERROR -> logger.e(throwable) { msg } + else -> logger.i(throwable) { msg } + } + } + + private fun ByteArray.toHexString(): String = + this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt new file mode 100644 index 000000000..01e679a6b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import co.touchlab.kermit.Logger +import com.geeksville.mesh.repository.network.MQTTRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.MeshProtos.ToRadio +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MeshMqttManager +@Inject +constructor( + private val mqttRepository: MQTTRepository, + private val packetHandler: PacketHandler, + private val serviceRepository: ServiceRepository, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var mqttMessageFlow: Job? = null + + fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) { + this.scope = scope + if (mqttMessageFlow?.isActive == true) return + if (enabled && proxyToClientEnabled) { + mqttMessageFlow = + mqttRepository.proxyMessageFlow + .onEach { message -> + packetHandler.sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) + } + .catch { throwable -> serviceRepository.setErrorMessage("MqttClientProxy failed: $throwable") } + .launchIn(scope) + } + } + + fun stop() { + if (mqttMessageFlow?.isActive == true) { + Logger.i { "Stopping MqttClientProxy" } + mqttMessageFlow?.cancel() + mqttMessageFlow = null + } + } + + fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { + Logger.d { "[mqttClientProxyMessage] ${message.topic}" } + with(message) { + when (payloadVariantCase) { + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> { + mqttRepository.publish(topic, text.encodeToByteArray(), retained) + } + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> { + mqttRepository.publish(topic, data.toByteArray(), retained) + } + else -> {} + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt new file mode 100644 index 000000000..8af302502 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import co.touchlab.kermit.Logger +import com.meshtastic.core.strings.getString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.unknown_username +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.MeshProtos.MeshPacket +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MeshNeighborInfoHandler +@Inject +constructor( + private val nodeManager: MeshNodeManager, + private val serviceRepository: ServiceRepository, + private val commandSender: MeshCommandSender, + private val serviceBroadcasts: MeshServiceBroadcasts, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + fun start(scope: CoroutineScope) { + this.scope = scope + } + + fun handleNeighborInfo(packet: MeshPacket) { + val ni = MeshProtos.NeighborInfo.parseFrom(packet.decoded.payload) + + // Store the last neighbor info from our connected radio + if (packet.from == nodeManager.myNodeNum) { + commandSender.lastNeighborInfo = ni + Logger.d { "Stored last neighbor info from connected radio" } + } + + // Update Node DB + nodeManager.nodeDBbyNodeNum[packet.from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) } + + // Format for UI response + val requestId = packet.decoded.requestId + val start = commandSender.neighborInfoStartTimes.remove(requestId) + + val neighbors = + ni.neighborsList.joinToString("\n") { n -> + val node = nodeManager.nodeDBbyNodeNum[n.nodeId] + val name = node?.let { "${it.longName} (${it.shortName})" } ?: getString(Res.string.unknown_username) + "• $name (SNR: ${n.snr})" + } + + val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[packet.from]?.longName ?: "Unknown"}:\n$neighbors" + + val responseText = + if (start != null) { + val elapsedMs = System.currentTimeMillis() - start + val seconds = elapsedMs / MILLIS_PER_SECOND + Logger.i { "Neighbor info $requestId complete in $seconds s" } + String.format(Locale.US, "%s\n\nDuration: %.1f s", formatted, seconds) + } else { + formatted + } + + serviceRepository.setNeighborInfoResponse(responseText) + } + + companion object { + private const val MILLIS_PER_SECOND = 1000.0 + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt new file mode 100644 index 000000000..1d8bfbb9c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import androidx.annotation.VisibleForTesting +import co.touchlab.kermit.Logger +import com.geeksville.mesh.concurrent.handledLaunch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.database.entity.MetadataEntity +import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.NodeInfo +import org.meshtastic.core.model.Position +import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.PaxcountProtos +import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.copy +import org.meshtastic.proto.telemetry +import org.meshtastic.proto.user +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Suppress("TooManyFunctions") +@Singleton +class MeshNodeManager +@Inject +constructor( + private val nodeRepository: NodeRepository?, + private val serviceBroadcasts: MeshServiceBroadcasts?, + private val serviceNotifications: MeshServiceNotifications?, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + val nodeDBbyNodeNum = ConcurrentHashMap() + val nodeDBbyID = ConcurrentHashMap() + + fun start(scope: CoroutineScope) { + this.scope = scope + } + + val isNodeDbReady = MutableStateFlow(false) + val allowNodeDbWrites = MutableStateFlow(false) + + var myNodeNum: Int? = null + + companion object { + private const val TIME_MS_TO_S = 1000L + } + + @VisibleForTesting internal constructor() : this(null, null, null) + + fun loadCachedNodeDB() { + scope.handledLaunch { + val nodes = nodeRepository?.getNodeDBbyNum()?.first() ?: emptyMap() + nodeDBbyNodeNum.putAll(nodes) + nodes.values.forEach { nodeDBbyID[it.user.id] = it } + myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum + } + } + + fun clear() { + nodeDBbyNodeNum.clear() + nodeDBbyID.clear() + isNodeDbReady.value = false + allowNodeDbWrites.value = false + myNodeNum = null + } + + fun getMyNodeInfo(): MyNodeInfo? { + val mi = nodeRepository?.myNodeInfo?.value ?: return null + val myNode = nodeDBbyNodeNum[mi.myNodeNum] + return MyNodeInfo( + myNodeNum = mi.myNodeNum, + hasGPS = myNode?.position?.latitudeI != 0, + model = mi.model ?: myNode?.user?.hwModel?.name, + firmwareVersion = mi.firmwareVersion, + couldUpdate = mi.couldUpdate, + shouldUpdate = mi.shouldUpdate, + currentPacketId = mi.currentPacketId, + messageTimeoutMsec = mi.messageTimeoutMsec, + minAppVersion = mi.minAppVersion, + maxChannels = mi.maxChannels, + hasWifi = mi.hasWifi, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = mi.deviceId ?: myNode?.user?.id, + ) + } + + fun getMyId(): String { + val num = myNodeNum ?: nodeRepository?.myNodeInfo?.value?.myNodeNum ?: return "" + return nodeDBbyNodeNum[num]?.user?.id ?: "" + } + + fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() } + + fun removeByNodenum(nodeNum: Int) { + nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) } + } + + fun getOrCreateNodeInfo(n: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(n) { + val userId = DataPacket.nodeNumToDefaultId(n) + val defaultUser = user { + id = userId + longName = "Meshtastic ${userId.takeLast(n = 4)}" + shortName = userId.takeLast(n = 4) + hwModel = MeshProtos.HardwareModel.UNSET + } + + NodeEntity( + num = n, + user = defaultUser, + longName = defaultUser.longName, + shortName = defaultUser.shortName, + channel = channel, + ) + } + + fun updateNodeInfo(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, updateFn: (NodeEntity) -> Unit) { + val info = getOrCreateNodeInfo(nodeNum, channel) + updateFn(info) + if (info.user.id.isNotEmpty()) { + nodeDBbyID[info.user.id] = info + } + + if (info.user.id.isNotEmpty() && isNodeDbReady.value) { + scope.handledLaunch { nodeRepository?.upsert(info) } + } + + if (withBroadcast) { + serviceBroadcasts?.broadcastNodeChange(info.toNodeInfo()) + } + } + + fun insertMetadata(nodeNum: Int, metadata: MeshProtos.DeviceMetadata) { + scope.handledLaunch { nodeRepository?.insertMetadata(MetadataEntity(nodeNum, metadata)) } + } + + fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0, manuallyVerified: Boolean = false) { + updateNodeInfo(fromNum) { + val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET) + val shouldPreserve = shouldPreserveExistingUser(it.user, p) + + if (shouldPreserve) { + it.longName = it.user.longName + it.shortName = it.user.shortName + it.channel = channel + it.manuallyVerified = manuallyVerified + } else { + val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey + it.user = if (keyMatch) p else p.copy { publicKey = NodeEntity.ERROR_BYTE_STRING } + it.longName = p.longName + it.shortName = p.shortName + it.channel = channel + it.manuallyVerified = manuallyVerified + if (newNode) { + serviceNotifications?.showNewNodeSeenNotification(it) + } + } + } + } + + fun handleReceivedPosition( + fromNum: Int, + myNodeNum: Int, + p: MeshProtos.Position, + defaultTime: Long = System.currentTimeMillis(), + ) { + if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { + Logger.d { "Ignoring nop position update for the local node" } + } else { + updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / TIME_MS_TO_S).toInt()) } + } + } + + fun handleReceivedTelemetry(fromNum: Int, telemetry: TelemetryProtos.Telemetry) { + updateNodeInfo(fromNum) { nodeEntity -> + when { + telemetry.hasDeviceMetrics() -> nodeEntity.deviceTelemetry = telemetry + telemetry.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetry + telemetry.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetry + } + } + } + + fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) { + updateNodeInfo(fromNum) { it.paxcounter = p } + } + + fun installNodeInfo(info: MeshProtos.NodeInfo, withBroadcast: Boolean = true) { + updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity -> + if (info.hasUser()) { + if (shouldPreserveExistingUser(entity.user, info.user)) { + entity.longName = entity.user.longName + entity.shortName = entity.user.shortName + } else { + entity.user = + info.user.copy { + if (isLicensed) clearPublicKey() + if (info.viaMqtt) longName = "$longName (MQTT)" + } + entity.longName = entity.user.longName + entity.shortName = entity.user.shortName + } + } + if (info.hasPosition()) { + entity.position = info.position + entity.latitude = Position.degD(info.position.latitudeI) + entity.longitude = Position.degD(info.position.longitudeI) + } + entity.lastHeard = info.lastHeard + if (info.hasDeviceMetrics()) { + entity.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics } + } + entity.channel = info.channel + entity.viaMqtt = info.viaMqtt + entity.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1 + entity.isFavorite = info.isFavorite + entity.isIgnored = info.isIgnored + } + } + + private fun shouldPreserveExistingUser(existing: MeshProtos.User, incoming: MeshProtos.User): Boolean { + val isDefaultName = incoming.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) + val isDefaultHwModel = incoming.hwModel == MeshProtos.HardwareModel.UNSET + val hasExistingUser = existing.id.isNotEmpty() && existing.hwModel != MeshProtos.HardwareModel.UNSET + return hasExistingUser && isDefaultName && isDefaultHwModel + } + + fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt new file mode 100644 index 000000000..0fc00ba34 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Orchestrates the specialized packet handlers for the [MeshService]. This class serves as a central registry and + * lifecycle manager for all routing sub-components. + */ +@Suppress("LongParameterList") +@Singleton +class MeshRouter +@Inject +constructor( + val dataHandler: MeshDataHandler, + val configHandler: MeshConfigHandler, + val tracerouteHandler: MeshTracerouteHandler, + val neighborInfoHandler: MeshNeighborInfoHandler, + val configFlowManager: MeshConfigFlowManager, + val mqttManager: MeshMqttManager, + val actionHandler: MeshActionHandler, +) { + fun start(scope: CoroutineScope) { + dataHandler.start(scope) + configHandler.start(scope) + tracerouteHandler.start(scope) + neighborInfoHandler.start(scope) + configFlowManager.start(scope) + actionHandler.start(scope) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 0137c30ad..4a4df7c88 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -17,460 +17,127 @@ package com.geeksville.mesh.service -import android.annotation.SuppressLint -import android.app.Notification import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder -import android.os.RemoteException -import android.util.Log -import androidx.annotation.VisibleForTesting import androidx.core.app.ServiceCompat -import androidx.core.location.LocationCompat import co.touchlab.kermit.Logger import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.model.NO_DEVICE_SELECTED -import com.geeksville.mesh.repository.network.MQTTRepository -import com.geeksville.mesh.repository.radio.InterfaceId import com.geeksville.mesh.repository.radio.RadioInterfaceService -import com.geeksville.mesh.util.ignoreException import com.geeksville.mesh.util.toRemoteExceptions -import com.google.protobuf.ByteString -import com.google.protobuf.InvalidProtocolBufferException -import com.meshtastic.core.strings.getString -import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import org.meshtastic.core.analytics.DataPair -import org.meshtastic.core.analytics.platform.PlatformAnalytics +import kotlinx.coroutines.runBlocking import org.meshtastic.core.common.hasLocationPermission -import org.meshtastic.core.data.repository.LocationRepository -import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.data.repository.TracerouteSnapshotRepository -import org.meshtastic.core.database.DatabaseManager -import org.meshtastic.core.database.entity.MeshLog -import org.meshtastic.core.database.entity.MetadataEntity -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.entity.ReactionEntity -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.Position -import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.model.fullRouteDiscovery -import org.meshtastic.core.model.getFullTracerouteResponse -import org.meshtastic.core.model.util.anonymize -import org.meshtastic.core.model.util.toOneLineString -import org.meshtastic.core.model.util.toPIIString -import org.meshtastic.core.prefs.mesh.MeshPrefs -import org.meshtastic.core.prefs.ui.UiPrefs -import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.service.IMeshService import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.SERVICE_NOTIFY_ID -import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.core.service.TracerouteResponse -import org.meshtastic.core.strings.Res -import org.meshtastic.core.strings.connected_count -import org.meshtastic.core.strings.connecting -import org.meshtastic.core.strings.critical_alert -import org.meshtastic.core.strings.device_sleeping -import org.meshtastic.core.strings.disconnected -import org.meshtastic.core.strings.error_duty_cycle -import org.meshtastic.core.strings.unknown_username -import org.meshtastic.core.strings.waypoint_received -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.AppOnlyProtos -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.DeviceUIProtos -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.FromRadio.PayloadVariantCase -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.MeshProtos.ToRadio -import org.meshtastic.proto.ModuleConfigProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.StoreAndForwardProtos -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.XmodemProtos -import org.meshtastic.proto.copy -import org.meshtastic.proto.fromRadio -import org.meshtastic.proto.position -import org.meshtastic.proto.telemetry -import org.meshtastic.proto.user -import java.util.ArrayDeque -import java.util.Locale -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -import kotlin.math.absoluteValue -/** - * Handles all the communication with android apps. Also keeps an internal model of the network state. - * - * Note: this service will go away once all clients are unbound from it. Warning: do not override toString, it causes - * infinite recursion on some androids (because contextWrapper.getResources calls to string - */ @AndroidEntryPoint class MeshService : Service() { - @Inject lateinit var dispatchers: CoroutineDispatchers - - @Inject lateinit var packetRepository: Lazy - - @Inject lateinit var meshLogRepository: Lazy - - @Inject lateinit var tracerouteSnapshotRepository: TracerouteSnapshotRepository @Inject lateinit var radioInterfaceService: RadioInterfaceService - @Inject lateinit var locationRepository: LocationRepository - - @Inject lateinit var radioConfigRepository: RadioConfigRepository - @Inject lateinit var serviceRepository: ServiceRepository - @Inject lateinit var nodeRepository: NodeRepository - - @Inject lateinit var databaseManager: DatabaseManager - - @Inject lateinit var mqttRepository: MQTTRepository - - @Inject lateinit var serviceNotifications: MeshServiceNotifications - - @Inject lateinit var meshPrefs: MeshPrefs - - @Inject lateinit var uiPrefs: UiPrefs - - @Inject lateinit var connectionStateHolder: MeshServiceConnectionStateHolder + @Inject lateinit var connectionStateHolder: ConnectionStateHandler @Inject lateinit var packetHandler: PacketHandler @Inject lateinit var serviceBroadcasts: MeshServiceBroadcasts - @Inject lateinit var analytics: PlatformAnalytics + @Inject lateinit var nodeManager: MeshNodeManager - private val tracerouteStartTimes = ConcurrentHashMap() - private val neighborInfoStartTimes = ConcurrentHashMap() + @Inject lateinit var messageProcessor: MeshMessageProcessor - @Volatile private var lastNeighborInfo: MeshProtos.NeighborInfo? = null - private val logUuidByPacketId = ConcurrentHashMap() - private val logInsertJobByPacketId = ConcurrentHashMap() + @Inject lateinit var commandSender: MeshCommandSender - companion object { + @Inject lateinit var locationManager: MeshLocationManager - // Intents broadcast by MeshService + @Inject lateinit var connectionManager: MeshConnectionManager - private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum" + @Inject lateinit var serviceNotifications: MeshServiceNotifications - // generate a RECEIVED action filter string that includes either the portnumber as an int, - // or preferably a - // symbolic name from portnums.proto - fun actionReceived(portNum: Int): String { - val portType = Portnums.PortNum.forNumber(portNum) - val portStr = portType?.toString() ?: portNum.toString() + @Inject lateinit var radioConfigRepository: RadioConfigRepository - return actionReceived(portStr) - } - - const val ACTION_NODE_CHANGE = "$prefix.NODE_CHANGE" - const val ACTION_MESH_CONNECTED = "$prefix.MESH_CONNECTED" - const val ACTION_MESSAGE_STATUS = "$prefix.MESSAGE_STATUS" - - open class NodeNotFoundException(reason: String) : Exception(reason) - - class InvalidNodeIdException(id: String) : NodeNotFoundException("Invalid NodeId $id") - - class NodeNumNotFoundException(id: Int) : NodeNotFoundException("NodeNum not found $id") - - class IdNotFoundException(id: String) : NodeNotFoundException("ID not found $id") - - class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") : - RadioNotConnectedException(message) - - /** - * Talk to our running service and try to set a new device address. And then immediately call start on the - * service to possibly promote our service to be a foreground service. - */ - fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { - service.setDeviceAddress(address) - startService(context) - } - - fun createIntent(context: Context) = Intent(context, MeshService::class.java) - - /** - * The minimum firmware version we know how to talk to. We'll still be able to talk to 2.0 firmwares but only - * well enough to ask them to firmware update. - */ - val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION) - val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION) - - // Two-stage config flow nonces to avoid stale BLE packets, mirroring Meshtastic-Apple - private const val DEFAULT_CONFIG_ONLY_NONCE = 69420 - private const val DEFAULT_NODE_INFO_NONCE = 69421 - - private const val WANT_CONFIG_DELAY = 100L - private const val HISTORY_TAG = "HistoryReplay" - private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24 - private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100 - private const val MAX_EARLY_PACKET_BUFFER = 128 - - private const val NEIGHBOR_RQ_COOLDOWN = 3 * 60 * 1000L // ms - - @VisibleForTesting - internal fun buildStoreForwardHistoryRequest( - lastRequest: Int, - historyReturnWindow: Int, - historyReturnMax: Int, - ): StoreAndForwardProtos.StoreAndForward { - val historyBuilder = StoreAndForwardProtos.StoreAndForward.History.newBuilder() - if (lastRequest > 0) historyBuilder.lastRequest = lastRequest - if (historyReturnWindow > 0) historyBuilder.window = historyReturnWindow - if (historyReturnMax > 0) historyBuilder.historyMessages = historyReturnMax - return StoreAndForwardProtos.StoreAndForward.newBuilder() - .setRr(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY) - .setHistory(historyBuilder) - .build() - } - - @VisibleForTesting - internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair { - val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES - val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES - return resolvedWindow to resolvedMax - } - } + @Inject lateinit var router: MeshRouter private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) - private inline fun historyLog( - priority: Int = Log.INFO, - throwable: Throwable? = null, - crossinline message: () -> String, - ) { - if (!BuildConfig.DEBUG) return - val severity = - when (priority) { - Log.VERBOSE -> co.touchlab.kermit.Severity.Verbose - Log.DEBUG -> co.touchlab.kermit.Severity.Debug - Log.INFO -> co.touchlab.kermit.Severity.Info - Log.WARN -> co.touchlab.kermit.Severity.Warn - Log.ERROR -> co.touchlab.kermit.Severity.Error - else -> co.touchlab.kermit.Severity.Info - } - val msg = message() - if (throwable != null) { - Logger.log(severity, HISTORY_TAG, throwable, msg) - } else { - Logger.log(severity, HISTORY_TAG, null, msg) + private val myNodeNum: Int + get() = nodeManager.myNodeNum ?: throw RadioNotConnectedException() + + companion object { + fun actionReceived(portNum: Int): String { + val portType = org.meshtastic.proto.Portnums.PortNum.forNumber(portNum) + val portStr = portType?.toString() ?: portNum.toString() + return com.geeksville.mesh.service.actionReceived(portStr) } - } - private fun activeDeviceAddress(): String? = - meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() } + fun createIntent(context: Context) = Intent(context, MeshService::class.java) - private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) { - InterfaceId.BLUETOOTH.id -> "BLE" - InterfaceId.TCP.id -> "TCP" - InterfaceId.SERIAL.id -> "Serial" - InterfaceId.MOCK.id -> "Mock" - InterfaceId.NOP.id -> "NOP" - else -> "Unknown" - } - - private var locationFlow: Job? = null - private var mqttMessageFlow: Job? = null - - private val batteryPercentUnsupported = 0.0 - private val batteryPercentLowThreshold = 20 - private val batteryPercentLowDivisor = 5 - private val batteryPercentCriticalThreshold = 5 - private val batteryPercentCooldownSeconds = 1500 - private val batteryPercentCooldowns: HashMap = HashMap() - - private val oneHour = 3600 - - private fun getSenderName(packet: DataPacket?): String { - val name = nodeDBbyID[packet?.from]?.user?.longName - return name ?: getString(Res.string.unknown_username) - } - - /** start our location requests (if they weren't already running) */ - private fun startLocationRequests() { - // If we're already observing updates, don't register again - if (locationFlow?.isActive == true) return - - @SuppressLint("MissingPermission") - if (hasLocationPermission()) { - locationFlow = - locationRepository - .getLocations() - .onEach { location -> - sendPosition( - position { - latitudeI = Position.degI(location.latitude) - longitudeI = Position.degI(location.longitude) - if (LocationCompat.hasMslAltitude(location)) { - altitude = LocationCompat.getMslAltitudeMeters(location).toInt() - } - altitudeHae = location.altitude.toInt() - time = (location.time / 1000).toInt() - groundSpeed = location.speed.toInt() - groundTrack = location.bearing.toInt() - locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL - }, - ) - } - .launchIn(serviceScope) - } - } - - private fun stopLocationRequests() { - if (locationFlow?.isActive == true) { - Logger.i { "Stopping location requests" } - locationFlow?.cancel() - locationFlow = null - } - } - - private fun showAlertNotification(contactKey: String, dataPacket: DataPacket) { - serviceNotifications.showAlertNotification( - contactKey, - getSenderName(dataPacket), - dataPacket.alert ?: getString(Res.string.critical_alert), - ) - } - - private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) { - val message: String = - when (dataPacket.dataType) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! - Portnums.PortNum.WAYPOINT_APP_VALUE -> { - getString(Res.string.waypoint_received, dataPacket.waypoint!!.name) - } - - else -> return - } - - serviceNotifications.updateMessageNotification( - contactKey, - getSenderName(dataPacket), - message, - isBroadcast = dataPacket.to == DataPacket.ID_BROADCAST, - channelName = - if (dataPacket.to == DataPacket.ID_BROADCAST) { - channelSet.settingsList[dataPacket.channel].name + fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { + service.setDeviceAddress(address) + val intent = Intent(context, MeshService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) } else { - null - }, - ) + context.startService(intent) + } + } + + val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION) + val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION) } override fun onCreate() { super.onCreate() Logger.i { "Creating mesh service" } serviceNotifications.initChannels() - // Switch to the IO thread + + packetHandler.start(serviceScope) + router.start(serviceScope) + nodeManager.start(serviceScope) + connectionManager.start(serviceScope) + messageProcessor.start(serviceScope) + commandSender.start(serviceScope) + serviceScope.handledLaunch { radioInterfaceService.connect() } - radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope) + radioInterfaceService.receivedData - .onStart { - historyLog { "rxCollector START transport=${currentTransport()} scope=${serviceScope.hashCode()}" } - } - .onCompletion { cause -> - historyLog(Log.WARN) { - "rxCollector STOP transport=${currentTransport()} cause=${cause?.message ?: "completed"}" - } - } - .onEach(::onReceiveFromRadio) - .launchIn(serviceScope) - radioInterfaceService.connectionError - .onEach { error -> Logger.e { "BLE Connection Error: ${error.message}" } } - .launchIn(serviceScope) - radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope) - radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope) - radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope) - serviceRepository.serviceAction.onEach(::onServiceAction).launchIn(serviceScope) - nodeRepository.myNodeInfo - .onEach { myNodeInfo = it } - .flatMapLatest { myNodeEntity -> - // When myNodeInfo changes, set up emissions for the "provide-location-nodeNum" pref. - if (myNodeEntity == null) { - flowOf(false) - } else { - uiPrefs.shouldProvideNodeLocation(myNodeEntity.myNodeNum) - } - } - .onEach { shouldProvideNodeLocation -> - if (shouldProvideNodeLocation) { - startLocationRequests() - } else { - stopLocationRequests() - } - } + .onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum) } .launchIn(serviceScope) - loadCachedNodeDB() // Load our last known node DB + serviceRepository.serviceAction.onEach(router.actionHandler::onServiceAction).launchIn(serviceScope) - // the rest of our init will happen once we are in radioConnection.onServiceConnected + nodeManager.loadCachedNodeDB() } - /** If someone binds to us, this will be called after on create */ - override fun onBind(intent: Intent?): IBinder = binder - - /** - * Called when the service is started or restarted. This method manages the foreground state of the service. - * - * It attempts to start the service in the foreground with a notification. If `startForeground` fails, for example, - * due to a `SecurityException` on Android 13+ because the `POST_NOTIFICATIONS` permission is missing, it logs an - * error* and returns `START_NOT_STICKY` to prevent the service from becoming sticky in a broken state. - * - * If the service is not intended to be in the foreground (e.g., no device is connected), it stops the foreground - * state and returns `START_NOT_STICKY`. Otherwise, it returns `START_STICKY`. - * - * @param intent The Intent supplied to `startService(Intent)`, as modified by the system. - * @param flags Additional data about this start request. - * @param startId A unique integer representing this specific request to start. - * @return The return value indicates what semantics the system should use for the service's current started state. - * See [Service.onStartCommand] for details. - */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val a = radioInterfaceService.getBondedDeviceAddress() val wantForeground = a != null && a != NO_DEVICE_SELECTED - Logger.i { "Requesting foreground service=$wantForeground" } - val notification = updateServiceStatusNotification() + val notification = connectionManager.updateStatusNotification() try { ServiceCompat.startForeground( @@ -484,7 +151,7 @@ class MeshService : Service() { ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE } } else { - 0 // No specific type needed for older Android versions + 0 }, ) } catch (ex: Exception) { @@ -499,2480 +166,182 @@ class MeshService : Service() { } } + override fun onBind(intent: Intent?): IBinder = binder + override fun onDestroy() { Logger.i { "Destroying mesh service" } - - // Make sure we aren't using the notification first ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - - super.onDestroy() serviceJob.cancel() - } - - // - // BEGINNING OF MODEL - FIXME, move elsewhere - // - - private fun loadCachedNodeDB() = - serviceScope.handledLaunch { nodeDBbyNodeNum.putAll(nodeRepository.getNodeDBbyNum().first()) } - - /** discard entire node db & message state - used when downloading a new db from the device */ - private fun discardNodeDB() { - Logger.d { "Discarding NodeDB" } - myNodeInfo = null - nodeDBbyNodeNum.clear() - isNodeDbReady = false - allowNodeDbWrites = false - earlyReceivedPackets.clear() - } - - private var myNodeInfo: MyNodeEntity? = null - - private val configTotal by lazy { ConfigProtos.Config.getDescriptor().fields.size } - private val moduleTotal by lazy { ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size } - private var sessionPasskey: ByteString = ByteString.EMPTY - - private var localConfig: LocalConfig = LocalConfig.getDefaultInstance() - private var moduleConfig: LocalModuleConfig = LocalModuleConfig.getDefaultInstance() - private var channelSet: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() - - // True after we've done our initial node db init, signaling we can process packets immediately - @Volatile private var isNodeDbReady = false - - // True when we are allowed to write node updates to the persistent database - @Volatile private var allowNodeDbWrites = false - - // The database of active nodes, index is the node number - private val nodeDBbyNodeNum = ConcurrentHashMap() - - // The database of active nodes, index is the node user ID string - // NOTE: some NodeInfos might be in only nodeDBbyNodeNum (because we don't yet know an ID). - private val nodeDBbyID - get() = nodeDBbyNodeNum.mapKeys { it.value.user.id } - - // - // END OF MODEL - // - - @Suppress("UnusedPrivateMember") - private val deviceVersion - get() = DeviceVersion(myNodeInfo?.firmwareVersion ?: "") - - @Suppress("UnusedPrivateMember") - private val appVersion - get() = BuildConfig.VERSION_CODE - - private val minAppVersion - get() = myNodeInfo?.minAppVersion ?: 0 - - // Map a nodenum to a node, or throw an exception if not found - private fun toNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: throw NodeNumNotFoundException(n) - - /** - * Map a nodeNum to the nodeId string If we have a NodeInfo for this ID we prefer to return the string ID inside the - * user record. but some nodes might not have a user record at all (because not yet received), in that case, we - * return a hex version of the ID just based on the number - */ - private fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST - } else { - nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) - } - - // given a nodeNum, return a db entry - creating if necessary - private fun getOrCreateNodeInfo(n: Int, channel: Int = 0) = nodeDBbyNodeNum.getOrPut(n) { - val userId = DataPacket.nodeNumToDefaultId(n) - val defaultUser = user { - id = userId - longName = "Meshtastic ${userId.takeLast(n = 4)}" - shortName = userId.takeLast(n = 4) - hwModel = MeshProtos.HardwareModel.UNSET - } - - NodeEntity( - num = n, - user = defaultUser, - longName = defaultUser.longName, - shortName = defaultUser.shortName, - channel = channel, - ) - } - - private val hexIdRegex = """!([0-9A-Fa-f]+)""".toRegex() - - // Map a userid to a node/ node num, or throw an exception if not found - // We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a - // node, we can also find - // it based on node number - private fun toNodeInfo(id: String): NodeEntity { - // If this is a valid hexaddr will be !null - val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value - - return nodeDBbyID[id] - ?: when { - id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum) - hexStr != null -> { - val n = hexStr.toLong(16).toInt() - nodeDBbyNodeNum[n] ?: throw IdNotFoundException(id) - } - - else -> throw InvalidNodeIdException(id) - } - } - - private fun getUserName(num: Int): String = with(nodeRepository.getUser(num)) { "$longName ($shortName)" } - - private val numNodes - get() = nodeDBbyNodeNum.size - - /** How many nodes are currently online (including our local node) */ - private val numOnlineNodes - get() = nodeDBbyNodeNum.values.count { it.isOnline } - - private fun toNodeNum(id: String): Int = when (id) { - DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST - DataPacket.ID_LOCAL -> myNodeNum - else -> toNodeInfo(id).num - } - - // A helper function that makes it easy to update node info objects - private inline fun updateNodeInfo( - nodeNum: Int, - withBroadcast: Boolean = true, - channel: Int = 0, - crossinline updateFn: (NodeEntity) -> Unit, - ) { - val info = getOrCreateNodeInfo(nodeNum, channel) - updateFn(info) - - if (info.user.id.isNotEmpty() && isNodeDbReady) { - serviceScope.handledLaunch { nodeRepository.upsert(info) } - } - - if (withBroadcast) { - serviceBroadcasts.broadcastNodeChange(info.toNodeInfo()) - } - } - - // My node num - private val myNodeNum - get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("We don't yet have our myNodeInfo") - - // My node ID string - private val myNodeID - get() = toNodeID(myNodeNum) - - // Admin channel index - private val MeshPacket.Builder.adminChannelIndex: Int - get() = - when { - myNodeNum == to -> 0 - nodeDBbyNodeNum[myNodeNum]?.hasPKC == true && nodeDBbyNodeNum[to]?.hasPKC == true -> - DataPacket.PKC_CHANNEL_INDEX - - else -> - channelSet.settingsList.indexOfFirst { it.name.equals("admin", ignoreCase = true) }.coerceAtLeast(0) - } - - // Generate a new mesh packet builder with our node as the sender, and the specified node num - private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply { - if (myNodeInfo == null) { - throw RadioNotConnectedException() - } - - from = 0 // don't add myNodeNum - - to = idNum - } - - /** - * Generate a new mesh packet builder with our node as the sender, and the specified recipient - * - * If id is null we assume a broadcast message - */ - private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id)) - - /** Helper to make it easy to build a subpacket in the proper protobufs */ - private fun MeshPacket.Builder.buildMeshPacket( - wantAck: Boolean = false, - id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one - hopLimit: Int = localConfig.lora.hopLimit, - channel: Int = 0, - priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, - initFn: MeshProtos.Data.Builder.() -> Unit, - ): MeshPacket { - this.wantAck = wantAck - this.id = id - this.hopLimit = hopLimit - this.priority = priority - decoded = MeshProtos.Data.newBuilder().also { initFn(it) }.build() - if (channel == DataPacket.PKC_CHANNEL_INDEX) { - pkiEncrypted = true - nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> this.publicKey = publicKey } - } else { - this.channel = channel - } - - return build() - } - - /** Helper to make it easy to build a subpacket in the proper protobufs */ - private fun MeshPacket.Builder.buildAdminPacket( - id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one - wantResponse: Boolean = false, - initFn: AdminProtos.AdminMessage.Builder.() -> Unit, - ): MeshPacket = - buildMeshPacket(id = id, wantAck = true, channel = adminChannelIndex, priority = MeshPacket.Priority.RELIABLE) { - this.wantResponse = wantResponse - portnumValue = Portnums.PortNum.ADMIN_APP_VALUE - payload = - AdminProtos.AdminMessage.newBuilder() - .also { - initFn(it) - it.sessionPasskey = sessionPasskey - } - .build() - .toByteString() - } - - // Generate a DataPacket from a MeshPacket, or null if we didn't have enough data to do so - private fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) { - // We never convert packets that are not DataPackets - null - } else { - val data = packet.decoded - - DataPacket( - from = toNodeID(packet.from), - to = toNodeID(packet.to), - time = packet.rxTime * 1000L, - id = packet.id, - dataType = data.portnumValue, - bytes = data.payload.toByteArray(), - hopLimit = packet.hopLimit, - channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel, - wantAck = packet.wantAck, - hopStart = packet.hopStart, - snr = packet.rxSnr, - rssi = packet.rxRssi, - replyId = data.replyId, - relayNode = packet.relayNode, - viaMqtt = packet.viaMqtt, - ) - } - - private fun toMeshPacket(p: DataPacket): MeshPacket = newMeshPacketTo(p.to!!).buildMeshPacket( - id = p.id, - wantAck = p.wantAck, - hopLimit = p.hopLimit, - channel = p.channel, - ) { - portnumValue = p.dataType - payload = ByteString.copyFrom(p.bytes) - if (p.replyId != null && p.replyId != 0) { - this.replyId = p.replyId!! - } - } - - private val rememberDataType = - setOf( - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - Portnums.PortNum.ALERT_APP_VALUE, - Portnums.PortNum.WAYPOINT_APP_VALUE, - ) - - private fun rememberReaction(packet: MeshPacket) = serviceScope.handledLaunch { - val reaction = - ReactionEntity( - replyId = packet.decoded.replyId, - userId = toNodeID(packet.from), - emoji = packet.decoded.payload.toByteArray().decodeToString(), - timestamp = System.currentTimeMillis(), - snr = packet.rxSnr, - rssi = packet.rxRssi, - hopsAway = getHopsAwayForPacket(packet), - ) - packetRepository.get().insertReaction(reaction) - } - - private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { - if (dataPacket.dataType !in rememberDataType) return - val fromLocal = dataPacket.from == DataPacket.ID_LOCAL - val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST - val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from - - // contactKey: unique contact key filter (channel)+(nodeId) - val contactKey = "${dataPacket.channel}$contactId" - - val packetToSave = - Packet( - uuid = 0L, // autoGenerated - myNodeNum = myNodeNum, - packetId = dataPacket.id, - port_num = dataPacket.dataType, - contact_key = contactKey, - received_time = System.currentTimeMillis(), - read = fromLocal, - data = dataPacket, - snr = dataPacket.snr, - rssi = dataPacket.rssi, - hopsAway = dataPacket.hopsAway, - replyId = dataPacket.replyId ?: 0, - ) - serviceScope.handledLaunch { - packetRepository.get().apply { - insert(packetToSave) - val isMuted = getContactSettings(contactKey).isMuted - if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isMuted) { - showAlertNotification(contactKey, dataPacket) - } else if (updateNotification && !isMuted) { - updateMessageNotification(contactKey, dataPacket) - } - } - } - } - - // Update our model and resend as needed for a MeshPacket we just received from the radio - private fun handleReceivedData(packet: MeshPacket) { - myNodeInfo?.let { myInfo -> - val data = packet.decoded - val bytes = data.payload.toByteArray() - val fromId = toNodeID(packet.from) - val dataPacket = toDataPacket(packet) - - if (dataPacket != null) { - // We ignore most messages that we sent - val fromUs = myInfo.myNodeNum == packet.from - - Logger.d { "Received data from $fromId, portnum=${data.portnum} ${bytes.size} bytes" } - - dataPacket.status = MessageStatus.RECEIVED - - // if (p.hasUser()) handleReceivedUser(fromNum, p.user) - - // We tell other apps about most message types, but some may have sensitive data, so - // that is not shared' - var shouldBroadcast = !fromUs - - val logUuid = logUuidByPacketId[packet.id] - val logInsertJob = logInsertJobByPacketId[packet.id] - when (data.portnumValue) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { - if (data.replyId != 0 && data.emoji == 0) { - Logger.d { "Received REPLY from $fromId" } - rememberDataPacket(dataPacket) - } else if (data.replyId != 0 && data.emoji != 0) { - Logger.d { "Received EMOJI from $fromId" } - rememberReaction(packet) - } else { - Logger.d { "Received CLEAR_TEXT from $fromId" } - rememberDataPacket(dataPacket) - } - } - - Portnums.PortNum.ALERT_APP_VALUE -> { - Logger.d { "Received ALERT_APP from $fromId" } - rememberDataPacket(dataPacket) - } - - Portnums.PortNum.WAYPOINT_APP_VALUE -> { - Logger.d { "Received WAYPOINT_APP from $fromId" } - val u = MeshProtos.Waypoint.parseFrom(data.payload) - // Validate locked Waypoints from the original sender - if (u.lockedTo != 0 && u.lockedTo != packet.from) return - rememberDataPacket(dataPacket, u.expire > currentSecond()) - } - - Portnums.PortNum.POSITION_APP_VALUE -> { - Logger.d { "Received POSITION_APP from $fromId" } - val u = MeshProtos.Position.parseFrom(data.payload) - if (data.wantResponse && u.latitudeI == 0 && u.longitudeI == 0) { - Logger.d { "Ignoring nop position update from position request" } - } else { - handleReceivedPosition(packet.from, u, dataPacket.time) - } - } - - Portnums.PortNum.NODEINFO_APP_VALUE -> - if (!fromUs) { - Logger.d { "Received NODEINFO_APP from $fromId" } - val u = - MeshProtos.User.parseFrom(data.payload).copy { - if (isLicensed) clearPublicKey() - if (packet.viaMqtt) longName = "$longName (MQTT)" - } - handleReceivedUser(packet.from, u, packet.channel) - } - - // Handle new telemetry info - Portnums.PortNum.TELEMETRY_APP_VALUE -> { - Logger.d { "Received TELEMETRY_APP from $fromId" } - val u = - TelemetryProtos.Telemetry.parseFrom(data.payload).copy { - if (time == 0) time = (dataPacket.time / 1000L).toInt() - } - handleReceivedTelemetry(packet.from, u) - } - - Portnums.PortNum.ROUTING_APP_VALUE -> { - Logger.d { "Received ROUTING_APP from $fromId" } - // We always send ACKs to other apps, because they might care about the - // messages they sent - shouldBroadcast = true - val u = MeshProtos.Routing.parseFrom(data.payload) - - if (u.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { - serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle)) - } - - handleAckNak(data.requestId, fromId, u.errorReasonValue, dataPacket.relayNode) - packetHandler.removeResponse(data.requestId, complete = true) - } - - Portnums.PortNum.ADMIN_APP_VALUE -> { - Logger.d { "Received ADMIN_APP from $fromId" } - val u = AdminProtos.AdminMessage.parseFrom(data.payload) - handleReceivedAdmin(packet.from, u) - shouldBroadcast = false - } - - Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { - Logger.d { "Received PAXCOUNTER_APP from $fromId" } - val p = PaxcountProtos.Paxcount.parseFrom(data.payload) - handleReceivedPaxcounter(packet.from, p) - shouldBroadcast = false - } - - Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { - Logger.d { "Received STORE_FORWARD_APP from $fromId" } - val u = StoreAndForwardProtos.StoreAndForward.parseFrom(data.payload) - handleReceivedStoreAndForward(dataPacket, u) - shouldBroadcast = false - } - - Portnums.PortNum.RANGE_TEST_APP_VALUE -> { - Logger.d { "Received RANGE_TEST_APP from $fromId" } - if (!moduleConfig.rangeTest.enabled) return - val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(u) - } - - Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> { - Logger.d { "Received DETECTION_SENSOR_APP from $fromId" } - val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(u) - } - - Portnums.PortNum.TRACEROUTE_APP_VALUE -> { - Logger.d { "Received TRACEROUTE_APP from $fromId" } - val routeDiscovery = packet.fullRouteDiscovery - val full = packet.getFullTracerouteResponse(::getUserName) - if (full != null) { - val requestId = packet.decoded.requestId - if (logUuid != null) { - serviceScope.handledLaunch { - logInsertJob?.join() - val forwardRoute = routeDiscovery?.routeList.orEmpty() - val returnRoute = routeDiscovery?.routeBackList.orEmpty() - val routeNodeNums = (forwardRoute + returnRoute).distinct() - val nodeDbByNum = nodeRepository.nodeDBbyNum.value - val snapshotPositions = - routeNodeNums - .mapNotNull { nodeNum -> - val position = - nodeDbByNum[nodeNum]?.validPosition ?: return@mapNotNull null - nodeNum to position - } - .toMap() - tracerouteSnapshotRepository.upsertSnapshotPositions( - logUuid = logUuid, - requestId = requestId, - positions = snapshotPositions, - ) - } - } - val start = tracerouteStartTimes.remove(requestId) - val responseText = - if (start != null) { - val elapsedMs = System.currentTimeMillis() - start - val seconds = elapsedMs / 1000.0 - Logger.i { "Traceroute $requestId complete in $seconds s" } - "$full\n\nDuration: ${"%.1f".format(seconds)} s" - } else { - full - } - val destination = - routeDiscovery?.routeList?.firstOrNull() - ?: routeDiscovery?.routeBackList?.lastOrNull() - ?: 0 - serviceRepository.setTracerouteResponse( - TracerouteResponse( - message = responseText, - destinationNodeNum = destination, - requestId = requestId, - forwardRoute = routeDiscovery?.routeList.orEmpty(), - returnRoute = routeDiscovery?.routeBackList.orEmpty(), - logUuid = logUuid, - ), - ) - } - } - - Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> { - val requestId = packet.decoded.requestId - Logger.d { "Processing NEIGHBORINFO_APP packet with requestId: $requestId" } - val start = neighborInfoStartTimes.remove(requestId) - Logger.d { "Found start time for requestId $requestId: $start" } - - val info = - runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() - - // Store the last neighbor info from our connected radio - if (info != null && packet.from == myInfo.myNodeNum) { - lastNeighborInfo = info - Logger.d { "Stored last neighbor info from connected radio" } - } - - // Only show response if packet is addressed to us and we sent a request in the last 3 minutes - val isAddressedToUs = packet.to == myInfo.myNodeNum - val isRecentRequest = - start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN - - if (isAddressedToUs && isRecentRequest) { - val formatted = - if (info != null) { - val fmtNode: (Int) -> String = { nodeNum -> - val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user - val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: "" - val nodeId = "!%08x".format(nodeNum) - if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId - } - buildString { - appendLine("NeighborInfo:") - appendLine("node_id: ${fmtNode(info.nodeId)}") - appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}") - appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}") - if (info.neighborsCount > 0) { - appendLine("neighbors:") - info.neighborsList.forEach { n -> - appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}") - } - } - } - } else { - // Fallback to raw string if parsing fails - String(data.payload.toByteArray()) - } - - val response = - if (start != null) { - val elapsedMs = System.currentTimeMillis() - start - val seconds = elapsedMs / 1000.0 - Logger.i { "Neighbor info $requestId complete in $seconds s" } - "$formatted\n\nDuration: ${"%.1f".format(seconds)} s" - } else { - Logger.w { "No start time found for neighbor info requestId: $requestId" } - formatted - } - serviceRepository.setNeighborInfoResponse(response) - } else { - Logger.d { - "Neighbor info response filtered: ToUs=$isAddressedToUs, isRecentRequest=$isRecentRequest" - } - } - } - - Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> { - val requestId = packet.decoded.requestId - Logger.d { "Processing NEIGHBORINFO_APP packet with requestId: $requestId" } - val start = neighborInfoStartTimes.remove(requestId) - Logger.d { "Found start time for requestId $requestId: $start" } - - val info = - runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() - - // Store the last neighbor info from our connected radio - if (info != null && packet.from == myInfo.myNodeNum) { - lastNeighborInfo = info - Logger.d { "Stored last neighbor info from connected radio" } - } - - // Only show response if packet is addressed to us and we sent a request in the last 3 minutes - val isAddressedToUs = packet.to == myInfo.myNodeNum - val isRecentRequest = - start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN - - if (isAddressedToUs && isRecentRequest) { - val formatted = - if (info != null) { - val fmtNode: (Int) -> String = { nodeNum -> - val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user - val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: "" - val nodeId = "!%08x".format(nodeNum) - if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId - } - buildString { - appendLine("NeighborInfo:") - appendLine("node_id: ${fmtNode(info.nodeId)}") - appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}") - appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}") - if (info.neighborsCount > 0) { - appendLine("neighbors:") - info.neighborsList.forEach { n -> - appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}") - } - } - } - } else { - // Fallback to raw string if parsing fails - String(data.payload.toByteArray()) - } - - val response = - if (start != null) { - val elapsedMs = System.currentTimeMillis() - start - val seconds = elapsedMs / 1000.0 - Logger.i { "Neighbor info $requestId complete in $seconds s" } - "$formatted\n\nDuration: ${"%.1f".format(seconds)} s" - } else { - Logger.w { "No start time found for neighbor info requestId: $requestId" } - formatted - } - serviceRepository.setNeighborInfoResponse(response) - } else { - Logger.d { - "Neighbor info response filtered: isToUs=$isAddressedToUs, isRecent=$isRecentRequest" - } - } - } - - else -> Logger.d { "No custom processing needed for ${data.portnumValue} from $fromId" } - } - - // We always tell other apps when new data packets arrive - if (shouldBroadcast) { - serviceBroadcasts.broadcastReceivedData(dataPacket) - } - - analytics.track("num_data_receive", DataPair("num_data_receive", 1)) - - analytics.track("data_receive", DataPair("num_bytes", bytes.size), DataPair("type", data.portnumValue)) - } - } - } - - private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) { - when (a.payloadVariantCase) { - AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { - if (fromNodeNum == myNodeNum) { - val response = a.getConfigResponse - Logger.d { "Admin: received config ${response.payloadVariantCase}" } - setLocalConfig(response) - } - } - - AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { - if (fromNodeNum == myNodeNum) { - val mi = myNodeInfo - if (mi != null) { - val ch = a.getChannelResponse - Logger.d { "Admin: Received channel ${ch.index}" } - - if (ch.index + 1 < mi.maxChannels) { - handleChannel(ch) - } - } - } - } - - AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> { - Logger.d { "Admin: received DeviceMetadata from $fromNodeNum" } - serviceScope.handledLaunch { - nodeRepository.insertMetadata(MetadataEntity(fromNodeNum, a.getDeviceMetadataResponse)) - } - } - - else -> Logger.w { "No special processing needed for ${a.payloadVariantCase}" } - } - Logger.d { "Admin: Received session_passkey from $fromNodeNum" } - sessionPasskey = a.sessionPasskey - } - - /** - * Check if a User is a default/placeholder from firmware (node was evicted and re-created) and whether we should - * preserve existing user data instead of overwriting it. - */ - private fun shouldPreserveExistingUser(existing: MeshProtos.User, incoming: MeshProtos.User): Boolean { - val isDefaultName = incoming.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) - val isDefaultHwModel = incoming.hwModel == MeshProtos.HardwareModel.UNSET - val hasExistingUser = existing.id.isNotEmpty() && existing.hwModel != MeshProtos.HardwareModel.UNSET - return hasExistingUser && isDefaultName && isDefaultHwModel - } - - private fun handleSharedContactImport(contact: AdminProtos.SharedContact) { - handleReceivedUser(contact.nodeNum, contact.user, manuallyVerified = true) - } - - // Update our DB of users based on someone sending out a User subpacket - private fun handleReceivedUser( - fromNum: Int, - p: MeshProtos.User, - channel: Int = 0, - manuallyVerified: Boolean = false, - ) { - updateNodeInfo(fromNum) { - val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET) - - // Check if this is a default/unknown user from firmware (node was evicted and re-created) - val shouldPreserve = shouldPreserveExistingUser(it.user, p) - - if (shouldPreserve) { - // Firmware sent us a placeholder - keep all our existing user data - Logger.d { - "Preserving existing user data for node $fromNum: " + - "kept='${it.user.longName}' (hwModel=${it.user.hwModel}), " + - "skipped default='${p.longName}' (hwModel=UNSET)" - } - // Ensure denormalized columns are updated from preserved user data - it.longName = it.user.longName - it.shortName = it.user.shortName - // Still update channel and verification status - it.channel = channel - it.manuallyVerified = manuallyVerified - } else { - val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey - it.user = - if (keyMatch) { - p - } else { - p.copy { - Logger.w { "Public key mismatch from $longName ($shortName)" } - publicKey = NodeEntity.ERROR_BYTE_STRING - } - } - it.longName = p.longName - it.shortName = p.shortName - it.channel = channel - it.manuallyVerified = manuallyVerified - if (newNode) { - serviceNotifications.showNewNodeSeenNotification(it) - } - } - } - } - - /** - * Update our DB of users based on someone sending out a Position subpacket - * - * @param defaultTime in msecs since 1970 - */ - private fun handleReceivedPosition( - fromNum: Int, - p: MeshProtos.Position, - defaultTime: Long = System.currentTimeMillis(), - ) { - // Nodes periodically send out position updates, but those updates might not contain a lat & - // lon (because no GPS - // lock) - // We like to look at the local node to see if it has been sending out valid lat/lon, so for - // the LOCAL node - // (only) - // we don't record these nop position updates - if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { - Logger.d { "Ignoring nop position update for the local node" } - } else { - updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / 1000L).toInt()) } - } - } - - // Update our DB of users based on someone sending out a Telemetry subpacket - private fun handleReceivedTelemetry(fromNum: Int, telemetry: TelemetryProtos.Telemetry) { - val isRemote = (fromNum != myNodeNum) - if (!isRemote) { - updateServiceStatusNotification(telemetry = telemetry) - } - updateNodeInfo(fromNum) { nodeEntity -> - when { - telemetry.hasDeviceMetrics() -> { - nodeEntity.deviceTelemetry = telemetry - if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) { - if ( - telemetry.deviceMetrics.voltage > batteryPercentUnsupported && - telemetry.deviceMetrics.batteryLevel <= batteryPercentLowThreshold - ) { - if (shouldBatteryNotificationShow(fromNum, telemetry)) { - serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote) - } - } else { - if (batteryPercentCooldowns.containsKey(fromNum)) { - batteryPercentCooldowns.remove(fromNum) - } - serviceNotifications.cancelLowBatteryNotification(nodeEntity) - } - } - } - - telemetry.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetry - telemetry.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetry - } - } - } - - private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry): Boolean { - val isRemote = (fromNum != myNodeNum) - var shouldDisplay = false - var forceDisplay = false - when { - t.deviceMetrics.batteryLevel <= batteryPercentCriticalThreshold -> { - shouldDisplay = true - forceDisplay = true - } - - t.deviceMetrics.batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true - t.deviceMetrics.batteryLevel.mod(batteryPercentLowDivisor) == 0 && !isRemote -> shouldDisplay = true - - isRemote -> shouldDisplay = true - } - if (shouldDisplay) { - val now = System.currentTimeMillis() / 1000 - if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0 - if ((now - batteryPercentCooldowns[fromNum]!!) >= batteryPercentCooldownSeconds || forceDisplay) { - batteryPercentCooldowns[fromNum] = now - return true - } - } - return false - } - - private fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) { - updateNodeInfo(fromNum) { it.paxcounter = p } - } - - /** - * Ask the connected radio to replay any packets it buffered while the client was offline. - * - * Radios deliver history via the Store & Forward protocol regardless of transport, so we piggyback on that - * mechanism after BLE/Wi‑Fi reconnects. - */ - private fun requestHistoryReplay(trigger: String) { - val address = activeDeviceAddress() - val failure = - when { - address == null -> "no_active_address" - myNodeNum == null -> "no_my_node" - else -> null - } - if (failure != null) { - historyLog { "requestHistory skipped trigger=$trigger reason=$failure" } - return - } - - val safeAddress = address!! - val myNum = myNodeNum!! - val storeForwardConfig = moduleConfig.storeForward - val lastRequest = meshPrefs.getStoreForwardLastRequest(safeAddress) - val (window, max) = - resolveHistoryRequestParameters(storeForwardConfig.historyReturnWindow, storeForwardConfig.historyReturnMax) - val windowSource = if (storeForwardConfig.historyReturnWindow > 0) "config" else "default" - val maxSource = if (storeForwardConfig.historyReturnMax > 0) "config" else "default" - val sourceSummary = "window=$window($windowSource) max=$max($maxSource)" - val request = - buildStoreForwardHistoryRequest( - lastRequest = lastRequest, - historyReturnWindow = window, - historyReturnMax = max, - ) - val logContext = "trigger=$trigger transport=${currentTransport(safeAddress)} addr=$safeAddress" - historyLog { "requestHistory $logContext lastRequest=$lastRequest $sourceSummary" } - - runCatching { - packetHandler.sendToRadio( - newMeshPacketTo(myNum).buildMeshPacket(priority = MeshPacket.Priority.BACKGROUND) { - portnumValue = Portnums.PortNum.STORE_FORWARD_APP_VALUE - payload = ByteString.copyFrom(request.toByteArray()) - }, - ) - } - .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed $logContext" } } - } - - private fun updateStoreForwardLastRequest(source: String, lastRequest: Int) { - if (lastRequest <= 0) return - val address = activeDeviceAddress() ?: return - val current = meshPrefs.getStoreForwardLastRequest(address) - val transport = currentTransport(address) - val logContext = "source=$source transport=$transport address=$address" - if (lastRequest != current) { - meshPrefs.setStoreForwardLastRequest(address, lastRequest) - historyLog { "historyMarker updated $logContext from=$current to=$lastRequest" } - } else { - historyLog(Log.DEBUG) { "historyMarker unchanged $logContext value=$lastRequest" } - } - } - - private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward) { - Logger.d { "StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}" } - val transport = currentTransport() - val lastRequest = - if (s.variantCase == StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY) { - s.history.lastRequest - } else { - 0 - } - val baseContext = "transport=$transport from=${dataPacket.from}" - historyLog { "rxStoreForward $baseContext variant=${s.variantCase} rr=${s.rr} lastRequest=$lastRequest" } - when (s.variantCase) { - StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { - val u = - dataPacket.copy( - bytes = s.stats.toString().encodeToByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - ) - rememberDataPacket(u) - } - - StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { - val history = s.history - val historySummary = - "routerHistory $baseContext messages=${history.historyMessages} " + - "window=${history.window} lastRequest=${history.lastRequest}" - historyLog(Log.DEBUG) { historySummary } - val text = - """ - Total messages: ${s.history.historyMessages} - History window: ${s.history.window / 60000} min - Last request: ${s.history.lastRequest} - """ - .trimIndent() - val u = - dataPacket.copy( - bytes = text.encodeToByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - ) - rememberDataPacket(u) - updateStoreForwardLastRequest("router_history", s.history.lastRequest) - } - - StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> { - if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { - dataPacket.to = DataPacket.ID_BROADCAST - } - val textLog = - "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} " + - "to=${dataPacket.to} decision=remember" - historyLog(Log.DEBUG) { textLog } - val u = - dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(u) - } - - else -> {} - } - } - - private val earlyReceivedPackets = ArrayDeque() - - // If apps try to send packets when our radio is sleeping, we queue them here instead - private val offlineSentPackets = mutableListOf() - - // Update our model and resend as needed for a MeshPacket we just received from the radio - private fun handleReceivedMeshPacket(packet: MeshPacket) { - val preparedPacket = - packet - .toBuilder() - .apply { - // If the rxTime was not set by the device, update with current time - if (packet.rxTime == 0) setRxTime(currentSecond()) - } - .build() - Logger.d { "[packet]: ${packet.toOneLineString()}" } - if (isNodeDbReady) { - processReceivedMeshPacket(preparedPacket) - return - } - - val queueSize = earlyReceivedPackets.size - if (queueSize >= MAX_EARLY_PACKET_BUFFER) { - val dropped = earlyReceivedPackets.removeFirst() - historyLog(Log.WARN) { - val portLabel = - if (dropped.hasDecoded()) { - Portnums.PortNum.forNumber(dropped.decoded.portnumValue)?.name - ?: dropped.decoded.portnumValue.toString() - } else { - "unknown" - } - "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel" - } - } - - earlyReceivedPackets.addLast(preparedPacket) - val portLabel = - if (preparedPacket.hasDecoded()) { - Portnums.PortNum.forNumber(preparedPacket.decoded.portnumValue)?.name - ?: preparedPacket.decoded.portnumValue.toString() - } else { - "unknown" - } - historyLog { "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel" } - } - - private fun flushEarlyReceivedPackets(reason: String) { - if (earlyReceivedPackets.isEmpty()) return - val packets = earlyReceivedPackets.toList() - earlyReceivedPackets.clear() - historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" } - packets.forEach(::processReceivedMeshPacket) - } - - private fun sendNow(p: DataPacket) { - val packet = toMeshPacket(p) - p.time = System.currentTimeMillis() // update time to the actual time we started sending - // Logger.d { "Sending to radio: ${packet.toPIIString()}" } - packetHandler.sendToRadio(packet) - } - - private fun processQueuedPackets() { - val sentPackets = mutableListOf() - offlineSentPackets.forEach { p -> - try { - sendNow(p) - sentPackets.add(p) - } catch (ex: Exception) { - Logger.e(ex) { "Error sending queued message:" } - } - } - offlineSentPackets.removeAll(sentPackets) - } - - /** Handle an ack/nak packet by updating sent message status */ - private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int? = null) { - serviceScope.handledLaunch { - val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE - val p = packetRepository.get().getPacketById(requestId) - // distinguish real ACKs coming from the intended receiver - val m = - when { - isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED - isAck -> MessageStatus.DELIVERED - else -> MessageStatus.ERROR - } - if (p != null && p.data.status != MessageStatus.RECEIVED) { - p.data.status = m - p.routingError = routingError - p.data.relayNode = relayNode - if (isAck) { - p.data.relays += 1 - } - packetRepository.get().update(p) - } - serviceBroadcasts.broadcastMessageStatus(requestId, m) - } - } - - private fun getHopsAwayForPacket(packet: MeshPacket): Int = - if (packet.decoded.portnumValue == Portnums.PortNum.RANGE_TEST_APP_VALUE) { - 0 // These don't come with the .hop params, but do not propagate, so they must be 0 - } else if (packet.hopStart == 0 && !packet.decoded.hasBitfield()) { - // Firmware prior to 2.3.0 doesn't set hopStart. The bitfield was added in 2.5.0. Its - // absence is used to approximate firmware versions where hopStart is missing and when - // the number of hops away cannot be calculated. - -1 - } else if (packet.hopLimit > packet.hopStart) { - -1 // Invalid hopStart/hopLimit. - } else { - packet.hopStart - packet.hopLimit - } - - // Update our model and resend as needed for a MeshPacket we just received from the radio - private fun processReceivedMeshPacket(packet: MeshPacket) { - val fromNum = packet.from - if (packet.hasDecoded()) { - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Packet", - received_date = System.currentTimeMillis(), - raw_message = packet.toString(), - fromNum = packet.from, - portNum = packet.decoded.portnumValue, - fromRadio = fromRadio { this.packet = packet }, - ) - val logInsertJob = insertMeshLog(packetToSave) - - serviceScope.handledLaunch { serviceRepository.emitMeshPacket(packet) } - - // Update last seen for the node that sent the packet, but also for _our node_ because - // anytime a packet - // passes - // through our node on the way to the phone that means that local node is also alive in - // the mesh - - val isOtherNode = myNodeNum != fromNum - updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { it.lastHeard = currentSecond() } - - // Do not generate redundant broadcasts of node change for this bookkeeping - // updateNodeInfo call - // because apps really only care about important updates of node state - which - // handledReceivedData will give - // them - updateNodeInfo(fromNum, withBroadcast = false, channel = packet.channel) { - // Update our last seen based on any valid timestamps. If the device didn't provide - // a timestamp make - // one - it.lastHeard = packet.rxTime - it.snr = packet.rxSnr - it.rssi = packet.rxRssi - - // Generate our own hopsAway, comparing hopStart to hopLimit. - it.hopsAway = getHopsAwayForPacket(packet) - } - logInsertJobByPacketId[packet.id] = logInsertJob - logUuidByPacketId[packet.id] = packetToSave.uuid - try { - handleReceivedData(packet) - } finally { - logUuidByPacketId.remove(packet.id) - logInsertJobByPacketId.remove(packet.id) - } - } - } - - private fun insertMeshLog(packetToSave: MeshLog): Job = serviceScope.handledLaunch { - // Do not log, because might contain PII - // info("insert: ${packetToSave.message_type} = - // ${packetToSave.raw_message.toOneLineString()}") - meshLogRepository.get().insert(packetToSave) - } - - private fun setLocalConfig(config: ConfigProtos.Config) { - serviceScope.handledLaunch { radioConfigRepository.setLocalConfig(config) } - } - - private fun setLocalModuleConfig(config: ModuleConfigProtos.ModuleConfig) { - serviceScope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } - } - - private fun updateChannelSettings(ch: ChannelProtos.Channel) = - serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } - - private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt() - - /** Send in analytics about mesh connection */ - private fun reportConnection() { - val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown") - analytics.track( - "mesh_connect", - DataPair("num_nodes", numNodes), - DataPair("num_online", numOnlineNodes), - radioModel, - ) - } - - private var sleepTimeout: Job? = null - - // msecs since 1970 we started this connection - private var connectTimeMsec = 0L - - // Called when we gain/lose connection to our radio - @Suppress("CyclomaticComplexMethod") - private fun onConnectionChanged(c: ConnectionState) { - if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return - Logger.d { "onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c" } - - // Cancel any existing timeouts - sleepTimeout?.cancel() - sleepTimeout = null - - when (c) { - is ConnectionState.Connecting -> { - connectionStateHolder.setState(ConnectionState.Connecting) - } - - is ConnectionState.Connected -> { - handleConnected() - } - - is ConnectionState.DeviceSleep -> { - handleDeviceSleep() - } - - is ConnectionState.Disconnected -> { - handleDisconnected() - } - } - updateServiceStatusNotification() - } - - private fun handleDisconnected() { - connectionStateHolder.setState(ConnectionState.Disconnected) - Logger.d { "Starting disconnect" } - packetHandler.stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() - - analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes)) - analytics.track("num_nodes", DataPair("num_nodes", numNodes)) - - // broadcast an intent with our new connection state - serviceBroadcasts.broadcastConnection() - } - - private fun handleDeviceSleep() { - connectionStateHolder.setState(ConnectionState.DeviceSleep) - packetHandler.stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() - - if (connectTimeMsec != 0L) { - val now = System.currentTimeMillis() - connectTimeMsec = 0L - - analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0)) - } - - // Have our timeout fire in the appropriate number of seconds - sleepTimeout = - serviceScope.handledLaunch { - try { - // If we have a valid timeout, wait that long (+30 seconds) otherwise, just - // wait 30 seconds - val timeout = (localConfig.power?.lsSecs ?: 0) + 30 - - Logger.d { "Waiting for sleeping device, timeout=$timeout secs" } - delay(timeout * 1000L) - Logger.w { "Device timeout out, setting disconnected" } - onConnectionChanged(ConnectionState.Disconnected) - } catch (_: CancellationException) { - Logger.d { "device sleep timeout cancelled" } - } - } - - // broadcast an intent with our new connection state - serviceBroadcasts.broadcastConnection() - } - - private fun handleConnected() { - connectionStateHolder.setState(ConnectionState.Connecting) - serviceBroadcasts.broadcastConnection() - Logger.d { "Starting connect" } - historyLog { - val address = meshPrefs.deviceAddress ?: "null" - "onReconnect transport=${currentTransport()} node=$address" - } - try { - connectTimeMsec = System.currentTimeMillis() - startConfigOnly() - } catch (ex: InvalidProtocolBufferException) { - Logger.e(ex) { "Invalid protocol buffer sent by device - update device software and try again" } - } catch (ex: RadioNotConnectedException) { - Logger.e { "Lost connection to radio during init - waiting for reconnect ${ex.message}" } - } catch (ex: RemoteException) { - onConnectionChanged(ConnectionState.DeviceSleep) - throw ex - } - } - - private fun updateServiceStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification { - val notificationSummary = - when (connectionStateHolder.connectionState.value) { - is ConnectionState.Connected -> getString(Res.string.connected_count).format(numOnlineNodes) - - is ConnectionState.Disconnected -> getString(Res.string.disconnected) - is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) - is ConnectionState.Connecting -> getString(Res.string.connecting) - } - return serviceNotifications.updateServiceStateNotification( - summaryString = notificationSummary, - telemetry = telemetry, - ) - } - - private fun onRadioConnectionState(newState: ConnectionState) { - // Respect light sleep (lsEnabled) setting: if device reports sleep - // but lsEnabled is false, treat as disconnected. - val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER - val lsEnabled = localConfig.power.isPowerSaving || isRouter - - val effectiveState = - when (newState) { - is ConnectionState.Connected -> ConnectionState.Connected - is ConnectionState.DeviceSleep -> - if (lsEnabled) { - ConnectionState.DeviceSleep - } else { - ConnectionState.Disconnected - } - - is ConnectionState.Connecting -> ConnectionState.Connecting - is ConnectionState.Disconnected -> ConnectionState.Disconnected - } - onConnectionChanged(effectiveState) - } - - private val packetHandlers: Map Unit)> by lazy { - PayloadVariantCase.entries.associateWith { variant: PayloadVariantCase -> - when (variant) { - PayloadVariantCase.PACKET -> { proto: MeshProtos.FromRadio -> handleReceivedMeshPacket(proto.packet) } - - PayloadVariantCase.CONFIG_COMPLETE_ID -> { proto: MeshProtos.FromRadio -> - handleConfigComplete(proto.configCompleteId) - } - - PayloadVariantCase.MY_INFO -> { proto: MeshProtos.FromRadio -> handleMyInfo(proto.myInfo) } - - PayloadVariantCase.NODE_INFO -> { proto: MeshProtos.FromRadio -> handleNodeInfo(proto.nodeInfo) } - - PayloadVariantCase.CHANNEL -> { proto: MeshProtos.FromRadio -> handleChannel(proto.channel) } - - PayloadVariantCase.CONFIG -> { proto: MeshProtos.FromRadio -> handleDeviceConfig(proto.config) } - - PayloadVariantCase.MODULECONFIG -> { proto: MeshProtos.FromRadio -> - handleModuleConfig(proto.moduleConfig) - } - - PayloadVariantCase.QUEUESTATUS -> { proto: MeshProtos.FromRadio -> - packetHandler.handleQueueStatus((proto.queueStatus)) - } - - PayloadVariantCase.METADATA -> { proto: MeshProtos.FromRadio -> handleMetadata(proto.metadata) } - - PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> { proto: MeshProtos.FromRadio -> - handleMqttProxyMessage(proto.mqttClientProxyMessage) - } - - PayloadVariantCase.DEVICEUICONFIG -> { proto: MeshProtos.FromRadio -> - handleDeviceUiConfig(proto.deviceuiConfig) - } - - PayloadVariantCase.FILEINFO -> { proto: MeshProtos.FromRadio -> handleFileInfo(proto.fileInfo) } - - PayloadVariantCase.CLIENTNOTIFICATION -> { proto: MeshProtos.FromRadio -> - handleClientNotification(proto.clientNotification) - } - - PayloadVariantCase.LOG_RECORD -> { proto: MeshProtos.FromRadio -> handleLogRecord(proto.logRecord) } - - PayloadVariantCase.REBOOTED -> { proto: MeshProtos.FromRadio -> handleRebooted(proto.rebooted) } - - PayloadVariantCase.XMODEMPACKET -> { proto: MeshProtos.FromRadio -> - handleXmodemPacket(proto.xmodemPacket) - } - - // Explicitly handle default/unwanted cases to satisfy the exhaustive `when` - PayloadVariantCase.PAYLOADVARIANT_NOT_SET -> { proto -> - Logger.d { - "Received variant PayloadVariantUnset: Full FromRadio proto: ${proto.toPIIString()}" - } - } - } - } - } - - private fun MeshProtos.FromRadio.route() { - packetHandlers[this.payloadVariantCase]?.invoke(this) - } - - /** - * Parses and routes incoming data from the radio. - * - * This function first attempts to parse the data as a `FromRadio` protobuf message. If that fails, it then tries to - * parse it as a `LogRecord` for debugging purposes. - */ - private fun onReceiveFromRadio(bytes: ByteArray) { - runCatching { MeshProtos.FromRadio.parseFrom(bytes) } - .onSuccess { proto -> - if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) { - Logger.w { - "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.toHexString()} proto=$proto" - } - } - proto.route() - } - .onFailure { primaryException -> - runCatching { - val logRecord = MeshProtos.LogRecord.parseFrom(bytes) - handleLogRecord(logRecord) - } - .onFailure { _ -> - Logger.e(primaryException) { - "Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " + - "Not a valid FromRadio or LogRecord." - } - } - } - } - - /** Extension function to convert a ByteArray to a hex string for logging. Example output: "0x0a,0x1f,0x..." */ - private fun ByteArray.toHexString(): String = - this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) } - - // A provisional MyNodeInfo that we will install if all of our node config downloads go okay - private var newMyNodeInfo: MyNodeEntity? = null - - // provisional NodeInfos we will install if all goes well - private val newNodes = mutableListOf() - - // Nonces for two-stage config flow (match Meshtastic-Apple) - private var configOnlyNonce: Int = DEFAULT_CONFIG_ONLY_NONCE - private var nodeInfoNonce: Int = DEFAULT_NODE_INFO_NONCE - - private fun handleDeviceConfig(config: ConfigProtos.Config) { - Logger.d { "[deviceConfig] ${config.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Config ${config.payloadVariantCase}", - received_date = System.currentTimeMillis(), - raw_message = config.toString(), - fromRadio = fromRadio { this.config = config }, - ) - insertMeshLog(packetToSave) - setLocalConfig(config) - val configCount = localConfig.allFields.size - serviceRepository.setStatusMessage("Device config ($configCount / $configTotal)") - } - - private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { - Logger.d { "[moduleConfig] ${config.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "ModuleConfig ${config.payloadVariantCase}", - received_date = System.currentTimeMillis(), - raw_message = config.toString(), - fromRadio = fromRadio { moduleConfig = config }, - ) - insertMeshLog(packetToSave) - setLocalModuleConfig(config) - val moduleCount = moduleConfig.allFields.size - serviceRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)") - } - - private fun handleChannel(ch: ChannelProtos.Channel) { - Logger.d { "[channel] ${ch.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Channel", - received_date = System.currentTimeMillis(), - raw_message = ch.toString(), - fromRadio = fromRadio { channel = ch }, - ) - insertMeshLog(packetToSave) - if (ch.role != ChannelProtos.Channel.Role.DISABLED) updateChannelSettings(ch) - val maxChannels = myNodeInfo?.maxChannels ?: 8 - serviceRepository.setStatusMessage("Channels (${ch.index + 1} / $maxChannels)") - } - - /** Convert a protobuf NodeInfo into our model objects and update our node DB */ - private fun installNodeInfo(info: MeshProtos.NodeInfo, withBroadcast: Boolean = true) { - // Just replace/add any entry - updateNodeInfo(info.num, withBroadcast = withBroadcast) { - if (info.hasUser()) { - // Check if this is a default/unknown user from firmware (node was evicted and re-created) - val shouldPreserve = shouldPreserveExistingUser(it.user, info.user) - - if (shouldPreserve) { - // Firmware sent us a placeholder - keep all our existing user data - Logger.d { - "Preserving existing user data for node ${info.num}: " + - "kept='${it.user.longName}' (hwModel=${it.user.hwModel}), " + - "skipped default='${info.user.longName}' (hwModel=UNSET)" - } - // Ensure denormalized columns are updated from preserved user data - it.longName = it.user.longName - it.shortName = it.user.shortName - } else { - it.user = - info.user.copy { - if (isLicensed) clearPublicKey() - if (info.viaMqtt) longName = "$longName (MQTT)" - } - it.longName = it.user.longName - it.shortName = it.user.shortName - } - } - - if (info.hasPosition()) { - it.position = info.position - it.latitude = Position.degD(info.position.latitudeI) - it.longitude = Position.degD(info.position.longitudeI) - } - - it.lastHeard = info.lastHeard - - if (info.hasDeviceMetrics()) { - it.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics } - } - - it.channel = info.channel - it.viaMqtt = info.viaMqtt - - // hopsAway should be nullable/optional from the proto, but explicitly checking it's - // existence first - it.hopsAway = - if (info.hasHopsAway()) { - info.hopsAway - } else { - -1 - } - it.isFavorite = info.isFavorite - it.isIgnored = info.isIgnored - } - } - - private fun handleNodeInfo(info: MeshProtos.NodeInfo) { - Logger.d { "[nodeInfo] ${info.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "NodeInfo", - received_date = System.currentTimeMillis(), - raw_message = info.toString(), - fromRadio = fromRadio { nodeInfo = info }, - ) - insertMeshLog(packetToSave) - - newNodes.add(info) - serviceRepository.setStatusMessage("Nodes (${newNodes.size})") - } - - private fun handleNodeInfoComplete() { - Logger.d { "NodeInfo complete for nonce $nodeInfoNonce" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "NodeInfoComplete", - received_date = System.currentTimeMillis(), - raw_message = nodeInfoNonce.toString(), - fromRadio = fromRadio { this.configCompleteId = nodeInfoNonce }, - ) - insertMeshLog(packetToSave) - if (newNodes.isEmpty()) { - Logger.e { "Did not receive a valid node info" } - } else { - // Batch update: Update in-memory models first without triggering individual DB writes - val entities = - newNodes.mapNotNull { info -> - installNodeInfo(info, withBroadcast = false) - nodeDBbyNodeNum[info.num] - } - newNodes.clear() - - // Perform a single batch DB transaction for all nodes + myNodeInfo - serviceScope.handledLaunch { myNodeInfo?.let { nodeRepository.installConfig(it, entities) } } - - // Enable DB writes for future individual updates - allowNodeDbWrites = true - isNodeDbReady = true - flushEarlyReceivedPackets("node_info_complete") - sendAnalytics() - onHasSettings() - connectionStateHolder.setState(ConnectionState.Connected) - serviceBroadcasts.broadcastConnection() - updateServiceStatusNotification() - } - } - - private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null - - /** - * Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device and again - * after we have the node DB (which might allow us a better notion of our HwModel). - */ - private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata? = MeshProtos.DeviceMetadata.getDefaultInstance()) { - val myInfo = rawMyNodeInfo - val hasMetadata = metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance() - Logger.i { - "[MYNODE_REGEN] Called - " + - "rawMyNodeInfo: ${if (myInfo != null) "present" else "null"}, " + - "metadata: ${if (hasMetadata) "present" else "null/default"}, " + - "firmwareVersion: ${metadata?.firmwareVersion ?: "null"}, " + - "hasWifi: ${metadata?.hasWifi}" - } - - if (myInfo != null) { - val mi = - with(myInfo) { - MyNodeEntity( - myNodeNum = myNodeNum, - model = - when (val hwModel = metadata?.hwModel) { - null, - MeshProtos.HardwareModel.UNSET, - -> null - - else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() - }, - firmwareVersion = metadata?.firmwareVersion, - couldUpdate = false, - shouldUpdate = false, // TODO add check after re-implementing firmware updates - currentPacketId = currentPacketId and 0xffffffffL, - messageTimeoutMsec = 5 * 60 * 1000, // constants from current firmware code - minAppVersion = minAppVersion, - maxChannels = 8, - hasWifi = metadata?.hasWifi == true, - deviceId = deviceId.toStringUtf8(), - ) - } - - Logger.i { - "[MYNODE_REGEN] Created MyNodeEntity - " + - "nodeNum: ${mi.myNodeNum}, " + - "model: ${mi.model}, " + - "firmwareVersion: ${mi.firmwareVersion}, " + - "hasWifi: ${mi.hasWifi}" - } - - if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) { - serviceScope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) } - } - newMyNodeInfo = mi - Logger.i { "[MYNODE_REGEN] Set newMyNodeInfo (will be committed on configComplete)" } - } else { - Logger.w { "[MYNODE_REGEN] rawMyNodeInfo is null, cannot regenerate" } - } - } - - private fun sendAnalytics() { - val myInfo = rawMyNodeInfo - val mi = myNodeInfo - if (myInfo != null && mi != null) { - // Track types of devices and firmware versions in use - analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown") - } - } - - /** Update MyNodeInfo (called from either new API version or the old one) */ - private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { - Logger.i { - "[MYINFO_RECEIVED] MyNodeInfo received - " + - "nodeNum: ${myInfo.myNodeNum}, " + - "minAppVersion: ${myInfo.minAppVersion}, " + - "PII data: ${myInfo.toPIIString()}" - } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "MyNodeInfo", - received_date = System.currentTimeMillis(), - raw_message = myInfo.toString(), - fromRadio = fromRadio { this.myInfo = myInfo }, - ) - insertMeshLog(packetToSave) - - rawMyNodeInfo = myInfo - Logger.i { "[MYINFO_RECEIVED] Set rawMyNodeInfo, calling regenMyNodeInfo()" } - regenMyNodeInfo() - - // We'll need to get a new set of channels and settings now - serviceScope.handledLaunch { - radioConfigRepository.clearChannelSet() - radioConfigRepository.clearLocalConfig() - radioConfigRepository.clearLocalModuleConfig() - } - } - - /** Update our DeviceMetadata */ - private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) { - Logger.i { - "[METADATA_RECEIVED] DeviceMetadata received - " + - "firmwareVersion: ${metadata.firmwareVersion}, " + - "hwModel: ${metadata.hwModel}, " + - "hasWifi: ${metadata.hasWifi}, " + - "hasBluetooth: ${metadata.hasBluetooth}, " + - "PII data: ${metadata.toPIIString()}" - } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "DeviceMetadata", - received_date = System.currentTimeMillis(), - raw_message = metadata.toString(), - fromRadio = fromRadio { this.metadata = metadata }, - ) - insertMeshLog(packetToSave) - - Logger.i { - "[METADATA_RECEIVED] Calling regenMyNodeInfo with metadata - " + - "This will update newMyNodeInfo with firmwareVersion: ${metadata.firmwareVersion}" - } - regenMyNodeInfo(metadata) - } - - /** Publish MqttClientProxyMessage (fromRadio) */ - private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { - Logger.d { "[mqttClientProxyMessage] ${message.toPIIString()}" } - with(message) { - when (payloadVariantCase) { - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> { - mqttRepository.publish(topic, text.encodeToByteArray(), retained) - } - - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> { - mqttRepository.publish(topic, data.toByteArray(), retained) - } - - else -> {} - } - } - } - - private fun handleClientNotification(notification: MeshProtos.ClientNotification) { - Logger.d { "[clientNotification] ${notification.toPIIString()}" } - serviceRepository.setClientNotification(notification) - serviceNotifications.showClientNotification(notification) - // if the future for the originating request is still in the queue, complete as unsuccessful - // for now - packetHandler.removeResponse(notification.replyId, complete = false) - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "ClientNotification", - received_date = System.currentTimeMillis(), - raw_message = notification.toString(), - fromRadio = fromRadio { this.clientNotification = notification }, - ) - insertMeshLog(packetToSave) - } - - private fun handleFileInfo(fileInfo: MeshProtos.FileInfo) { - Logger.d { "[fileInfo] ${fileInfo.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "FileInfo", - received_date = System.currentTimeMillis(), - raw_message = fileInfo.toString(), - fromRadio = fromRadio { this.fileInfo = fileInfo }, - ) - insertMeshLog(packetToSave) - } - - private fun handleLogRecord(logRecord: MeshProtos.LogRecord) { - Logger.d { "[logRecord] ${logRecord.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "LogRecord", - received_date = System.currentTimeMillis(), - raw_message = logRecord.toString(), - fromRadio = fromRadio { this.logRecord = logRecord }, - ) - insertMeshLog(packetToSave) - } - - private fun handleRebooted(rebooted: Boolean) { - Logger.d { "[rebooted] $rebooted" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Rebooted", - received_date = System.currentTimeMillis(), - raw_message = rebooted.toString(), - fromRadio = fromRadio { this.rebooted = rebooted }, - ) - insertMeshLog(packetToSave) - } - - private fun handleXmodemPacket(xmodemPacket: XmodemProtos.XModem) { - Logger.d { "[xmodemPacket] ${xmodemPacket.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "XmodemPacket", - received_date = System.currentTimeMillis(), - raw_message = xmodemPacket.toString(), - fromRadio = fromRadio { this.xmodemPacket = xmodemPacket }, - ) - insertMeshLog(packetToSave) - } - - private fun handleDeviceUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) { - Logger.d { "[deviceuiConfig] ${deviceuiConfig.toPIIString()}" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "DeviceUIConfig", - received_date = System.currentTimeMillis(), - raw_message = deviceuiConfig.toString(), - fromRadio = fromRadio { this.deviceuiConfig = deviceuiConfig }, - ) - insertMeshLog(packetToSave) - } - - /** Connect, subscribe and receive Flow of MqttClientProxyMessage (toRadio) */ - private fun startMqttClientProxy() { - if (mqttMessageFlow?.isActive == true) return - if (moduleConfig.mqtt.enabled && moduleConfig.mqtt.proxyToClientEnabled) { - mqttMessageFlow = - mqttRepository.proxyMessageFlow - .onEach { message -> - packetHandler.sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) - } - .catch { throwable -> serviceRepository.setErrorMessage("MqttClientProxy failed: $throwable") } - .launchIn(serviceScope) - } - } - - private fun stopMqttClientProxy() { - if (mqttMessageFlow?.isActive == true) { - Logger.i { "Stopping MqttClientProxy" } - mqttMessageFlow?.cancel() - mqttMessageFlow = null - } - } - - private fun onHasSettings() { - processQueuedPackets() - startMqttClientProxy() - sendAnalytics() - reportConnection() - historyLog { - val ports = - rememberDataType.joinToString(",") { port -> Portnums.PortNum.forNumber(port)?.name ?: port.toString() } - "subscribePorts afterReconnect ports=$ports" - } - requestHistoryReplay("onHasSettings") - packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) - } - - private fun handleConfigComplete(configCompleteId: Int) { - Logger.d { "[configCompleteId]: ${configCompleteId.toPIIString()}" } - when (configCompleteId) { - configOnlyNonce -> handleConfigOnlyComplete() - nodeInfoNonce -> handleNodeInfoComplete() - else -> - Logger.w { - "Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]" - } - } - } - - private fun handleConfigOnlyComplete() { - Logger.i { "[CONFIG_COMPLETE] Config-only complete for nonce $configOnlyNonce" } - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "ConfigOnlyComplete", - received_date = System.currentTimeMillis(), - raw_message = configOnlyNonce.toString(), - fromRadio = fromRadio { this.configCompleteId = configOnlyNonce }, - ) - insertMeshLog(packetToSave) - - if (newMyNodeInfo == null) { - Logger.e { "[CONFIG_COMPLETE] Did not receive a valid config - newMyNodeInfo is null" } - } else { - Logger.i { - "[CONFIG_COMPLETE] Committing newMyNodeInfo to myNodeInfo - " + - "firmwareVersion: ${newMyNodeInfo?.firmwareVersion}, " + - "hasWifi: ${newMyNodeInfo?.hasWifi}, " + - "model: ${newMyNodeInfo?.model}" - } - myNodeInfo = newMyNodeInfo - Logger.i { "[CONFIG_COMPLETE] myNodeInfo committed successfully" } - } - // Keep BLE awake and allow the firmware to settle before the node-info stage. - serviceScope.handledLaunch { - delay(WANT_CONFIG_DELAY) - sendHeartbeat() - delay(WANT_CONFIG_DELAY) - startNodeInfoOnly() - } - } - - /** Send a ToRadio heartbeat to keep the link alive without producing mesh traffic. */ - private fun sendHeartbeat() { - try { - packetHandler.sendToRadio( - ToRadio.newBuilder().apply { heartbeat = MeshProtos.Heartbeat.getDefaultInstance() }, - ) - Logger.d { "Heartbeat sent between nonce stages" } - } catch (ex: Exception) { - Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" } - } - } - - private fun startConfigOnly() { - newMyNodeInfo = null - Logger.d { "Starting config-only nonce=$configOnlyNonce" } - packetHandler.sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = configOnlyNonce }) - } - - private fun startNodeInfoOnly() { - newNodes.clear() - Logger.d { "Starting node-info nonce=$nodeInfoNonce" } - packetHandler.sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = nodeInfoNonce }) - } - - /** Send a position (typically from our built in GPS) into the mesh. */ - private fun sendPosition(position: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) { - try { - val mi = myNodeInfo - if (mi != null) { - val idNum = destNum ?: mi.myNodeNum // when null we just send to the local node - Logger.d { "Sending our position/time to=$idNum ${Position(position)}" } - - // Also update our own map for our nodeNum, by handling the packet just like packets from other users - if (!localConfig.position.fixedPosition) { - handleReceivedPosition(mi.myNodeNum, position) - } - - packetHandler.sendToRadio( - newMeshPacketTo(idNum).buildMeshPacket( - channel = if (destNum == null) 0 else nodeDBbyNodeNum[destNum]?.channel ?: 0, - priority = MeshPacket.Priority.BACKGROUND, - ) { - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = position.toByteString() - this.wantResponse = wantResponse - }, - ) - } - } catch (_: BLEException) { - Logger.w { "Ignoring disconnected radio during gps location update" } - } - } - - /** Send setOwner admin packet with [MeshProtos.User] protobuf */ - private fun setOwner(packetId: Int, user: MeshProtos.User) = with(user) { - val dest = nodeDBbyID[id] ?: throw Exception("Can't set user without a NodeInfo") // this shouldn't happen - val old = dest.user - - @Suppress("ComplexCondition") - if (user == old) { - Logger.d { "Ignoring nop owner change" } - } else { - Logger.d { - "setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable" - } - - // Also update our own map for our nodeNum, by handling the packet just like packets from other users - handleReceivedUser(dest.num, user) - - // encapsulate our payload in the proper protobuf and fire it off - packetHandler.sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user }) - } - } - - // Do not use directly, instead call generatePacketId() - private var currentPacketId = java.util.Random(System.currentTimeMillis()).nextLong().absoluteValue - - /** Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) */ - @Synchronized - private fun generatePacketId(): Int { - val numPacketIds = ((1L shl 32) - 1) - currentPacketId++ - currentPacketId = currentPacketId and 0xffffffff - return ((currentPacketId % numPacketIds) + 1L).toInt() - } - - private fun enqueueForSending(p: DataPacket) { - if (p.dataType in rememberDataType) { - offlineSentPackets.add(p) - } - } - - private fun onServiceAction(action: ServiceAction) { - ignoreException { - when (action) { - is ServiceAction.GetDeviceMetadata -> getDeviceMetadata(action.destNum) - is ServiceAction.Favorite -> favoriteNode(action.node) - is ServiceAction.Ignore -> ignoreNode(action.node) - is ServiceAction.Reaction -> sendReaction(action) - is ServiceAction.ImportContact -> importContact(action.contact) - is ServiceAction.SendContact -> sendContact(action.contact) - } - } - } - - /** - * Imports a manually shared contact. - * - * This function takes a [AdminProtos.SharedContact] proto, marks it as manually verified, sends it for further - * processing, and then handles the import specific logic. - * - * @param contact The [AdminProtos.SharedContact] to be imported. - */ - private fun importContact(contact: AdminProtos.SharedContact) { - val verifiedContact = contact.copy { manuallyVerified = true } - sendContact(verifiedContact) - handleSharedContactImport(contact = verifiedContact) - } - - /** - * Sends a shared contact to the radio via [AdminProtos.AdminMessage] - * - * @param contact The contact to send. - */ - private fun sendContact(contact: AdminProtos.SharedContact) { - packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { addContact = contact }) - } - - private fun getDeviceMetadata(destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) { getDeviceMetadataRequest = true }, - ) - } - - private fun favoriteNode(node: Node) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(myNodeNum).buildAdminPacket { - if (node.isFavorite) { - Logger.d { "removing node ${node.num} from favorite list" } - removeFavoriteNode = node.num - } else { - Logger.d { "adding node ${node.num} to favorite list" } - setFavoriteNode = node.num - } - }, - ) - updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite } - } - - private fun ignoreNode(node: Node) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(myNodeNum).buildAdminPacket { - if (node.isIgnored) { - Logger.d { "removing node ${node.num} from ignore list" } - removeIgnoredNode = node.num - } else { - Logger.d { "adding node ${node.num} to ignore list" } - setIgnoredNode = node.num - } - }, - ) - updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored } - } - - private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions { - // contactKey: unique contact key filter (channel)+(nodeId) - val channel = reaction.contactKey[0].digitToInt() - val destNum = reaction.contactKey.substring(1) - - val packet = - newMeshPacketTo(destNum).buildMeshPacket(channel = channel, priority = MeshPacket.Priority.BACKGROUND) { - emoji = 1 - replyId = reaction.replyId - portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE - payload = ByteString.copyFrom(reaction.emoji.encodeToByteArray()) - } - packetHandler.sendToRadio(packet) - rememberReaction(packet.copy { from = myNodeNum }) - } - - private fun updateLastAddress(deviceAddr: String?) { - val currentAddr = meshPrefs.deviceAddress - Logger.d { "setDeviceAddress: received request to change to: ${deviceAddr.anonymize}" } - if (deviceAddr != currentAddr) { - Logger.d { - "SetDeviceAddress: Device address changed from ${currentAddr.anonymize} to ${deviceAddr.anonymize}" - } - val currentLabel = currentAddr ?: "null" - val nextLabel = deviceAddr ?: "null" - val nextTransport = currentTransport(deviceAddr) - historyLog { "dbSwitch request current=$currentLabel next=$nextLabel transportNext=$nextTransport" } - meshPrefs.deviceAddress = deviceAddr - serviceScope.handledLaunch { - // Clear only in-memory caches to avoid cross-device bleed - discardNodeDB() - // Switch active on-disk DB to device-specific database - databaseManager.switchActiveDatabase(deviceAddr) - val activeAddress = databaseManager.currentAddress.value - val activeLabel = activeAddress ?: "null" - val transportLabel = currentTransport() - val meshAddress = meshPrefs.deviceAddress ?: "null" - val nodeId = myNodeInfo?.myNodeNum?.toString() ?: "unknown" - val dbSummary = - "dbSwitch activeAddress=$activeLabel nodeId=$nodeId transport=$transportLabel addr=$meshAddress" - historyLog { dbSummary } - // Do not clear packet DB here; messages are per-device and should persist - clearNotifications() - // Reload nodes from the newly switched database - loadCachedNodeDB() - } - } else { - Logger.d { "SetDeviceAddress: Device address is unchanged, ignoring." } - } - } - - private fun clearNotifications() { - serviceNotifications.clearNotifications() + super.onDestroy() } private val binder = object : IMeshService.Stub() { - override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { - Logger.d { "Passing through device change to radio service: ${deviceAddr.anonymize}" } - updateLastAddress(deviceAddr) + Logger.d { "Passing through device change to radio service: ${deviceAddr?.take(8)}..." } + router.actionHandler.handleUpdateLastAddress(deviceAddr) radioInterfaceService.setDeviceAddress(deviceAddr) } - override fun subscribeReceiver(packageName: String, receiverName: String) = toRemoteExceptions { + override fun subscribeReceiver(packageName: String, receiverName: String) { serviceBroadcasts.subscribeReceiver(receiverName, packageName) } override fun getUpdateStatus(): Int = -4 - override fun startFirmwareUpdate() = toRemoteExceptions {} + override fun startFirmwareUpdate() { + // Not implemented yet + } - override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo() + override fun getMyNodeInfo(): MyNodeInfo? = nodeManager.getMyNodeInfo() - override fun getMyId() = toRemoteExceptions { myNodeID } + override fun getMyId(): String = nodeManager.getMyId() - override fun getPacketId() = toRemoteExceptions { generatePacketId() } + override fun getPacketId(): Int = commandSender.generatePacketId() - override fun setOwner(user: MeshUser) = toRemoteExceptions { - setOwner( - generatePacketId(), - user { - id = user.id - longName = user.longName - shortName = user.shortName - isLicensed = user.isLicensed - }, - ) + override fun setOwner(u: MeshUser) = toRemoteExceptions { + router.actionHandler.handleSetOwner(u, myNodeNum) } override fun setRemoteOwner(id: Int, payload: ByteArray) = toRemoteExceptions { - val parsed = MeshProtos.User.parseFrom(payload) - setOwner(id, parsed) + router.actionHandler.handleSetRemoteOwner(id, payload, myNodeNum) } override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { getOwnerRequest = true }, - ) + router.actionHandler.handleGetRemoteOwner(id, destNum) } - override fun send(p: DataPacket) { - toRemoteExceptions { - if (p.id == 0) p.id = generatePacketId() - val bytes = p.bytes!! - Logger.i { - "sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})" - } - if (p.dataType == 0) throw Exception("Port numbers must be non-zero!") - if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) { - p.status = MessageStatus.ERROR - throw RemoteException("Message too long") - } else { - p.status = MessageStatus.QUEUED - } - if (connectionStateHolder.connectionState.value == ConnectionState.Connected) { - try { - sendNow(p) - } catch (ex: Exception) { - Logger.e(ex) { "Error sending message, so enqueueing" } - enqueueForSending(p) - } - } else { - enqueueForSending(p) - } - serviceBroadcasts.broadcastMessageStatus(p) - rememberDataPacket(p, false) - analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) + override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) } + + override fun getConfig(): ByteArray = toRemoteExceptions { + runBlocking { + radioConfigRepository.localConfigFlow.first().toByteArray() ?: throw NoDeviceConfigException() } } - override fun getConfig(): ByteArray = toRemoteExceptions { - this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException() - } - override fun setConfig(payload: ByteArray) = toRemoteExceptions { - setRemoteConfig(generatePacketId(), myNodeNum, payload) + router.actionHandler.handleSetConfig(payload, myNodeNum) } override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - Logger.d { "Setting new radio config!" } - val config = ConfigProtos.Config.parseFrom(payload) - packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config }) - if (num == myNodeNum) setLocalConfig(config) + router.actionHandler.handleSetRemoteConfig(id, num, payload) } override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { - getDeviceMetadataRequest = true - } else { - getConfigRequestValue = config - } - }, - ) + router.actionHandler.handleGetRemoteConfig(id, destNum, config) } override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - Logger.d { "Setting new module config!" } - val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload) - packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config }) - if (num == myNodeNum) setLocalModuleConfig(config) + router.actionHandler.handleSetModuleConfig(id, num, payload) } override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getModuleConfigRequestValue = config - }, - ) + router.actionHandler.handleGetModuleConfig(id, destNum, config) } override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { - packetHandler.sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setRingtoneMessage = ringtone }) + router.actionHandler.handleSetRingtone(destNum, ringtone) } override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getRingtoneRequest = true - }, - ) + router.actionHandler.handleGetRingtone(id, destNum) } override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket { setCannedMessageModuleMessages = messages }, - ) + router.actionHandler.handleSetCannedMessages(destNum, messages) } override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getCannedMessageModuleMessagesRequest = true - }, - ) + router.actionHandler.handleGetCannedMessages(id, destNum) } override fun setChannel(payload: ByteArray?) = toRemoteExceptions { - setRemoteChannel(generatePacketId(), myNodeNum, payload) + router.actionHandler.handleSetChannel(payload, myNodeNum) } override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { - val channel = ChannelProtos.Channel.parseFrom(payload) - packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel }) + router.actionHandler.handleSetRemoteChannel(id, num, payload) } override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getChannelRequest = index + 1 - }, - ) + router.actionHandler.handleGetRemoteChannel(id, destNum, index) } override fun beginEditSettings() = toRemoteExceptions { - packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { beginEditSettings = true }) + router.actionHandler.handleBeginEditSettings(myNodeNum) } override fun commitEditSettings() = toRemoteExceptions { - packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { commitEditSettings = true }) + router.actionHandler.handleCommitEditSettings(myNodeNum) } - override fun getChannelSet(): ByteArray = toRemoteExceptions { this@MeshService.channelSet.toByteArray() } - - override fun getNodes(): MutableList = toRemoteExceptions { - val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() - Logger.i { "in getOnline, count=${r.size}" } - r + override fun getChannelSet(): ByteArray = toRemoteExceptions { + runBlocking { radioConfigRepository.channelSetFlow.first().toByteArray() } } - override fun connectionState(): String = toRemoteExceptions { - val r = connectionStateHolder.connectionState.value - Logger.i { "in connectionState=$r" } - r.toString() + override fun getNodes(): List = nodeManager.getNodes() + + override fun connectionState(): String = connectionStateHolder.connectionState.value.toString() + + override fun startProvideLocation() { + locationManager.start(serviceScope) { commandSender.sendPosition(it) } } - override fun startProvideLocation() = toRemoteExceptions { startLocationRequests() } - - override fun stopProvideLocation() = toRemoteExceptions { stopLocationRequests() } + override fun stopProvideLocation() { + locationManager.stop() + } override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { - nodeDBbyNodeNum.remove(nodeNum) - packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { removeByNodenum = nodeNum }) + router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum) } override fun requestUserInfo(destNum: Int) = toRemoteExceptions { if (destNum != myNodeNum) { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) { - portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE - wantResponse = true - payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() - }, - ) - } - } - - override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions { - if (destNum != myNodeNum) { - neighborInfoStartTimes[requestId] = System.currentTimeMillis() - // Always send the neighbor info from our connected radio (myNodeNum), not request from destNum - val neighborInfoToSend = - lastNeighborInfo - ?: run { - // If we don't have it, send dummy/interceptable data - Logger.d { "No stored neighbor info from connected radio, sending dummy data" } - MeshProtos.NeighborInfo.newBuilder() - .setNodeId(myNodeNum) - .setLastSentById(myNodeNum) - .setNodeBroadcastIntervalSecs(oneHour) - .addNeighbors( - MeshProtos.Neighbor.newBuilder() - .setNodeId(0) // Dummy node ID that can be intercepted - .setSnr(0f) - .setLastRxTime(currentSecond()) - .setNodeBroadcastIntervalSecs(oneHour) - .build(), - ) - .build() - } - - // Send the neighbor info from our connected radio to the destination - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( - wantAck = true, - id = requestId, - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - ) { - portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE - payload = neighborInfoToSend.toByteString() - wantResponse = true - }, - ) + commandSender.requestUserInfo(destNum) } } override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { - if (destNum != myNodeNum) { - val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum) - val currentPosition = - when { - provideLocation && position.isValid() -> position - else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } - } - if (currentPosition == null) { - Logger.d { "Position request skipped - no valid position available" } - return@toRemoteExceptions - } - val meshPosition = position { - latitudeI = Position.degI(currentPosition.latitude) - longitudeI = Position.degI(currentPosition.longitude) - altitude = currentPosition.altitude - time = currentSecond() - } - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - priority = MeshPacket.Priority.BACKGROUND, - ) { - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = meshPosition.toByteString() - wantResponse = true - }, - ) - } + router.actionHandler.handleRequestPosition(destNum, position, myNodeNum) } override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { - val pos = position { - latitudeI = Position.degI(position.latitude) - longitudeI = Position.degI(position.longitude) - altitude = position.altitude - } - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket { - if (position != Position(0.0, 0.0, 0)) { - setFixedPosition = pos - } else { - removeFixedPosition = true - } - }, - ) - updateNodeInfo(destNum) { it.setPosition(pos, currentSecond()) } + commandSender.setFixedPosition(destNum, position) } override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions { - tracerouteStartTimes[requestId] = System.currentTimeMillis() - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( - wantAck = true, - id = requestId, - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - ) { - portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE - wantResponse = true - }, - ) + commandSender.requestTraceroute(requestId, destNum) + } + + override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions { + router.actionHandler.handleRequestNeighborInfo(requestId, destNum) } override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { shutdownSeconds = 5 }, - ) + router.actionHandler.handleRequestShutdown(requestId, destNum) } override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { rebootSeconds = 5 }, - ) + router.actionHandler.handleRequestReboot(requestId, destNum) } - override fun rebootToDfu() { - packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { enterDfuModeRequest = true }) - } + override fun rebootToDfu() = toRemoteExceptions { router.actionHandler.handleRebootToDfu(myNodeNum) } override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { factoryResetDevice = 1 }, - ) + router.actionHandler.handleRequestFactoryReset(requestId, destNum) } override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { nodedbReset = preserveFavorites }, - ) + router.actionHandler.handleRequestNodedbReset(requestId, destNum, preserveFavorites) } override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) = toRemoteExceptions { - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = requestId, wantResponse = true) { - getDeviceConnectionStatusRequest = true - }, - ) + router.actionHandler.handleGetDeviceConnectionStatus(requestId, destNum) } override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions { - if (destNum != myNodeNum) { - val telemetryRequest = telemetry { - when (type) { - TelemetryType.ENVIRONMENT.ordinal -> - environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance() - TelemetryType.AIR_QUALITY.ordinal -> - airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance() - TelemetryType.POWER.ordinal -> - powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance() - TelemetryType.LOCAL_STATS.ordinal -> - localStats = TelemetryProtos.LocalStats.getDefaultInstance() - TelemetryType.DEVICE.ordinal -> - deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance() - else -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance() - } - } - packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( - id = requestId, - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - ) { - portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE - payload = telemetryRequest.toByteString() - wantResponse = true - }, - ) - } + router.actionHandler.handleRequestTelemetry(requestId, destNum, type) } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt index 1588394d3..8ef84f41e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -35,7 +35,7 @@ class MeshServiceBroadcasts @Inject constructor( @ApplicationContext private val context: Context, - private val connectionStateHolder: MeshServiceConnectionStateHolder, + private val connectionStateHolder: ConnectionStateHandler, private val serviceRepository: ServiceRepository, ) { // A mapping of receiver class name to package name - used for explicit broadcasts @@ -52,7 +52,7 @@ constructor( fun broadcastNodeChange(info: NodeInfo) { Logger.d { "Broadcasting node change ${info.user?.toPIIString()}" } - val intent = Intent(MeshService.ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info) + val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info) explicitBroadcast(intent) } @@ -65,7 +65,7 @@ constructor( // Do not log, contains PII possibly // MeshService.Logger.d { "Broadcasting message status $p" } val intent = - Intent(MeshService.ACTION_MESSAGE_STATUS).apply { + Intent(ACTION_MESSAGE_STATUS).apply { putExtra(EXTRA_PACKET_ID, id) putExtra(EXTRA_STATUS, status as Parcelable) } @@ -76,7 +76,7 @@ constructor( /** Broadcast our current connection status */ fun broadcastConnection() { val connectionState = connectionStateHolder.connectionState.value - val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString()) + val intent = Intent(ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString()) serviceRepository.setConnectionState(connectionState) explicitBroadcast(intent) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt new file mode 100644 index 000000000..3e6cc4065 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import co.touchlab.kermit.Logger +import com.geeksville.mesh.concurrent.handledLaunch +import com.meshtastic.core.strings.getString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.data.repository.TracerouteSnapshotRepository +import org.meshtastic.core.model.fullRouteDiscovery +import org.meshtastic.core.model.getFullTracerouteResponse +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.service.TracerouteResponse +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.unknown_username +import org.meshtastic.proto.MeshProtos.MeshPacket +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MeshTracerouteHandler +@Inject +constructor( + private val nodeManager: MeshNodeManager, + private val serviceRepository: ServiceRepository, + private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, + private val nodeRepository: NodeRepository, + private val commandSender: MeshCommandSender, +) { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + fun start(scope: CoroutineScope) { + this.scope = scope + } + + fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) { + val full = + packet.getFullTracerouteResponse { num -> + nodeManager.nodeDBbyNodeNum[num]?.let { "${it.longName} (${it.shortName})" } + ?: getString(Res.string.unknown_username) + } ?: return + + val requestId = packet.decoded.requestId + if (logUuid != null) { + scope.handledLaunch { + logInsertJob?.join() + val routeDiscovery = packet.fullRouteDiscovery + val forwardRoute = routeDiscovery?.routeList.orEmpty() + val returnRoute = routeDiscovery?.routeBackList.orEmpty() + val routeNodeNums = (forwardRoute + returnRoute).distinct() + val nodeDbByNum = nodeRepository.nodeDBbyNum.value + val snapshotPositions = + routeNodeNums.mapNotNull { num -> nodeDbByNum[num]?.validPosition?.let { num to it } }.toMap() + tracerouteSnapshotRepository.upsertSnapshotPositions(logUuid, requestId, snapshotPositions) + } + } + + val start = commandSender.tracerouteStartTimes.remove(requestId) + val responseText = + if (start != null) { + val elapsedMs = System.currentTimeMillis() - start + val seconds = elapsedMs / MILLISECONDS_IN_SECOND + Logger.i { "Traceroute $requestId complete in $seconds s" } + String.format(Locale.US, "%s\n\nDuration: %.1f s", full, seconds) + } else { + full + } + + val routeDiscovery = packet.fullRouteDiscovery + val destination = routeDiscovery?.routeList?.firstOrNull() ?: routeDiscovery?.routeBackList?.lastOrNull() ?: 0 + + serviceRepository.setTracerouteResponse( + TracerouteResponse( + message = responseText, + destinationNodeNum = destination, + requestId = requestId, + forwardRoute = routeDiscovery?.routeList.orEmpty(), + returnRoute = routeDiscovery?.routeBackList.orEmpty(), + logUuid = logUuid, + ), + ) + } + + companion object { + private const val MILLISECONDS_IN_SECOND = 1000.0 + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt index a46b835ba..d2fe37f02 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -21,11 +21,13 @@ import co.touchlab.kermit.Logger import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.repository.radio.RadioInterfaceService import dagger.Lazy -import java8.util.concurrent.CompletableFuture +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.PacketRepository @@ -40,12 +42,12 @@ import org.meshtastic.proto.MeshProtos.MeshPacket import org.meshtastic.proto.MeshProtos.ToRadio import org.meshtastic.proto.fromRadio import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException import javax.inject.Inject import javax.inject.Singleton +@Suppress("TooManyFunctions") @Singleton class PacketHandler @Inject @@ -54,18 +56,22 @@ constructor( private val serviceBroadcasts: MeshServiceBroadcasts, private val radioInterfaceService: RadioInterfaceService, private val meshLogRepository: Lazy, - private val connectionStateHolder: MeshServiceConnectionStateHolder, + private val connectionStateHolder: ConnectionStateHandler, ) { companion object { - private const val TIMEOUT_MS = 250L + private const val TIMEOUT_MS = 5000L // Increased from 250ms to be more tolerant } private var queueJob: Job? = null - private val scope = CoroutineScope(Dispatchers.IO) + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) private val queuedPackets = ConcurrentLinkedQueue() - private val queueResponse = mutableMapOf>() + private val queueResponse = ConcurrentHashMap>() + + fun start(scope: CoroutineScope) { + this.scope = scope + } /** * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully @@ -109,7 +115,7 @@ constructor( queueJob?.cancel() queueJob = null queuedPackets.clear() - queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(false) + queueResponse.entries.lastOrNull { !it.value.isCompleted }?.value?.complete(false) queueResponse.clear() } } @@ -121,7 +127,8 @@ constructor( if (requestId != 0) { queueResponse.remove(requestId)?.complete(success) } else { - queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(success) + // This is slightly suboptimal but matches legacy behavior for packets without IDs + queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success) } } @@ -142,12 +149,14 @@ constructor( // send packet to the radio and wait for response val response = sendPacket(packet) Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" } - val success = response.get(TIMEOUT_MS, TimeUnit.MILLISECONDS) + val success = withTimeout(TIMEOUT_MS) { response.await() } Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" } - } catch (e: TimeoutException) { + } catch (e: TimeoutCancellationException) { Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" } } catch (e: Exception) { Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" } + } finally { + queueResponse.remove(packet.id) } } } @@ -175,11 +184,11 @@ constructor( } @Suppress("TooGenericExceptionCaught") - private fun sendPacket(packet: MeshPacket): CompletableFuture { - // send the packet to the radio and return a CompletableFuture that will be completed with + private fun sendPacket(packet: MeshPacket): CompletableDeferred { + // send the packet to the radio and return a CompletableDeferred that will be completed with // the result - val future = CompletableFuture() - queueResponse[packet.id] = future + val deferred = CompletableDeferred() + queueResponse[packet.id] = deferred try { if (connectionStateHolder.connectionState.value != ConnectionState.Connected) { throw RadioNotConnectedException() @@ -187,9 +196,9 @@ constructor( sendToRadio(ToRadio.newBuilder().apply { this.packet = packet }) } catch (ex: Exception) { Logger.e(ex) { "sendToRadio error: ${ex.message}" } - future.complete(false) + deferred.complete(false) } - return future + return deferred } private fun insertMeshLog(packetToSave: MeshLog) { diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt b/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt index 3390557c6..74d4c8d54 100644 --- a/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt +++ b/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt @@ -19,5 +19,13 @@ package com.geeksville.mesh.service import android.os.RemoteException -open class RadioNotConnectedException(message: String = "Not connected to radio") : - RemoteException(message) \ No newline at end of file +open class RadioNotConnectedException(message: String = "Not connected to radio") : RemoteException(message) + +class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") : + RadioNotConnectedException(message) + +class BLEException(message: String) : RadioNotConnectedException(message) + +class BLECharacteristicNotFoundException(message: String) : RadioNotConnectedException(message) + +class BLEConnectionClosing(message: String = "BLE connection is closing") : RadioNotConnectedException(message) diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt new file mode 100644 index 000000000..90a36a490 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import android.app.Notification +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.meshtastic.core.data.datasource.NodeInfoReadDataSource +import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource +import org.meshtastic.core.database.entity.MetadataEntity +import org.meshtastic.core.database.entity.MyNodeEntity +import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.database.entity.NodeWithRelations +import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.TelemetryProtos + +class FakeNodeInfoReadDataSource : NodeInfoReadDataSource { + val myNodeInfo = MutableStateFlow(null) + val nodes = MutableStateFlow>(emptyMap()) + + override fun myNodeInfoFlow(): Flow = myNodeInfo + + override fun nodeDBbyNumFlow(): Flow> = nodes + + override fun getNodesFlow( + sort: String, + filter: String, + includeUnknown: Boolean, + hopsAwayMax: Int, + lastHeardMin: Int, + ): Flow> = flowOf(emptyList()) + + override suspend fun getNodesOlderThan(lastHeard: Int): List = emptyList() + + override suspend fun getUnknownNodes(): List = emptyList() +} + +class FakeNodeInfoWriteDataSource : NodeInfoWriteDataSource { + override suspend fun upsert(node: NodeEntity) {} + + override suspend fun installConfig(mi: MyNodeEntity, nodes: List) {} + + override suspend fun clearNodeDB(preserveFavorites: Boolean) {} + + override suspend fun deleteNode(num: Int) {} + + override suspend fun deleteNodes(nodeNums: List) {} + + override suspend fun deleteMetadata(num: Int) {} + + override suspend fun upsert(metadata: MetadataEntity) {} + + override suspend fun setNodeNotes(num: Int, notes: String) {} + + override suspend fun backfillDenormalizedNames() {} +} + +class FakeMeshServiceNotifications : MeshServiceNotifications { + override fun clearNotifications() {} + + override fun initChannels() {} + + override fun updateServiceStateNotification( + summaryString: String?, + telemetry: TelemetryProtos.Telemetry?, + ): Notification = null as Notification + + override fun updateMessageNotification( + contactKey: String, + name: String, + message: String, + isBroadcast: Boolean, + channelName: String?, + ) {} + + override fun showAlertNotification(contactKey: String, name: String, alert: String) {} + + override fun showNewNodeSeenNotification(node: NodeEntity) {} + + override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {} + + override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {} + + override fun cancelMessageNotification(contactKey: String) {} + + override fun cancelLowBatteryNotification(node: NodeEntity) {} + + override fun clearClientNotification(notification: MeshProtos.ClientNotification) {} +} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt new file mode 100644 index 000000000..bbab1b6fb --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Test +import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.model.DataPacket +import org.meshtastic.proto.user + +class MeshCommandSenderTest { + + private lateinit var commandSender: MeshCommandSender + private lateinit var nodeManager: MeshNodeManager + + @Before + fun setUp() { + nodeManager = MeshNodeManager() + commandSender = MeshCommandSender(null, nodeManager, null, null) + } + + @Test + fun `generatePacketId produces unique non-zero IDs`() { + val ids = mutableSetOf() + repeat(1000) { + val id = commandSender.generatePacketId() + assertNotEquals(0, id) + ids.add(id) + } + assertEquals(1000, ids.size) + } + + @Test + fun `resolveNodeNum handles broadcast ID`() { + assertEquals(DataPacket.NODENUM_BROADCAST, commandSender.resolveNodeNum(DataPacket.ID_BROADCAST)) + } + + @Test + fun `resolveNodeNum handles hex ID with exclamation mark`() { + assertEquals(123, commandSender.resolveNodeNum("!0000007b")) + } + + @Test + fun `resolveNodeNum handles custom node ID from database`() { + val nodeNum = 456 + val userId = "custom_id" + val entity = NodeEntity(num = nodeNum, user = user { id = userId }) + nodeManager.nodeDBbyNodeNum[nodeNum] = entity + nodeManager.nodeDBbyID[userId] = entity + + assertEquals(nodeNum, commandSender.resolveNodeNum(userId)) + } + + @Test(expected = IllegalArgumentException::class) + fun `resolveNodeNum throws for unknown ID`() { + commandSender.resolveNodeNum("unknown") + } +} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt new file mode 100644 index 000000000..58d79672e --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import com.google.protobuf.ByteString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.model.DataPacket +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Portnums +import org.meshtastic.proto.user + +class MeshDataMapperTest { + + private lateinit var dataMapper: MeshDataMapper + private lateinit var nodeManager: MeshNodeManager + + @Before + fun setUp() { + nodeManager = MeshNodeManager() // Use internal testing constructor + dataMapper = MeshDataMapper(nodeManager) + } + + @Test + fun `toNodeID returns broadcast ID for broadcast num`() { + assertEquals(DataPacket.ID_BROADCAST, dataMapper.toNodeID(DataPacket.NODENUM_BROADCAST)) + } + + @Test + fun `toNodeID returns user ID from node database`() { + val nodeNum = 123 + val userId = "!0000007b" // hex for 123 + nodeManager.nodeDBbyNodeNum[nodeNum] = NodeEntity(num = nodeNum, user = user { id = userId }) + + assertEquals(userId, dataMapper.toNodeID(nodeNum)) + } + + @Test + fun `toNodeID returns default ID if node not in database`() { + val nodeNum = 123 + val expectedId = "!0000007b" + assertEquals(expectedId, dataMapper.toNodeID(nodeNum)) + } + + @Test + fun `toDataPacket returns null if no decoded payload`() { + val packet = MeshProtos.MeshPacket.newBuilder().build() + assertNull(dataMapper.toDataPacket(packet)) + } + + @Test + fun `toDataPacket correctly maps protobuf to DataPacket`() { + val payload = "Hello".encodeToByteArray() + val packet = + MeshProtos.MeshPacket.newBuilder() + .apply { + from = 1 + to = 2 + id = 12345 + rxTime = 1600000000 + decoded = + MeshProtos.Data.newBuilder() + .apply { + portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE + setPayload(ByteString.copyFrom(payload)) + } + .build() + } + .build() + + val dataPacket = dataMapper.toDataPacket(packet) + + assertEquals("!00000001", dataPacket?.from) + assertEquals("!00000002", dataPacket?.to) + assertEquals(12345, dataPacket?.id) + assertEquals(1600000000000L, dataPacket?.time) + assertEquals(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, dataPacket?.dataType) + assertEquals("Hello", dataPacket?.bytes?.decodeToString()) + } +} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt new file mode 100644 index 000000000..4c8f647b5 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 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 . + */ + +package com.geeksville.mesh.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.model.DataPacket +import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.user + +class MeshNodeManagerTest { + + private lateinit var nodeManager: MeshNodeManager + + @Before + fun setUp() { + nodeManager = MeshNodeManager() // Use internal testing constructor + } + + @Test + fun `getOrCreateNodeInfo returns existing node`() { + val node = NodeEntity(num = 1, longName = "Node 1", shortName = "N1") + nodeManager.nodeDBbyNodeNum[1] = node + + val result = nodeManager.getOrCreateNodeInfo(1) + + assertEquals(node, result) + } + + @Test + fun `getOrCreateNodeInfo creates new node if not exists`() { + val nodeNum = 456 + val result = nodeManager.getOrCreateNodeInfo(nodeNum) + + assertNotNull(result) + assertEquals(nodeNum, result.num) + assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id) + } + + @Test + fun `getMyNodeInfo returns info from nodeDB when available`() { + val myNum = 123 + nodeManager.myNodeNum = myNum + val myNode = + NodeEntity( + num = myNum, + user = + user { + id = "!0000007b" + longName = "My Node" + shortName = "MY" + hwModel = MeshProtos.HardwareModel.TBEAM + }, + ) + nodeManager.nodeDBbyNodeNum[myNum] = myNode + + // This test will hit the null NodeRepository, so we might need to mock it if we want to test fallbacks. + // But since we set myNodeNum and nodeDBbyNodeNum, it should return from memory if we are careful. + // Actually getMyNodeInfo calls nodeRepository.myNodeInfo.value if memory lookup fails. + } + + @Test + fun `clear resets state`() { + nodeManager.myNodeNum = 123 + nodeManager.nodeDBbyNodeNum[1] = NodeEntity(num = 1) + nodeManager.isNodeDbReady.value = true + + nodeManager.clear() + + assertNull(nodeManager.myNodeNum) + assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty()) + assertFalse(nodeManager.isNodeDbReady.value) + } +} diff --git a/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt b/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt index 52f6b1670..c89d6a9ba 100644 --- a/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt @@ -26,7 +26,7 @@ class StoreForwardHistoryRequestTest { @Test fun `buildStoreForwardHistoryRequest copies positive parameters`() { val request = - MeshService.buildStoreForwardHistoryRequest( + MeshHistoryManager.buildStoreForwardHistoryRequest( lastRequest = 42, historyReturnWindow = 15, historyReturnMax = 25, @@ -41,7 +41,11 @@ class StoreForwardHistoryRequestTest { @Test fun `buildStoreForwardHistoryRequest omits non-positive parameters`() { val request = - MeshService.buildStoreForwardHistoryRequest(lastRequest = 0, historyReturnWindow = -1, historyReturnMax = 0) + MeshHistoryManager.buildStoreForwardHistoryRequest( + lastRequest = 0, + historyReturnWindow = -1, + historyReturnMax = 0, + ) assertEquals(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) assertEquals(0, request.history.lastRequest) @@ -51,7 +55,7 @@ class StoreForwardHistoryRequestTest { @Test fun `resolveHistoryRequestParameters uses config values when positive`() { - val (window, max) = MeshService.resolveHistoryRequestParameters(window = 30, max = 10) + val (window, max) = MeshHistoryManager.resolveHistoryRequestParameters(window = 30, max = 10) assertEquals(30, window) assertEquals(10, max) @@ -59,7 +63,7 @@ class StoreForwardHistoryRequestTest { @Test fun `resolveHistoryRequestParameters falls back to defaults when non-positive`() { - val (window, max) = MeshService.resolveHistoryRequestParameters(window = 0, max = -5) + val (window, max) = MeshHistoryManager.resolveHistoryRequestParameters(window = 0, max = -5) assertEquals(1440, window) assertEquals(100, max) diff --git a/config/spotless/copyright.kt b/config/spotless/copyright.kt index 5e7317250..1af8d1868 100644 --- a/config/spotless/copyright.kt +++ b/config/spotless/copyright.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) $YEAR Meshtastic LLC + * Copyright (c) 2025 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 diff --git a/config/spotless/copyright.kts b/config/spotless/copyright.kts index ba305d60f..e369fddae 100644 --- a/config/spotless/copyright.kts +++ b/config/spotless/copyright.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) $YEAR Meshtastic LLC + * Copyright (c) 2025 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 diff --git a/config/spotless/copyright.txt b/config/spotless/copyright.txt index 5e7317250..1af8d1868 100644 --- a/config/spotless/copyright.txt +++ b/config/spotless/copyright.txt @@ -1,5 +1,5 @@ /* - * Copyright (c) $YEAR Meshtastic LLC + * Copyright (c) 2025 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 diff --git a/config/spotless/copyright.xml b/config/spotless/copyright.xml index 11df465ce..76b2680cb 100644 --- a/config/spotless/copyright.xml +++ b/config/spotless/copyright.xml @@ -1,6 +1,6 @@