/*
* 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.Manifest
import android.annotation.SuppressLint
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.RemoteException
import androidx.annotation.RequiresPermission
import androidx.core.app.ServiceCompat
import androidx.core.content.edit
import androidx.core.location.LocationCompat
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.DeviceUIProtos
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.ToRadio
import com.geeksville.mesh.MeshUser
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.ModuleConfigProtos
import com.geeksville.mesh.MyNodeInfo
import com.geeksville.mesh.NodeInfo
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.Position
import com.geeksville.mesh.R
import com.geeksville.mesh.StoreAndForwardProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.TelemetryProtos.LocalStats
import com.geeksville.mesh.analytics.DataPair
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.hasLocationPermission
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.copy
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.ReactionEntity
import com.geeksville.mesh.fromRadio
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.getTracerouteResponse
import com.geeksville.mesh.position
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.repository.network.MQTTRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.telemetry
import com.geeksville.mesh.user
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.ignoreException
import com.geeksville.mesh.util.toOneLineString
import com.geeksville.mesh.util.toPIIString
import com.geeksville.mesh.util.toRemoteExceptions
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import java8.util.concurrent.CompletableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Random
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 java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import kotlin.math.absoluteValue
sealed class ServiceAction {
data class GetDeviceMetadata(val destNum: Int) : ServiceAction()
data class Favorite(val node: Node) : ServiceAction()
data class Ignore(val node: Node) : ServiceAction()
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
data class AddSharedContact(val contact: AdminProtos.SharedContact) : ServiceAction()
}
/**
* Handles all communication with android apps and the Meshtastic device. It maintains an internal model of the network
* state, manages device configurations, and processes incoming/outgoing packets.
*
* Note: This service will go away once all clients are unbound from it. Warning: Do not override toString, it causes
* infinite recursion on some Android versions (because contextWrapper.getResources calls toString).
*/
@Suppress("MagicNumber")
@AndroidEntryPoint
class MeshService :
Service(),
Logging {
@Inject lateinit var dispatchers: CoroutineDispatchers
@Inject lateinit var packetRepository: Lazy
@Inject lateinit var meshLogRepository: Lazy
@Inject lateinit var radioInterfaceService: RadioInterfaceService
@Inject lateinit var locationRepository: LocationRepository
@Inject lateinit var radioConfigRepository: RadioConfigRepository
@Inject lateinit var mqttRepository: MQTTRepository
@Inject lateinit var serviceNotifications: MeshServiceNotifications
@Inject lateinit var connectionRouter: ConnectionRouter
companion object : Logging {
private const val MESH_PREFS_NAME = "mesh-prefs"
private const val DEVICE_ADDRESS_KEY = "device_address"
private const val ADMIN_CHANNEL_NAME = "admin"
// Intents broadcast by MeshService
private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum"
/** Generates a RECEIVED action filter string for a given port number. */
fun actionReceived(portNum: Int): String {
val portType = Portnums.PortNum.forNumber(portNum)
val portStr = portType?.toString() ?: portNum.toString()
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)
/** Initiates a device address change and starts the service. */
fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) {
service.setDeviceAddress(address)
startService(context) // Ensure service is started/foregrounded if needed
}
fun createIntent(context: Context): Intent = Intent(context, MeshService::class.java)
val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION)
val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION)
private const val CONFIG_ONLY_NONCE = 69420
private const val NODE_INFO_ONLY_NONCE = 69421
}
private var previousSummary: String? = null
private var previousStats: LocalStats? = null
private val clientPackages = ConcurrentHashMap()
private val serviceBroadcasts by lazy {
MeshServiceBroadcasts(this, clientPackages) {
connectionRouter.connectionState.value.also { radioConfigRepository.setConnectionState(it) }
}
}
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private var locationFlow: Job? = null
private var mqttMessageFlow: Job? = null
// Battery thresholds and cooldowns
private val batteryPercentUnsupported = 0.0
private val batteryPercentLowThreshold = 20
private val batteryPercentLowDivisor = 5
private val batteryPercentCriticalThreshold = 5
private val batteryPercentCooldownSeconds = 1500L
private val batteryPercentCooldowns = ConcurrentHashMap()
private fun getSenderName(packet: DataPacket?): String {
val nodeId = packet?.from ?: return getString(R.string.unknown_username)
return nodeDBbyID[nodeId]?.user?.longName ?: getString(R.string.unknown_username)
}
private val notificationSummary: String
get() =
when (connectionRouter.connectionState.value) {
ConnectionState.CONNECTED -> getString(R.string.connected_count, numOnlineNodes.toString())
ConnectionState.DISCONNECTED -> getString(R.string.disconnected)
ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping)
ConnectionState.CONNECTING -> getString(R.string.connecting_to_device)
}
private var localStatsTelemetry: TelemetryProtos.Telemetry? = null
private val localStats: LocalStats?
get() = localStatsTelemetry?.localStats
private val localStatsUpdatedAtMillis: Long?
get() = localStatsTelemetry?.time?.let { it * 1000L }
/** Starts location requests if permissions are granted and not already active. */
@RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun startLocationRequests() {
if (locationFlow?.isActive == true) return
if (hasLocationPermission()) {
locationFlow =
locationRepository
.getLocations()
.onEach { location ->
val positionBuilder = 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
}
sendPosition(positionBuilder)
}
.launchIn(serviceScope)
}
}
private fun stopLocationRequests() {
locationFlow
?.takeIf { it.isActive }
?.let {
info("Stopping location requests")
it.cancel()
locationFlow = null
}
}
private fun sendToRadio(toRadioBuilder: ToRadio.Builder) {
val builtProto = toRadioBuilder.build()
debug("Sending to radio: ${builtProto.toPIIString()}")
radioInterfaceService.sendToRadio(builtProto.toByteArray())
if (toRadioBuilder.hasPacket()) {
val packet = toRadioBuilder.packet
changeStatus(packet.id, MessageStatus.ENROUTE)
if (packet.hasDecoded()) {
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "PacketSent", // Clarified type
received_date = System.currentTimeMillis(),
raw_message = packet.toString(),
fromNum = myNodeNum, // Correctly use myNodeNum for sent packets
portNum = packet.decoded.portnumValue,
fromRadio = fromRadio { this.packet = packet },
),
)
}
}
}
private fun sendToRadio(packet: MeshPacket) {
queuedPackets.add(packet)
startPacketQueue()
}
private fun showAlertNotification(contactKey: String, dataPacket: DataPacket) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
dataPacket.alert ?: getString(R.string.critical_alert),
)
}
private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) {
val message: String =
when (dataPacket.dataType) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text ?: return
Portnums.PortNum.WAYPOINT_APP_VALUE ->
getString(R.string.waypoint_received, dataPacket.waypoint?.name ?: "")
else -> return
}
serviceNotifications.updateMessageNotification(
contactKey,
getSenderName(dataPacket),
message,
isBroadcast = dataPacket.to == DataPacket.ID_BROADCAST,
)
}
override fun onCreate() {
super.onCreate()
sharedPreferences = getSharedPreferences(MESH_PREFS_NAME, Context.MODE_PRIVATE)
_lastAddress.value = sharedPreferences.getString(DEVICE_ADDRESS_KEY, null) ?: NO_DEVICE_SELECTED
info("Creating mesh service")
serviceNotifications.initChannels()
connectionRouter.start()
serviceScope.handledLaunch { radioInterfaceService.connect() }
connectionRouter.connectionState
.onEach { state ->
when (state) {
ConnectionState.CONNECTED -> startConnect()
ConnectionState.DEVICE_SLEEP -> startDeviceSleep()
ConnectionState.DISCONNECTED -> startDisconnect()
else -> Unit
}
}
.launchIn(serviceScope)
radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope)
radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope)
radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope)
radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope)
radioConfigRepository.serviceAction.onEach(::onServiceAction).launchIn(serviceScope)
loadSettings()
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val deviceAddress = radioInterfaceService.getBondedDeviceAddress()
val wantForeground = deviceAddress != null && deviceAddress != NO_DEVICE_SELECTED
info("Requesting foreground service: $wantForeground")
val notification = serviceNotifications.createServiceStateNotification(notificationSummary)
val foregroundServiceType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (hasLocationPermission()) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
}
} else {
0
}
try {
ServiceCompat.startForeground(this, serviceNotifications.notifyId, notification, foregroundServiceType)
} catch (ex: SecurityException) {
val errorMessage =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
"startForeground failed, likely due to missing POST_NOTIFICATIONS permission on Android 13+"
} else {
"startForeground failed"
}
errormsg(errorMessage, ex)
return START_NOT_STICKY // Prevent service becoming sticky in a broken state
}
return if (!wantForeground) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
START_NOT_STICKY
} else {
START_STICKY
}
}
override fun onDestroy() {
info("Destroying mesh service")
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
serviceJob.cancel()
connectionRouter.stop()
}
// Node Database and Model Management
private fun loadSettings() = serviceScope.handledLaunch {
resetState() // Clear previous state
myNodeInfo = radioConfigRepository.myNodeInfo.value
val nodesFromDb = radioConfigRepository.getNodeDBbyNum()
nodeDBbyNodeNum.putAll(nodesFromDb)
nodesFromDb.values.forEach { nodeEntity ->
if (nodeEntity.user.id.isNotEmpty()) {
_nodeDBbyID[nodeEntity.user.id] = nodeEntity
}
}
}
/**
* Resets all relevant service state variables to their defaults or clears collections. This is crucial when
* switching to a new device connection to prevent state from a previous session from affecting the new one. It
* ensures a clean slate for node information, configurations, pending operations, and cached data.
*/
private fun resetState() = serviceScope.handledLaunch {
debug("Discarding NodeDB and resetting all service state for new device connection")
clearDatabases()
// Core Node and Config data
myNodeInfo = null
rawMyNodeInfo = null
nodeDBbyNodeNum.clear()
_nodeDBbyID.clear()
localStatsTelemetry = null
sessionPasskey = ByteString.EMPTY
currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue
packetIdGenerator.set(Random(System.currentTimeMillis()).nextLong().absoluteValue)
offlineSentPackets.clear()
stopPacketQueue()
connectTimeMsec = 0L
stopLocationRequests()
stopMqttClientProxy()
previousSummary = null
previousStats = null
batteryPercentCooldowns.clear()
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearLocalModuleConfig()
info("MeshService state has been reset for a new device session.")
}
private var myNodeInfo: MyNodeEntity? = null
private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null
private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue
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()
private val nodeDBbyNodeNum = ConcurrentHashMap()
private val _nodeDBbyID = ConcurrentHashMap() // Cached map for ID lookups
val nodeDBbyID: Map
get() = _nodeDBbyID // Expose immutable view if needed externally
private fun toNodeInfo(nodeNum: Int): NodeEntity =
nodeDBbyNodeNum[nodeNum] ?: throw NodeNumNotFoundException(nodeNum)
private fun toNodeID(nodeNum: Int): String = when (nodeNum) {
DataPacket.NODENUM_BROADCAST -> DataPacket.ID_BROADCAST
else -> nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
}
private fun getOrCreateNodeInfo(nodeNum: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(nodeNum) {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val defaultUser = user {
id = userId
longName = "Meshtastic ${userId.takeLast(4)}"
shortName = userId.takeLast(4)
hwModel = MeshProtos.HardwareModel.UNSET
}
NodeEntity(
num = nodeNum,
user = defaultUser,
longName = defaultUser.longName,
channel = channel,
).also { newEntity ->
if (newEntity.user.id.isNotEmpty()) {
_nodeDBbyID[newEntity.user.id] = newEntity
}
}
}
private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex()
private fun toNodeInfo(id: String): NodeEntity = _nodeDBbyID[id]
?: run {
val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value
when {
id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum)
hexStr != null -> {
val nodeNum = hexStr.toLong(16).toInt()
nodeDBbyNodeNum[nodeNum] ?: throw IdNotFoundException(id)
}
else -> throw InvalidNodeIdException(id)
}
}
private fun getUserName(num: Int): String =
radioConfigRepository.getUser(num).let { "${it.longName} (${it.shortName})" }
private val numNodes: Int
get() = nodeDBbyNodeNum.size
private val numOnlineNodes: Int
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
}
private inline fun updateNodeInfo(
nodeNum: Int,
withBroadcast: Boolean = true,
channel: Int = 0,
crossinline updateFn: (NodeEntity) -> Unit,
) {
val info = getOrCreateNodeInfo(nodeNum, channel)
val oldUserId = info.user.id
updateFn(info)
val newUserId = info.user.id
if (oldUserId.isNotEmpty() && oldUserId != newUserId) {
_nodeDBbyID.remove(oldUserId)
}
if (newUserId.isNotEmpty()) {
_nodeDBbyID[newUserId] = info
}
if (info.user.id.isNotEmpty()) {
serviceScope.handledLaunch { radioConfigRepository.upsert(info) }
}
if (withBroadcast) {
serviceBroadcasts.broadcastNodeChange(info.toNodeInfo())
}
}
private val myNodeNum: Int
get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("Local node information not yet available")
private val myNodeID: String
get() = toNodeID(myNodeNum)
private val MeshPacket.Builder.adminChannelIndex: Int
get() =
when {
myNodeNum == to -> 0 // Admin channel to self is 0
nodeDBbyNodeNum[myNodeNum]?.hasPKC == true && nodeDBbyNodeNum[to]?.hasPKC == true ->
DataPacket.PKC_CHANNEL_INDEX
else ->
channelSet.settingsList
.indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) }
.coerceAtLeast(0)
}
private fun newMeshPacketTo(nodeNum: Int): MeshPacket.Builder = MeshPacket.newBuilder().apply {
from = 0 // Device sets this to myNodeNum
to = nodeNum
}
private fun newMeshPacketTo(id: String): MeshPacket.Builder = newMeshPacketTo(toNodeNum(id))
private fun MeshPacket.Builder.buildMeshPacket(
wantAck: Boolean = false,
id: Int = generatePacketId(),
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
this.decoded = MeshProtos.Data.newBuilder().apply(initFn).build()
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
nodeDBbyNodeNum[to]?.user?.publicKey?.let { this.publicKey = it }
} else {
this.channel = channel
}
return build()
}
private fun MeshPacket.Builder.buildAdminPacket(
id: Int = generatePacketId(),
wantResponse: Boolean = false,
initFn: AdminProtos.AdminMessage.Builder.() -> Unit,
): MeshPacket =
buildMeshPacket(id = id, wantAck = true, channel = adminChannelIndex, priority = MeshPacket.Priority.RELIABLE) {
this.wantResponse = wantResponse
this.portnumValue = Portnums.PortNum.ADMIN_APP_VALUE
this.payload =
AdminProtos.AdminMessage.newBuilder()
.apply {
initFn(this)
this.sessionPasskey = this@MeshService.sessionPasskey
}
.build()
.toByteString()
}
private fun toDataPacket(packet: MeshPacket): DataPacket? {
if (!packet.hasDecoded()) return null
val data = packet.decoded
return 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,
)
}
private fun toMeshPacket(dataPacket: DataPacket): MeshPacket = newMeshPacketTo(dataPacket.to!!).buildMeshPacket(
id = dataPacket.id,
wantAck = dataPacket.wantAck,
hopLimit = dataPacket.hopLimit,
channel = dataPacket.channel,
) {
portnumValue = dataPacket.dataType
payload = ByteString.copyFrom(dataPacket.bytes)
dataPacket.replyId?.takeIf { it != 0 }?.let { this.replyId = it }
}
private val rememberableDataTypes =
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(),
)
packetRepository.get().insertReaction(reaction)
}
private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) {
if (dataPacket.dataType !in rememberableDataTypes) 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
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)
}
}
}
}
// region Received Data Handlers
private fun handleReceivedData(packet: MeshPacket) {
val currentMyNodeInfo = myNodeInfo ?: return // Early exit if no local node info
val decodedData = packet.decoded
val fromNodeId = toNodeID(packet.from)
val appDataPacket = toDataPacket(packet) ?: return // Not a processable data packet
val fromThisDevice = currentMyNodeInfo.myNodeNum == packet.from
debug("Received data from $fromNodeId, portnum=${decodedData.portnum} ${decodedData.payload.size()} bytes")
appDataPacket.status = MessageStatus.RECEIVED
var shouldBroadcastToClients = !fromThisDevice
when (decodedData.portnumValue) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleReceivedText(packet, appDataPacket, fromNodeId)
Portnums.PortNum.ALERT_APP_VALUE -> handleReceivedAlert(appDataPacket, fromNodeId)
Portnums.PortNum.WAYPOINT_APP_VALUE -> handleReceivedWaypoint(packet, appDataPacket)
Portnums.PortNum.POSITION_APP_VALUE -> handleReceivedPositionApp(packet, decodedData, appDataPacket)
Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromThisDevice) handleReceivedNodeInfoApp(packet, decodedData)
Portnums.PortNum.TELEMETRY_APP_VALUE -> handleReceivedTelemetryApp(packet, decodedData, appDataPacket)
Portnums.PortNum.ROUTING_APP_VALUE -> {
shouldBroadcastToClients = true
handleReceivedRoutingApp(decodedData, fromNodeId)
}
Portnums.PortNum.ADMIN_APP_VALUE -> {
handleReceivedAdmin(packet.from, AdminProtos.AdminMessage.parseFrom(decodedData.payload))
shouldBroadcastToClients = false
}
Portnums.PortNum.PAXCOUNTER_APP_VALUE -> {
handleReceivedPaxcounter(packet.from, PaxcountProtos.Paxcount.parseFrom(decodedData.payload))
shouldBroadcastToClients = false
}
Portnums.PortNum.STORE_FORWARD_APP_VALUE -> {
handleReceivedStoreAndForward(
appDataPacket,
StoreAndForwardProtos.StoreAndForward.parseFrom(decodedData.payload),
)
shouldBroadcastToClients = false
}
Portnums.PortNum.RANGE_TEST_APP_VALUE -> handleReceivedRangeTest(appDataPacket)
Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> handleReceivedDetectionSensor(appDataPacket)
Portnums.PortNum.TRACEROUTE_APP_VALUE ->
radioConfigRepository.setTracerouteResponse(packet.getTracerouteResponse(::getUserName))
else -> debug("No custom processing needed for ${decodedData.portnumValue}")
}
if (shouldBroadcastToClients) {
serviceBroadcasts.broadcastReceivedData(appDataPacket)
}
trackDataReceptionAnalytics(decodedData.portnumValue, decodedData.payload.size())
}
private fun handleReceivedText(meshPacket: MeshPacket, dataPacket: DataPacket, fromId: String) {
val decodedPayload = meshPacket.decoded
when {
decodedPayload.replyId != 0 && decodedPayload.emoji == 0 -> { // Text reply
debug("Received REPLY from $fromId")
rememberDataPacket(dataPacket)
}
decodedPayload.replyId != 0 && decodedPayload.emoji != 0 -> { // Emoji reaction
debug("Received EMOJI from $fromId")
rememberReaction(meshPacket)
}
else -> { // Standard text message
debug("Received CLEAR_TEXT from $fromId")
rememberDataPacket(dataPacket)
}
}
}
private fun handleReceivedAlert(dataPacket: DataPacket, fromId: String) {
debug("Received ALERT_APP from $fromId")
rememberDataPacket(dataPacket)
}
private fun handleReceivedWaypoint(meshPacket: MeshPacket, dataPacket: DataPacket) {
val waypointProto = MeshProtos.Waypoint.parseFrom(meshPacket.decoded.payload)
// Validate locked Waypoints from the original sender
if (waypointProto.lockedTo != 0 && waypointProto.lockedTo != meshPacket.from) return
rememberDataPacket(dataPacket, waypointProto.expire > currentSecond())
}
private fun handleReceivedPositionApp(
meshPacket: MeshPacket,
decodedData: MeshProtos.Data,
dataPacket: DataPacket,
) {
val positionProto = MeshProtos.Position.parseFrom(decodedData.payload)
if (decodedData.wantResponse && positionProto.latitudeI == 0 && positionProto.longitudeI == 0) {
debug("Ignoring nop position update from position request")
} else {
handleReceivedPosition(meshPacket.from, positionProto, dataPacket.time)
}
}
private fun handleReceivedNodeInfoApp(meshPacket: MeshPacket, decodedData: MeshProtos.Data) {
val userProto =
MeshProtos.User.parseFrom(decodedData.payload).copy {
if (isLicensed) clearPublicKey()
if (meshPacket.viaMqtt) longName = "$longName (MQTT)"
}
handleReceivedUser(meshPacket.from, userProto, meshPacket.channel)
}
private fun handleReceivedTelemetryApp(
meshPacket: MeshPacket,
decodedData: MeshProtos.Data,
dataPacket: DataPacket,
) {
val telemetryProto =
TelemetryProtos.Telemetry.parseFrom(decodedData.payload).copy {
if (time == 0) time = (dataPacket.time / 1000L).toInt()
}
handleReceivedTelemetry(meshPacket.from, telemetryProto)
}
private fun handleReceivedRoutingApp(decodedData: MeshProtos.Data, fromId: String) {
val routingProto = MeshProtos.Routing.parseFrom(decodedData.payload)
if (routingProto.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) {
radioConfigRepository.setErrorMessage(getString(R.string.error_duty_cycle))
}
handleAckNak(decodedData.requestId, fromId, routingProto.errorReasonValue)
queueResponse.remove(decodedData.requestId)?.complete(true)
}
private fun handleReceivedRangeTest(dataPacket: DataPacket) {
if (!moduleConfig.rangeTest.enabled) return
val textDataPacket = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
rememberDataPacket(textDataPacket)
}
private fun handleReceivedDetectionSensor(dataPacket: DataPacket) {
val textDataPacket = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
rememberDataPacket(textDataPacket)
}
private fun trackDataReceptionAnalytics(portNum: Int, bytesSize: Int) {
GeeksvilleApplication.analytics.track("num_data_receive", DataPair(1))
GeeksvilleApplication.analytics.track(
"data_receive",
DataPair("num_bytes", bytesSize),
DataPair("type", portNum),
)
}
// endregion
@Suppress("NestedBlockDepth")
private fun handleReceivedAdmin(fromNodeNum: Int, adminMessage: AdminProtos.AdminMessage) {
when (adminMessage.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
if (fromNodeNum == myNodeNum) {
val response = adminMessage.getConfigResponse
debug("Admin: received config ${response.payloadVariantCase}")
setLocalConfig(response)
}
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
if (fromNodeNum == myNodeNum) {
myNodeInfo?.let {
val ch = adminMessage.getChannelResponse
debug("Admin: Received channel ${ch.index}")
if (ch.index + 1 < it.maxChannels) {
handleChannel(ch)
}
}
}
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> {
debug("Admin: received DeviceMetadata from $fromNodeNum")
serviceScope.handledLaunch {
radioConfigRepository.insertMetadata(fromNodeNum, adminMessage.getDeviceMetadataResponse)
}
}
AdminProtos.AdminMessage.PayloadVariantCase.PAYLOADVARIANT_NOT_SET,
null,
-> warn("Received admin message with no payload variant set.")
else -> warn("No special processing needed for admin payload ${adminMessage.payloadVariantCase}")
}
debug("Admin: Received session_passkey from $fromNodeNum")
sessionPasskey = adminMessage.sessionPasskey
}
private fun handleReceivedUser(fromNum: Int, userProto: MeshProtos.User, channel: Int = 0) {
updateNodeInfo(fromNum, channel = channel) { nodeEntity ->
val isNewNode = (nodeEntity.isUnknownUser && userProto.hwModel != MeshProtos.HardwareModel.UNSET)
val keyMatch = !nodeEntity.hasPKC || nodeEntity.user.publicKey == userProto.publicKey
nodeEntity.user =
if (keyMatch) {
userProto
} else {
userProto.copy {
warn("Public key mismatch from ${userProto.longName} (${userProto.shortName})")
publicKey = NodeEntity.ERROR_BYTE_STRING
}
}
nodeEntity.longName = userProto.longName
nodeEntity.shortName = userProto.shortName
if (isNewNode) {
serviceNotifications.showNewNodeSeenNotification(nodeEntity)
}
}
}
private fun handleReceivedPosition(
fromNum: Int,
positionProto: MeshProtos.Position,
defaultTimeMillis: Long = System.currentTimeMillis(),
) {
if (myNodeNum == fromNum && positionProto.latitudeI == 0 && positionProto.longitudeI == 0) {
debug("Ignoring nop position update for the local node")
return
}
updateNodeInfo(fromNum) {
debug("update position: ${it.longName?.toPIIString()} with ${positionProto.toPIIString()}")
it.setPosition(positionProto, (defaultTimeMillis / 1000L).toInt())
}
}
private fun handleReceivedTelemetry(fromNum: Int, telemetryProto: TelemetryProtos.Telemetry) {
val isRemote = (fromNum != myNodeNum)
if (!isRemote && telemetryProto.hasLocalStats()) {
localStatsTelemetry = telemetryProto
maybeUpdateServiceStatusNotification()
}
updateNodeInfo(fromNum) { nodeEntity ->
when {
telemetryProto.hasDeviceMetrics() -> {
nodeEntity.deviceTelemetry = telemetryProto
if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
val metrics = telemetryProto.deviceMetrics
if (
metrics.voltage > batteryPercentUnsupported &&
metrics.batteryLevel <= batteryPercentLowThreshold
) {
if (shouldBatteryNotificationShow(fromNum, telemetryProto)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
}
} else {
batteryPercentCooldowns.remove(fromNum)
serviceNotifications.cancelLowBatteryNotification(nodeEntity)
}
}
}
telemetryProto.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetryProto
telemetryProto.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetryProto
}
}
}
private fun shouldBatteryNotificationShow(fromNum: Int, telemetry: TelemetryProtos.Telemetry): Boolean {
val isRemote = (fromNum != myNodeNum)
val batteryLevel = telemetry.deviceMetrics.batteryLevel
var shouldDisplay = false
var forceDisplay = false
when {
batteryLevel <= batteryPercentCriticalThreshold -> {
shouldDisplay = true
forceDisplay = true
}
batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true
batteryLevel % batteryPercentLowDivisor == 0 && !isRemote -> shouldDisplay = true
isRemote -> shouldDisplay = true // For remote favorites, show if low
}
if (shouldDisplay) {
val nowSeconds = System.currentTimeMillis() / 1000
val lastNotificationTime = batteryPercentCooldowns[fromNum] ?: 0L
if ((nowSeconds - lastNotificationTime) >= batteryPercentCooldownSeconds || forceDisplay) {
batteryPercentCooldowns[fromNum] = nowSeconds
return true
}
}
return false
}
private fun handleReceivedPaxcounter(fromNum: Int, paxcountProto: PaxcountProtos.Paxcount) {
updateNodeInfo(fromNum) { it.paxcounter = paxcountProto }
}
private fun handleReceivedStoreAndForward(
dataPacket: DataPacket,
storeAndForwardProto: StoreAndForwardProtos.StoreAndForward,
) {
debug("StoreAndForward: ${storeAndForwardProto.variantCase} ${storeAndForwardProto.rr} from ${dataPacket.from}")
when (storeAndForwardProto.variantCase) {
StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> {
val textPacket =
dataPacket.copy(
bytes = storeAndForwardProto.stats.toString().encodeToByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
)
rememberDataPacket(textPacket)
}
StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> {
val text =
"""
Total messages: ${storeAndForwardProto.history.historyMessages}
History window: ${storeAndForwardProto.history.window / 60000} min
Last request: ${storeAndForwardProto.history.lastRequest}
"""
.trimIndent()
val textPacket =
dataPacket.copy(
bytes = text.encodeToByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
)
rememberDataPacket(textPacket)
}
StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> {
var actualTo = dataPacket.to
if (
storeAndForwardProto.rr ==
StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST
) {
actualTo = DataPacket.ID_BROADCAST
}
val textPacket =
dataPacket.copy(
to = actualTo,
bytes = storeAndForwardProto.text.toByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
)
rememberDataPacket(textPacket)
}
StoreAndForwardProtos.StoreAndForward.VariantCase.VARIANT_NOT_SET,
null,
-> Unit
StoreAndForwardProtos.StoreAndForward.VariantCase.HEARTBEAT -> {}
}
}
private val offlineSentPackets = mutableListOf()
private fun handleReceivedMeshPacket(packet: MeshPacket) {
val processedPacket =
packet
.toBuilder()
.apply {
if (rxTime == 0) setRxTime(currentSecond()) // Ensure rxTime is set
}
.build()
processReceivedMeshPacketInternal(processedPacket)
onNodeDBChanged()
}
private val queuedPackets = ConcurrentLinkedQueue()
private val queueResponse = ConcurrentHashMap>()
private var queueJob: Job? = null
private fun sendPacket(packet: MeshPacket): CompletableFuture {
val future = CompletableFuture()
queueResponse[packet.id] = future
try {
if (connectionRouter.connectionState.value != ConnectionState.CONNECTED) {
throw RadioNotConnectedException("Cannot send packet, radio not connected.")
}
sendToRadio(ToRadio.newBuilder().setPacket(packet))
} catch (ex: Exception) {
errormsg("sendToRadio error:", ex)
queueResponse.remove(packet.id) // Clean up if send failed immediately
future.completeExceptionally(ex) // Complete with exception
}
return future
}
private fun startPacketQueue() {
if (queueJob?.isActive == true) return
queueJob =
serviceScope.handledLaunch {
debug("Packet queueJob started")
while (
connectionRouter.connectionState.value == ConnectionState.CONNECTED && queuedPackets.isNotEmpty()
) {
val packet = queuedPackets.poll() ?: break // Should not be null if loop condition met
try {
debug("Queue: Sending packet id=${packet.id.toUInt()}")
val success = sendPacket(packet).get(2, TimeUnit.MINUTES)
debug("Queue: Packet id=${packet.id.toUInt()} sent, success=$success")
} catch (e: TimeoutException) {
debug("Queue: Packet id=${packet.id.toUInt()} timed out: ${e.message}")
queueResponse.remove(packet.id)?.complete(false)
} catch (e: Exception) {
debug("Queue: Packet id=${packet.id.toUInt()} failed: ${e.message}")
queueResponse.remove(packet.id)?.complete(false)
}
}
debug("Packet queueJob finished or radio disconnected")
}
}
private fun stopPacketQueue() {
queueJob
?.takeIf { it.isActive }
?.let {
info("Stopping packet queueJob")
it.cancel()
queueJob = null
queuedPackets.clear()
queueResponse.values.forEach { future -> if (!future.isDone) future.complete(false) }
queueResponse.clear()
}
}
private fun sendNow(dataPacket: DataPacket) {
val meshPacket = toMeshPacket(dataPacket)
dataPacket.time = System.currentTimeMillis() // Update time to actual send time
sendToRadio(meshPacket)
}
private fun processQueuedPackets() {
val packetsToSend = ArrayList(offlineSentPackets) // Avoid ConcurrentModificationException
offlineSentPackets.clear()
packetsToSend.forEach { p ->
try {
sendNow(p)
} catch (ex: Exception) {
errormsg("Error sending queued message, re-queuing:", ex)
offlineSentPackets.add(p) // Re-queue if sending failed
}
}
}
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000L) {
var dataPacket: DataPacket? = null
while (dataPacket == null && isActive) { // check coroutine isActive
dataPacket = packetRepository.get().getPacketById(packetId)?.data
if (dataPacket == null) delay(100L)
}
dataPacket
}
private fun changeStatus(packetId: Int, status: MessageStatus) = serviceScope.handledLaunch {
if (packetId == 0) return@handledLaunch // Ignore packets with no ID
getDataPacketById(packetId)?.let { p ->
if (p.status == status) return@handledLaunch
packetRepository.get().updateMessageStatus(p, status)
serviceBroadcasts.broadcastMessageStatus(packetId, status)
}
}
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int) {
serviceScope.handledLaunch {
val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE
val packetEntity = packetRepository.get().getPacketById(requestId)
packetEntity?.data?.let { dataPacket ->
// Distinguish real ACKs coming from the intended receiver
val newStatus =
when {
isAck && fromId == dataPacket.to -> MessageStatus.RECEIVED
isAck -> MessageStatus.DELIVERED
else -> MessageStatus.ERROR
}
if (dataPacket.status != MessageStatus.RECEIVED) { // Don't override final RECEIVED
dataPacket.status = newStatus
packetRepository.get().update(packetEntity.copy(routingError = routingError, data = dataPacket))
}
serviceBroadcasts.broadcastMessageStatus(requestId, newStatus)
}
}
}
private fun processReceivedMeshPacketInternal(packet: MeshPacket) {
if (!packet.hasDecoded()) return
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "PacketReceived", // Clarified type
received_date = System.currentTimeMillis(),
raw_message = packet.toString(),
fromNum = packet.from,
portNum = packet.decoded.portnumValue,
fromRadio = fromRadio { this.packet = packet },
),
)
serviceScope.handledLaunch { radioConfigRepository.emitMeshPacket(packet) }
val isOtherNode = myNodeNum != packet.from
// Update our own node's lastHeard as we are clearly active to receive this
updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { it.lastHeard = currentSecond() }
updateNodeInfo(packet.from, withBroadcast = false, channel = packet.channel) {
it.lastHeard = packet.rxTime
it.snr = packet.rxSnr
it.rssi = packet.rxRssi
it.hopsAway =
if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) {
-1 // Unknown or direct
} else {
packet.hopStart - packet.hopLimit
}
}
handleReceivedData(packet)
}
private fun insertMeshLog(meshLog: MeshLog) {
serviceScope.handledLaunch { meshLogRepository.get().insert(meshLog) }
}
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(channel: ChannelProtos.Channel) =
serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) }
private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt()
private fun onNodeDBChanged() {
maybeUpdateServiceStatusNotification()
}
private fun reportConnection() {
val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown")
GeeksvilleApplication.analytics.track(
"mesh_connect",
DataPair("num_nodes", numNodes),
DataPair("num_online", numOnlineNodes),
radioModel,
)
GeeksvilleApplication.analytics.setUserInfo(DataPair("num_nodes", numNodes), radioModel)
}
private var connectTimeMsec = 0L
private fun startConnect() {
try {
connectTimeMsec = System.currentTimeMillis()
sendConfigOnlyRequest()
} catch (ex: Exception) {
when (ex) {
is InvalidProtocolBufferException,
is RadioNotConnectedException,
is RemoteException,
-> {
errormsg("Failed to start connection sequence: ${ex.message}", ex)
}
else -> throw ex
}
}
}
private fun startDeviceSleep() {
stopPacketQueue()
stopLocationRequests()
stopMqttClientProxy()
if (connectTimeMsec != 0L) {
val now = System.currentTimeMillis()
GeeksvilleApplication.analytics.track("connected_seconds", DataPair((now - connectTimeMsec) / 1000.0))
connectTimeMsec = 0L
}
serviceBroadcasts.broadcastConnection()
}
private fun startDisconnect() {
stopPacketQueue()
stopLocationRequests()
stopMqttClientProxy()
GeeksvilleApplication.analytics.track(
"mesh_disconnect",
DataPair("num_nodes", numNodes),
DataPair("num_online", numOnlineNodes),
)
GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes))
serviceBroadcasts.broadcastConnection()
}
private fun maybeUpdateServiceStatusNotification() {
val currentSummary = notificationSummary
val currentStats = localStats
val currentStatsUpdatedAtMillis = localStatsUpdatedAtMillis
val summaryChanged = currentSummary.isNotBlank() && previousSummary != currentSummary
val statsChanged = currentStats != null && previousStats != currentStats
if (summaryChanged || statsChanged) {
previousSummary = currentSummary
previousStats = currentStats
serviceNotifications.updateServiceStateNotification(
summaryString = currentSummary,
localStats = currentStats,
currentStatsUpdatedAtMillis = currentStatsUpdatedAtMillis,
)
}
}
@SuppressLint("CheckResult")
@Suppress("CyclomaticComplexMethod")
private fun onReceiveFromRadio(bytes: ByteArray) {
try {
val proto = MeshProtos.FromRadio.parseFrom(bytes)
when (proto.payloadVariantCase) {
MeshProtos.FromRadio.PayloadVariantCase.PACKET -> handleReceivedMeshPacket(proto.packet)
MeshProtos.FromRadio.PayloadVariantCase.CONFIG_COMPLETE_ID ->
handleConfigComplete(proto.configCompleteId)
MeshProtos.FromRadio.PayloadVariantCase.MY_INFO -> handleMyInfo(proto.myInfo)
MeshProtos.FromRadio.PayloadVariantCase.NODE_INFO -> handleNodeInfo(proto.nodeInfo)
MeshProtos.FromRadio.PayloadVariantCase.CHANNEL -> handleChannel(proto.channel)
MeshProtos.FromRadio.PayloadVariantCase.CONFIG -> handleDeviceConfig(proto.config)
MeshProtos.FromRadio.PayloadVariantCase.MODULECONFIG -> handleModuleConfig(proto.moduleConfig)
MeshProtos.FromRadio.PayloadVariantCase.QUEUESTATUS -> handleQueueStatus(proto.queueStatus)
MeshProtos.FromRadio.PayloadVariantCase.METADATA -> handleMetadata(proto.metadata)
MeshProtos.FromRadio.PayloadVariantCase.MQTTCLIENTPROXYMESSAGE ->
handleMqttProxyMessage(proto.mqttClientProxyMessage)
MeshProtos.FromRadio.PayloadVariantCase.DEVICEUICONFIG -> handleDeviceUiConfig(proto.deviceuiConfig)
MeshProtos.FromRadio.PayloadVariantCase.FILEINFO -> handleFileInfo(proto.fileInfo)
MeshProtos.FromRadio.PayloadVariantCase.CLIENTNOTIFICATION ->
handleClientNotification(proto.clientNotification)
MeshProtos.FromRadio.PayloadVariantCase.LOG_RECORD -> {}
MeshProtos.FromRadio.PayloadVariantCase.REBOOTED -> {}
MeshProtos.FromRadio.PayloadVariantCase.XMODEMPACKET -> {}
MeshProtos.FromRadio.PayloadVariantCase.PAYLOADVARIANT_NOT_SET,
null,
-> errormsg("Unexpected FromRadio variant")
}
} catch (ex: InvalidProtocolBufferException) {
errormsg("Invalid Protobuf from radio, len=${bytes.size}", ex)
}
}
private fun handleDeviceConfig(config: ConfigProtos.Config) {
debug("Received config ${config.toOneLineString()}")
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "Config ${config.payloadVariantCase}",
received_date = System.currentTimeMillis(),
raw_message = config.toString(),
fromRadio = fromRadio { this.config = config },
),
)
setLocalConfig(config)
val configCount = localConfig.allFields.size
radioConfigRepository.setStatusMessage("Device config ($configCount / $configTotal)")
}
private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
debug("Received moduleConfig ${config.toOneLineString()}")
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "ModuleConfig ${config.payloadVariantCase}",
received_date = System.currentTimeMillis(),
raw_message = config.toString(),
fromRadio = fromRadio { this.moduleConfig = config },
),
)
setLocalModuleConfig(config)
val moduleCount = moduleConfig.allFields.size
radioConfigRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)")
}
private fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) {
debug("queueStatus ${queueStatus.toOneLineString()}")
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, meshPacketId) }
if (success && isFull) return // Queue is full, wait for next update
val future =
if (requestId != 0) {
queueResponse.remove(requestId)
} else {
// This is a bit of a guess, but for now we assume it's for the last request that isn't done.
// A more robust solution would involve matching something other than packetId.
queueResponse.entries.lastOrNull { !it.value.isDone }?.also { queueResponse.remove(it.key) }?.value
}
future?.complete(success)
}
private fun handleChannel(ch: ChannelProtos.Channel) {
debug("Received channel ${ch.index}")
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "Channel",
received_date = System.currentTimeMillis(),
raw_message = ch.toString(),
fromRadio = fromRadio { channel = ch },
),
)
if (ch.role != ChannelProtos.Channel.Role.DISABLED) updateChannelSettings(ch)
val maxChannels = myNodeInfo?.maxChannels ?: 8
radioConfigRepository.setStatusMessage("Channels (${ch.index + 1} / $maxChannels)")
}
private fun installNodeInfo(info: MeshProtos.NodeInfo) {
updateNodeInfo(info.num) {
if (info.hasUser()) {
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
it.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1
it.isFavorite = info.isFavorite
it.isIgnored = info.isIgnored
}
}
private fun handleNodeInfo(info: MeshProtos.NodeInfo) {
debug(
"Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, " +
"hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}",
)
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "NodeInfo",
received_date = System.currentTimeMillis(),
raw_message = info.toString(),
fromRadio = fromRadio { nodeInfo = info },
),
)
installNodeInfo(info)
onNodeDBChanged()
radioConfigRepository.setStatusMessage("Nodes ($numNodes)")
}
private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata) {
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, // 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,
deviceId = deviceId.toStringUtf8(),
)
}
serviceScope.handledLaunch {
radioConfigRepository.installMyNodeInfo(mi)
radioConfigRepository.insertMetadata(mi.myNodeNum, metadata)
}
myNodeInfo = mi
onConnected()
}
}
private fun sendAnalytics() {
myNodeInfo?.let {
GeeksvilleApplication.analytics.setUserInfo(
DataPair("firmware", it.firmwareVersion),
DataPair("hw_model", it.model),
)
}
}
private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) {
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "MyNodeInfo",
received_date = System.currentTimeMillis(),
raw_message = myInfo.toString(),
fromRadio = fromRadio { this.myInfo = myInfo },
),
)
rawMyNodeInfo = myInfo
}
private fun handleDeviceUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) {
debug("Received DeviceUIConfig ${deviceuiConfig.toOneLineString()}")
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)
}
private fun handleFileInfo(fileInfo: MeshProtos.FileInfo) {
debug("Received FileInfo ${fileInfo.toOneLineString()}")
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 handleMetadata(metadata: MeshProtos.DeviceMetadata) {
debug("Received deviceMetadata ${metadata.toOneLineString()}")
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "DeviceMetadata",
received_date = System.currentTimeMillis(),
raw_message = metadata.toString(),
fromRadio = fromRadio { this.metadata = metadata },
),
)
regenMyNodeInfo(metadata)
}
private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) {
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 -> Unit
}
}
}
private fun handleClientNotification(notification: MeshProtos.ClientNotification) {
debug("Received clientNotification ${notification.toOneLineString()}")
radioConfigRepository.setClientNotification(notification)
serviceNotifications.showClientNotification(notification)
queueResponse.remove(notification.replyId)?.complete(false)
}
private fun startMqttClientProxy() {
if (mqttMessageFlow?.isActive == true) return
if (moduleConfig.mqtt.enabled && moduleConfig.mqtt.proxyToClientEnabled) {
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message -> sendToRadio(ToRadio.newBuilder().setMqttClientProxyMessage(message)) }
.catch { throwable -> radioConfigRepository.setErrorMessage("MqttClientProxy failed: $throwable") }
.launchIn(serviceScope)
}
}
private fun stopMqttClientProxy() {
mqttMessageFlow
?.takeIf { it.isActive }
?.let {
info("Stopping MqttClientProxy")
it.cancel()
mqttMessageFlow = null
}
}
private fun onConnected() {
// Start sending queued packets and other tasks
processQueuedPackets()
startMqttClientProxy()
onNodeDBChanged()
serviceBroadcasts.broadcastConnection()
sendAnalytics()
reportConnection()
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() })
}
private fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) {
CONFIG_ONLY_NONCE -> handleConfigOnlyNonceResponse()
NODE_INFO_ONLY_NONCE -> handleNodeInfoNonceResponse()
else -> warn("Received unexpected config complete id $configCompleteId")
}
}
private fun handleConfigOnlyNonceResponse() {
debug("Received config only complete for nonce $CONFIG_ONLY_NONCE")
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "ConfigOnlyComplete",
received_date = System.currentTimeMillis(),
raw_message = CONFIG_ONLY_NONCE.toString(),
fromRadio = fromRadio { this.configCompleteId = CONFIG_ONLY_NONCE },
),
)
// we have recieved the response to our ConfigOnly request
// send a heartbeat, then request NodeInfoOnly to get the nodeDb from the radio
serviceScope.handledLaunch { radioInterfaceService.keepAlive() }
sendNodeInfoOnlyRequest()
}
private fun handleNodeInfoNonceResponse() {
debug("Received node info complete for nonce $NODE_INFO_ONLY_NONCE")
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "NodeInfoComplete",
received_date = System.currentTimeMillis(),
raw_message = NODE_INFO_ONLY_NONCE.toString(),
fromRadio = fromRadio { this.configCompleteId = NODE_INFO_ONLY_NONCE },
),
)
}
private fun sendConfigOnlyRequest() {
resetState()
debug("Starting config only with nonce=$CONFIG_ONLY_NONCE")
sendToRadio(ToRadio.newBuilder().setWantConfigId(CONFIG_ONLY_NONCE))
}
private fun sendNodeInfoOnlyRequest() {
debug("Starting node info with nonce=$NODE_INFO_ONLY_NONCE")
sendToRadio(ToRadio.newBuilder().setWantConfigId(NODE_INFO_ONLY_NONCE))
}
private fun sendPosition(position: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) {
try {
myNodeInfo?.let { mi ->
val targetNodeNum = destNum ?: mi.myNodeNum
debug("Sending our position/time to=$targetNodeNum ${Position(position)}")
if (!localConfig.position.fixedPosition) {
handleReceivedPosition(mi.myNodeNum, position)
}
sendToRadio(
newMeshPacketTo(targetNodeNum).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 (ex: BLEException) {
warn("Ignoring disconnected radio during gps location update: ${ex.message}")
}
}
private fun setOwner(packetId: Int, user: MeshProtos.User) {
val dest = nodeDBbyID[user.id] ?: throw Exception("Can't set user without a NodeInfo")
if (user == dest.user) {
debug("Ignoring nop owner change")
return
}
debug("setOwner Id: ${user.id} longName: ${user.longName.anonymize} shortName: ${user.shortName}")
handleReceivedUser(dest.num, user)
sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user })
}
private val packetIdGenerator = AtomicLong(Random().nextLong())
private fun generatePacketId(): Int {
// We need a 32 bit unsigned integer, but since Java doesn't have unsigned,
// we can use a long and mask it. To ensure it's never 0, we add 1 after masking.
return (packetIdGenerator.incrementAndGet() and 0xFFFFFFFFL).toInt().let { if (it == 0) 1 else it }
}
private fun enqueueForSending(p: DataPacket) {
if (p.dataType in rememberableDataTypes) {
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.AddSharedContact -> importContact(action.contact)
}
}
}
private fun importContact(contact: AdminProtos.SharedContact) {
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { addContact = contact })
handleReceivedUser(contact.nodeNum, contact.user)
}
private fun getDeviceMetadata(destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) { getDeviceMetadataRequest = true })
}
private fun favoriteNode(node: Node) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(myNodeNum).buildAdminPacket {
if (node.isFavorite) {
debug("removing node ${node.num} from favorite list")
removeFavoriteNode = node.num
} else {
debug("adding node ${node.num} to favorite list")
setFavoriteNode = node.num
}
},
)
updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
}
private fun ignoreNode(node: Node) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(myNodeNum).buildAdminPacket {
if (node.isIgnored) {
debug("removing node ${node.num} from ignore list")
removeIgnoredNode = node.num
} else {
debug("adding node ${node.num} to ignore list")
setIgnoredNode = node.num
}
},
)
updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
}
private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions {
val channel = reaction.contactKey[0].digitToInt()
val destId = reaction.contactKey.substring(1)
val packet =
newMeshPacketTo(destId).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())
}
sendToRadio(packet)
rememberReaction(packet.toBuilder().setFrom(myNodeNum).build())
}
private val _lastAddress: MutableStateFlow = MutableStateFlow(null)
val lastAddress: StateFlow
get() = _lastAddress.asStateFlow()
lateinit var sharedPreferences: SharedPreferences
fun clearDatabases() = serviceScope.handledLaunch {
debug("Clearing nodeDB")
radioConfigRepository.clearNodeDB()
}
private fun updateLastAddress(deviceAddr: String?) {
val currentAddr = lastAddress.value
debug("setDeviceAddress: New: ${deviceAddr.anonymize}, Old: ${currentAddr.anonymize}")
if (deviceAddr != currentAddr) {
_lastAddress.value = deviceAddr ?: NO_DEVICE_SELECTED
sharedPreferences.edit { putString(DEVICE_ADDRESS_KEY, deviceAddr) }
clearNotifications()
clearDatabases()
resetState()
}
}
private fun clearNotifications() {
serviceNotifications.clearNotifications()
}
private val binder =
object : IMeshService.Stub() {
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
debug("Passing through device change to radio service: ${deviceAddr.anonymize}")
updateLastAddress(deviceAddr)
sharedPreferences.edit { putString("device_address", deviceAddr) }
connectionRouter.setDeviceAddress(deviceAddr)
}
override fun subscribeReceiver(packageName: String, receiverName: String) = toRemoteExceptions {
clientPackages[receiverName] = packageName
}
override fun getUpdateStatus(): Int = -4 // ProgressNotStarted (DEPRECATED)
override fun startFirmwareUpdate() = toRemoteExceptions {}
override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo()
override fun getMyId(): String = toRemoteExceptions { myNodeID }
override fun getPacketId(): Int = toRemoteExceptions { generatePacketId() }
override fun setOwner(user: MeshUser) = toRemoteExceptions {
setOwner(
generatePacketId(),
user {
id = user.id
longName = user.longName
shortName = user.shortName
isLicensed = user.isLicensed
},
)
}
override fun setRemoteOwner(id: Int, payload: ByteArray) = toRemoteExceptions {
val parsed = MeshProtos.User.parseFrom(payload)
setOwner(id, parsed)
}
override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { getOwnerRequest = true },
)
}
override fun send(p: DataPacket) {
toRemoteExceptions {
if (p.id == 0) p.id = generatePacketId()
info(
"sendData dest=${p.to}, id=${p.id} <- ${p.bytes?.size} bytes " +
"(connectionState=${connectionRouter.connectionState.value})",
)
if (p.dataType == 0) throw InvalidProtocolBufferException("Port numbers must be non-zero")
if ((p.bytes?.size ?: 0) >= MeshProtos.Constants.DATA_PAYLOAD_LEN_VALUE) {
p.status = MessageStatus.ERROR
throw RemoteException("Message too long")
} else {
p.status = MessageStatus.QUEUED
}
if (connectionRouter.connectionState.value == ConnectionState.CONNECTED) {
try {
sendNow(p)
} catch (ex: Exception) {
errormsg("Error sending message, so enqueueing", ex)
enqueueForSending(p)
}
} else {
enqueueForSending(p)
}
serviceBroadcasts.broadcastMessageStatus(p)
rememberDataPacket(p, false)
GeeksvilleApplication.analytics.track(
"data_send",
DataPair("num_bytes", p.bytes?.size),
DataPair("type", p.dataType),
)
GeeksvilleApplication.analytics.track("num_data_sent", DataPair(1))
}
}
override fun getConfig(): ByteArray = toRemoteExceptions {
this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException()
}
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
setRemoteConfig(generatePacketId(), myNodeNum, payload)
}
override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
debug("Setting new radio config!")
val config = ConfigProtos.Config.parseFrom(payload)
sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config })
if (num == myNodeNum) setLocalConfig(config)
}
override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
getConfigRequestValue = config
},
)
}
override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
debug("Setting new module config!")
val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload)
sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config })
if (num == myNodeNum) setLocalModuleConfig(config)
}
override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
getModuleConfigRequestValue = config
},
)
}
override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setRingtoneMessage = ringtone })
}
override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
getRingtoneRequest = true
},
)
}
override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setCannedMessageModuleMessages = messages })
}
override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
getCannedMessageModuleMessagesRequest = true
},
)
}
override fun setChannel(payload: ByteArray) = toRemoteExceptions {
setRemoteChannel(generatePacketId(), myNodeNum, payload)
}
override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
val channel = ChannelProtos.Channel.parseFrom(payload)
sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel })
}
override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
getChannelRequest = index + 1 // API is 1-based
},
)
}
override fun beginEditSettings() = toRemoteExceptions {
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { beginEditSettings = true })
}
override fun commitEditSettings() = toRemoteExceptions {
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { commitEditSettings = true })
}
override fun getChannelSet(): ByteArray = toRemoteExceptions { this@MeshService.channelSet.toByteArray() }
override fun getNodes(): MutableList = toRemoteExceptions {
nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList()
}
override fun connectionState(): String = toRemoteExceptions {
this@MeshService.connectionRouter.connectionState.value.toString()
}
override fun startProvideLocation() = toRemoteExceptions {
@SuppressLint("MissingPermission")
startLocationRequests()
}
override fun stopProvideLocation() = toRemoteExceptions { stopLocationRequests() }
override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions {
nodeDBbyNodeNum.remove(nodeNum)?.let { removedNode ->
if (removedNode.user.id.isNotEmpty()) {
_nodeDBbyID.remove(removedNode.user.id)
}
}
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { this.removeByNodenum = nodeNum })
}
override fun requestUserInfo(destNum: Int) = toRemoteExceptions {
if (destNum != myNodeNum) {
sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) {
portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE
wantResponse = true
payload = nodeDBbyNodeNum[myNodeNum]?.user?.toByteString() ?: ByteString.EMPTY
},
)
}
}
override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions {
if (destNum == myNodeNum) return@toRemoteExceptions
val provideLocation = sharedPreferences.getBoolean("provide-location-$myNodeNum", false)
val currentPosition =
when {
provideLocation && position.isValid() -> position
else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
}
if (currentPosition == null) {
debug("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()
}
sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
priority = MeshPacket.Priority.BACKGROUND,
) {
portnumValue = Portnums.PortNum.POSITION_APP_VALUE
payload = meshPosition.toByteString()
wantResponse = true
},
)
}
override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions {
val pos = position {
latitudeI = Position.degI(position.latitude)
longitudeI = Position.degI(position.longitude)
altitude = position.altitude
}
sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket {
if (position.latitude != 0.0 || position.longitude != 0.0 || position.altitude != 0) {
setFixedPosition = pos
} else {
removeFixedPosition = true
}
},
)
updateNodeInfo(destNum) { it.setPosition(pos, currentSecond()) }
}
override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
wantAck = true,
id = requestId,
channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
) {
portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE
wantResponse = true
},
)
}
override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { shutdownSeconds = 5 })
}
override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { rebootSeconds = 5 })
}
override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { factoryResetDevice = 1 })
}
override fun requestNodedbReset(requestId: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { nodedbReset = 1 })
}
}
}