mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-13 01:05:55 -04:00
refactor: delete 6 dead packet handlers post-SDK cutover (-1420 lines)
Remove handler interfaces, implementations, and tests that have zero production callers after the SDK became authoritative for protocol handling: - TelemetryPacketHandler/Impl — SDK owns telemetry via NodeChange.Updated - StoreForwardPacketHandler/Impl — SDK owns S&F lifecycle + SFPP - NeighborInfoHandler/Impl — SDK owns NeighborInfo model - TracerouteHandler/Impl — SDK owns traceroute via AdminResult flow - MeshDataHandler/MessagePersistenceHandler — handleReceivedData was no-op - HistoryManager/Impl — only caller was deleted StoreForwardPacketHandlerImpl Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
|
||||
@Single
|
||||
class HistoryManagerImpl(
|
||||
private val meshPrefs: MeshPrefs,
|
||||
private val radioController: RadioController,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : HistoryManager {
|
||||
|
||||
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
|
||||
private const val NO_DEVICE_SELECTED = "No device selected"
|
||||
|
||||
fun resolveHistoryRequestParameters(window: Int, max: Int): Pair<Int, Int> {
|
||||
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 val logger = Logger.withTag(HISTORY_TAG)
|
||||
|
||||
private fun historyLog(message: String, throwable: Throwable? = null) {
|
||||
logger.i(throwable) { message }
|
||||
}
|
||||
|
||||
private fun activeDeviceAddress(): String? =
|
||||
meshPrefs.deviceAddress.value?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
|
||||
|
||||
override fun requestHistoryReplay(
|
||||
trigger: String,
|
||||
myNodeNum: Int?,
|
||||
storeForwardConfig: 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).value.takeIf { it > 0 }
|
||||
val (window, max) =
|
||||
resolveHistoryRequestParameters(
|
||||
storeForwardConfig?.history_return_window ?: 0,
|
||||
storeForwardConfig?.history_return_max ?: 0,
|
||||
)
|
||||
|
||||
historyLog(
|
||||
"requestHistory trigger=$trigger transport=$transport addr=$address " +
|
||||
"since=${lastRequest ?: "all"} window=$window max=$max via=sdk",
|
||||
)
|
||||
|
||||
scope.handledLaunch {
|
||||
val accepted = radioController.requestStoreForwardHistory(since = lastRequest)
|
||||
if (!accepted) {
|
||||
logger.w {
|
||||
"requestHistory failed trigger=$trigger transport=$transport addr=$address since=${lastRequest ?: "all"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
|
||||
if (lastRequest <= 0) return
|
||||
val address = activeDeviceAddress() ?: return
|
||||
val current = meshPrefs.getStoreForwardLastRequest(address).value
|
||||
if (lastRequest != current) {
|
||||
meshPrefs.setStoreForwardLastRequest(address, lastRequest)
|
||||
historyLog(
|
||||
"historyMarker updated source=$source transport=$transport " +
|
||||
"addr=$address from=$current to=$lastRequest",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.critical_alert
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.resources.waypoint_received
|
||||
import org.meshtastic.proto.PortNum
|
||||
|
||||
/**
|
||||
* SDK-era implementation of [MeshDataHandler] focused on message persistence and notifications.
|
||||
*
|
||||
* The full packet-routing logic (handleReceivedData) is no longer needed — the SDK's packet flow
|
||||
* is consumed directly by VMs and SdkStateBridge. This class retains only [rememberDataPacket]
|
||||
* which is called by [StoreForwardPacketHandlerImpl] to persist forwarded messages.
|
||||
*/
|
||||
@Single
|
||||
class MessagePersistenceHandler(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshServiceNotifications,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val messageFilter: MessageFilter,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : MeshDataHandler {
|
||||
|
||||
private val rememberDataType =
|
||||
setOf(
|
||||
PortNum.TEXT_MESSAGE_APP.value,
|
||||
PortNum.ALERT_APP.value,
|
||||
PortNum.WAYPOINT_APP.value,
|
||||
PortNum.NODE_STATUS_APP.value,
|
||||
)
|
||||
|
||||
override fun handleReceivedData(
|
||||
packet: org.meshtastic.proto.MeshPacket,
|
||||
myNodeNum: Int,
|
||||
logUuid: String?,
|
||||
logInsertJob: kotlinx.coroutines.Job?,
|
||||
) {
|
||||
// No-op: Incoming packet routing is handled by SdkStateBridge / VM packet observers.
|
||||
// This method exists only to satisfy the MeshDataHandler interface contract.
|
||||
}
|
||||
|
||||
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
|
||||
if (dataPacket.dataType !in rememberDataType) return
|
||||
val fromLocal = dataPacket.from == DataPacket.LOCAL || dataPacket.from == myNodeNum
|
||||
val toBroadcast = dataPacket.to == DataPacket.BROADCAST
|
||||
val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
|
||||
|
||||
val contactKey = "${dataPacket.channel}${DataPacket.nodeNumToId(contactId)}"
|
||||
|
||||
scope.handledLaunch {
|
||||
packetRepository.value.apply {
|
||||
val existingPackets = findPacketsWithId(dataPacket.id)
|
||||
if (existingPackets.isNotEmpty()) {
|
||||
Logger.d {
|
||||
"Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " +
|
||||
"to=${dataPacket.to} contactKey=$contactKey" +
|
||||
" (already have ${existingPackets.size} packet(s))"
|
||||
}
|
||||
return@handledLaunch
|
||||
}
|
||||
|
||||
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
|
||||
|
||||
insert(
|
||||
dataPacket,
|
||||
myNodeNum,
|
||||
contactKey,
|
||||
nowMillis,
|
||||
read = fromLocal || isFiltered,
|
||||
filtered = isFiltered,
|
||||
)
|
||||
if (!isFiltered) {
|
||||
handlePacketNotification(dataPacket, contactKey, updateNotification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
|
||||
val isIgnored = nodeRepository.nodeDBbyNum.value[dataPacket.from]?.isIgnored == true
|
||||
if (isIgnored) return true
|
||||
|
||||
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
|
||||
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
|
||||
return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
|
||||
}
|
||||
|
||||
private suspend fun handlePacketNotification(
|
||||
dataPacket: DataPacket,
|
||||
contactKey: String,
|
||||
updateNotification: Boolean,
|
||||
) {
|
||||
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
|
||||
val nodeMuted = nodeRepository.nodeDBbyNum.value[dataPacket.from]?.isMuted == true
|
||||
val isSilent = conversationMuted || nodeMuted
|
||||
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
|
||||
scope.launch {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
|
||||
category = Notification.Category.Alert,
|
||||
contactKey = contactKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (updateNotification && !isSilent) {
|
||||
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSenderName(packet: DataPacket): String {
|
||||
if (packet.from == DataPacket.LOCAL) {
|
||||
return nodeRepository.ourNodeInfo.value?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
|
||||
}
|
||||
return nodeRepository.nodeDBbyNum.value[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
|
||||
}
|
||||
|
||||
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
|
||||
when (dataPacket.dataType) {
|
||||
PortNum.TEXT_MESSAGE_APP.value -> {
|
||||
val message = dataPacket.text!!
|
||||
val channelName =
|
||||
if (dataPacket.to == DataPacket.BROADCAST) {
|
||||
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
serviceNotifications.updateMessageNotification(
|
||||
contactKey,
|
||||
getSenderName(dataPacket),
|
||||
message,
|
||||
dataPacket.to == DataPacket.BROADCAST,
|
||||
channelName,
|
||||
isSilent,
|
||||
)
|
||||
}
|
||||
|
||||
PortNum.WAYPOINT_APP.value -> {
|
||||
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = message,
|
||||
category = Notification.Category.Message,
|
||||
contactKey = contactKey,
|
||||
isSilent = isSilent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.update
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
import org.meshtastic.sdk.NeighborInfo as SdkNeighborInfo
|
||||
|
||||
@Single
|
||||
class NeighborInfoHandlerImpl(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) : NeighborInfoHandler {
|
||||
|
||||
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||
|
||||
override var lastNeighborInfo: NeighborInfo? = null
|
||||
|
||||
override fun recordStartTime(requestId: Int) {
|
||||
startTimes.update { it.put(requestId, nowMillis) }
|
||||
}
|
||||
|
||||
override fun handleNeighborInfo(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val ni = NeighborInfo.ADAPTER.decode(payload)
|
||||
|
||||
val from = packet.from
|
||||
if (from == nodeRepository.myNodeNum.value) {
|
||||
lastNeighborInfo = ni
|
||||
Logger.d { "Stored last neighbor info from connected radio" }
|
||||
}
|
||||
|
||||
val requestId = packet.decoded?.request_id ?: 0
|
||||
val start = startTimes.value[requestId]
|
||||
startTimes.update { it.remove(requestId) }
|
||||
|
||||
val formatted =
|
||||
SdkNeighborInfo
|
||||
.fromProto(
|
||||
reportingNode = from,
|
||||
neighborNodeIds = ni.neighbors.map { it.node_id },
|
||||
snrValues = ni.neighbors.map { it.snr },
|
||||
).format { nodeId ->
|
||||
val user = nodeRepository.getUser(nodeId.raw)
|
||||
"${user.long_name} (${user.short_name})"
|
||||
}
|
||||
|
||||
val responseText =
|
||||
if (start != null) {
|
||||
val elapsedMs = nowMillis - start
|
||||
val seconds = elapsedMs / MILLIS_PER_SECOND
|
||||
Logger.i { "Neighbor info $requestId complete in $seconds s" }
|
||||
"$formatted\nDuration: ${NumberFormatter.format(seconds, 1)} s"
|
||||
} else {
|
||||
formatted
|
||||
}
|
||||
|
||||
serviceRepository.setNeighborInfoResponse(responseText)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MILLIS_PER_SECOND = 1000.0
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.StoreAndForward
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Implementation of [StoreForwardPacketHandler] that keeps legacy S&F parsing for backward compatibility.
|
||||
*
|
||||
* SF++ parsing/status updates are delegated to the SDK and consumed via [org.meshtastic.core.data.radio.SdkStateBridge].
|
||||
*/
|
||||
@Single
|
||||
class StoreForwardPacketHandlerImpl(
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val historyManager: HistoryManager,
|
||||
private val dataHandler: Lazy<MeshDataHandler>,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : StoreForwardPacketHandler {
|
||||
|
||||
override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = StoreAndForward.ADAPTER.decode(payload)
|
||||
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
|
||||
}
|
||||
|
||||
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
|
||||
val lastRequest = s.history?.last_request ?: 0
|
||||
Logger.d { "StoreAndForward from=${dataPacket.from} lastRequest=$lastRequest" }
|
||||
when {
|
||||
s.stats != null -> {
|
||||
val text = s.stats.toString()
|
||||
val u =
|
||||
dataPacket.copy(
|
||||
bytes = text.encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
|
||||
s.history != null -> {
|
||||
val h = s.history!!
|
||||
val text =
|
||||
"Total messages: ${h.history_messages}\n" +
|
||||
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
|
||||
"Last request: ${h.last_request}"
|
||||
val u =
|
||||
dataPacket.copy(
|
||||
bytes = text.encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
|
||||
}
|
||||
|
||||
s.heartbeat != null -> {
|
||||
val hb = s.heartbeat!!
|
||||
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
|
||||
}
|
||||
|
||||
s.text != null -> {
|
||||
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
|
||||
dataPacket.to = DataPacket.BROADCAST
|
||||
}
|
||||
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
|
||||
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.toOneLiner
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.TelemetryPacketHandler
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Implementation of [TelemetryPacketHandler] that processes telemetry packets and manages battery-level notifications
|
||||
* with cooldown logic.
|
||||
*/
|
||||
@Single
|
||||
class TelemetryPacketHandlerImpl(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val notificationManager: NotificationManager,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : TelemetryPacketHandler {
|
||||
|
||||
private val batteryMutex = Mutex()
|
||||
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val t =
|
||||
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
|
||||
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
|
||||
}
|
||||
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
|
||||
val fromNum = packet.from
|
||||
val isRemote = (fromNum != myNodeNum)
|
||||
// Note: Local telemetry notification update was previously handled by
|
||||
// MeshConnectionManager.updateTelemetry(), now managed via SDK flows.
|
||||
|
||||
nodeRepository.updateNode(fromNum) { node: Node ->
|
||||
val metrics = t.device_metrics
|
||||
val environment = t.environment_metrics
|
||||
val power = t.power_metrics
|
||||
|
||||
var nextNode = node
|
||||
when {
|
||||
metrics != null -> {
|
||||
nextNode = nextNode.copy(deviceMetrics = metrics)
|
||||
if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
|
||||
if (
|
||||
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
|
||||
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
|
||||
) {
|
||||
scope.launch {
|
||||
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title =
|
||||
getStringSuspend(
|
||||
Res.string.low_battery_title,
|
||||
nextNode.user.short_name,
|
||||
),
|
||||
message =
|
||||
getStringSuspend(
|
||||
Res.string.low_battery_message,
|
||||
nextNode.user.long_name,
|
||||
nextNode.deviceMetrics.battery_level ?: 0,
|
||||
),
|
||||
category = Notification.Category.Battery,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
batteryMutex.withLock {
|
||||
if (batteryPercentCooldowns.containsKey(fromNum)) {
|
||||
batteryPercentCooldowns.remove(fromNum)
|
||||
}
|
||||
}
|
||||
notificationManager.cancel(nextNode.num)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
|
||||
|
||||
power != null -> nextNode = nextNode.copy(powerMetrics = power)
|
||||
}
|
||||
|
||||
val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard
|
||||
val newLastHeard = maxOf(nextNode.lastHeard, telemetryTime)
|
||||
nextNode.copy(lastHeard = newLastHeard)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
|
||||
val isRemote = (fromNum != myNodeNum)
|
||||
var shouldDisplay = false
|
||||
var forceDisplay = false
|
||||
val metrics = t.device_metrics ?: return false
|
||||
val batteryLevel = metrics.battery_level ?: 0
|
||||
when {
|
||||
batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
|
||||
shouldDisplay = true
|
||||
forceDisplay = true
|
||||
}
|
||||
|
||||
batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
|
||||
|
||||
batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
|
||||
|
||||
isRemote -> shouldDisplay = true
|
||||
}
|
||||
if (shouldDisplay) {
|
||||
val now = nowSeconds
|
||||
batteryMutex.withLock {
|
||||
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
|
||||
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
|
||||
batteryPercentCooldowns[fromNum] = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.update
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
import org.meshtastic.core.model.service.TracerouteResponse
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.core.repository.TracerouteSnapshotRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.traceroute_route_back_to_us
|
||||
import org.meshtastic.core.resources.traceroute_route_towards_dest
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
@Single
|
||||
class TracerouteHandlerImpl(
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : TracerouteHandler {
|
||||
|
||||
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||
|
||||
override fun recordStartTime(requestId: Int) {
|
||||
startTimes.update { it.put(requestId, nowMillis) }
|
||||
}
|
||||
|
||||
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) {
|
||||
// Decode the route discovery once — avoids triple protobuf decode
|
||||
val routeDiscovery = packet.fullRouteDiscovery ?: return
|
||||
val forwardRoute = routeDiscovery.route
|
||||
val returnRoute = routeDiscovery.route_back
|
||||
|
||||
// Require both directions for a "full" traceroute response
|
||||
if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return
|
||||
|
||||
scope.handledLaunch {
|
||||
val full =
|
||||
routeDiscovery.getTracerouteResponse(
|
||||
getUser = { num ->
|
||||
val user = nodeRepository.getUser(num)
|
||||
"${user.long_name} (${user.short_name})"
|
||||
},
|
||||
headerTowards = getStringSuspend(Res.string.traceroute_route_towards_dest),
|
||||
headerBack = getStringSuspend(Res.string.traceroute_route_back_to_us),
|
||||
)
|
||||
|
||||
val requestId = packet.decoded?.request_id ?: 0
|
||||
|
||||
if (logUuid != null) {
|
||||
logInsertJob?.join()
|
||||
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 = startTimes.value[requestId]
|
||||
startTimes.update { it.remove(requestId) }
|
||||
val responseText =
|
||||
if (start != null) {
|
||||
val elapsedMs = nowMillis - start
|
||||
val seconds = elapsedMs / MILLIS_PER_SECOND
|
||||
Logger.i { "Traceroute $requestId complete in $seconds s" }
|
||||
"$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
|
||||
} else {
|
||||
full
|
||||
}
|
||||
|
||||
val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0
|
||||
|
||||
serviceRepository.setTracerouteResponse(
|
||||
TracerouteResponse(
|
||||
message = responseText,
|
||||
destinationNodeNum = destination,
|
||||
requestId = requestId,
|
||||
forwardRoute = forwardRoute,
|
||||
returnRoute = returnRoute,
|
||||
logUuid = logUuid,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MILLIS_PER_SECOND = 1000.0
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class HistoryManagerImplTest {
|
||||
|
||||
@Test
|
||||
fun `resolveHistoryRequestParameters uses config values when positive`() {
|
||||
val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 30, max = 10)
|
||||
|
||||
assertEquals(30, window)
|
||||
assertEquals(10, max)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveHistoryRequestParameters falls back to defaults when non-positive`() {
|
||||
val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 0, max = -5)
|
||||
|
||||
assertEquals(1440, window)
|
||||
assertEquals(100, max)
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify.VerifyMode
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.StoreAndForward
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class StoreForwardPacketHandlerImplTest {
|
||||
|
||||
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
|
||||
private val historyManager = mock<HistoryManager>(MockMode.autofill)
|
||||
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var handler: StoreForwardPacketHandlerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
handler =
|
||||
StoreForwardPacketHandlerImpl(
|
||||
packetRepository = lazy { packetRepository },
|
||||
historyManager = historyManager,
|
||||
dataHandler = lazy { dataHandler },
|
||||
scope = testScope,
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeSfPacket(from: Int, sf: StoreAndForward): MeshPacket {
|
||||
val payload = StoreAndForward.ADAPTER.encode(sf).toByteString()
|
||||
return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload))
|
||||
}
|
||||
|
||||
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
|
||||
id = 1,
|
||||
time = 1700000000000L,
|
||||
to = DataPacket.BROADCAST,
|
||||
from = from,
|
||||
bytes = null,
|
||||
dataType = PortNum.STORE_FORWARD_APP.value,
|
||||
)
|
||||
|
||||
// ---------- Legacy S&F: stats ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward stats creates text data packet`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
stats = StoreAndForward.Statistics(messages_total = 100, messages_saved = 50, messages_max = 200),
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: history ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward history creates text packet and updates last request`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
history =
|
||||
StoreAndForward.History(history_messages = 42, window = 3600000, last_request = 1700000000),
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
verify { historyManager.updateStoreForwardLastRequest("router_history", 1700000000, "Unknown") }
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: heartbeat ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward heartbeat does not crash`() = testScope.runTest {
|
||||
val sf = StoreAndForward(heartbeat = StoreAndForward.Heartbeat(period = 900, secondary = 1))
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash, just logs
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: text ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward text with broadcast rr sets to broadcast`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
text = "Hello from router".encodeToByteArray().toByteString(),
|
||||
rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST,
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward text without broadcast rr preserves destination`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
text = "Direct message".encodeToByteArray().toByteString(),
|
||||
rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT,
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: null payload ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward with null payload returns early`() = testScope.runTest {
|
||||
val packet = MeshPacket(from = 999, decoded = null)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: empty message ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward with no fields set does not crash`() = testScope.runTest {
|
||||
val sf = StoreAndForward()
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash — falls through to else branch
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.PowerMetrics
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TelemetryPacketHandlerImplTest {
|
||||
|
||||
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
|
||||
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var handler: TelemetryPacketHandlerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
private val remoteNodeNum = 99999
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
handler =
|
||||
TelemetryPacketHandlerImpl(
|
||||
nodeRepository = nodeRepository,
|
||||
notificationManager = notificationManager,
|
||||
scope = testScope,
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeTelemetryPacket(from: Int, telemetry: Telemetry): MeshPacket {
|
||||
val payload = Telemetry.ADAPTER.encode(telemetry).toByteString()
|
||||
return MeshPacket(
|
||||
from = from,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = payload),
|
||||
rx_time = 1700000000,
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
|
||||
id = 1,
|
||||
time = 1700000000000L,
|
||||
to = DataPacket.BROADCAST,
|
||||
from = from,
|
||||
bytes = null,
|
||||
dataType = PortNum.TELEMETRY_APP.value,
|
||||
)
|
||||
|
||||
// ---------- Device metrics from local node ----------
|
||||
|
||||
@Test
|
||||
fun `local device metrics updates node`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f))
|
||||
val packet = makeTelemetryPacket(myNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeRepository.updateNode(myNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Device metrics from remote node ----------
|
||||
|
||||
@Test
|
||||
fun `remote device metrics updates node`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f))
|
||||
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(remoteNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Environment metrics ----------
|
||||
|
||||
@Test
|
||||
fun `environment metrics updates node with environment data`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
time = 1700000000,
|
||||
environment_metrics = EnvironmentMetrics(temperature = 25.5f, relative_humidity = 60.0f),
|
||||
)
|
||||
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(remoteNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Power metrics ----------
|
||||
|
||||
@Test
|
||||
fun `power metrics updates node with power data`() = testScope.runTest {
|
||||
val telemetry = Telemetry(time = 1700000000, power_metrics = PowerMetrics(ch1_voltage = 3.3f))
|
||||
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(remoteNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Telemetry time handling ----------
|
||||
|
||||
@Test
|
||||
fun `telemetry with time 0 gets time from dataPacket`() = testScope.runTest {
|
||||
val telemetry = Telemetry(time = 0, device_metrics = DeviceMetrics(battery_level = 50, voltage = 3.8f))
|
||||
val packet = makeTelemetryPacket(myNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeRepository.updateNode(myNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Null payload ----------
|
||||
|
||||
@Test
|
||||
fun `handleTelemetry with null decoded payload returns early`() = testScope.runTest {
|
||||
val packet = MeshPacket(from = myNodeNum, decoded = null)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleTelemetry with empty payload bytes returns early`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = okio.ByteString.EMPTY),
|
||||
)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash — decodeOrNull returns null for empty payload
|
||||
}
|
||||
|
||||
// ---------- Battery notification: healthy battery does NOT trigger ----------
|
||||
|
||||
@Test
|
||||
fun `healthy battery level does not trigger low battery notification`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.0f))
|
||||
val packet = makeTelemetryPacket(myNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No dispatch call — battery is healthy
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
|
||||
/** Interface for managing store-and-forward history replay requests. */
|
||||
interface HistoryManager {
|
||||
/**
|
||||
* Requests a history replay from the radio.
|
||||
*
|
||||
* @param trigger A string identifying the trigger for the request (for logging).
|
||||
* @param myNodeNum The local node number.
|
||||
* @param storeForwardConfig The store-and-forward module configuration.
|
||||
* @param transport The transport method being used (for logging).
|
||||
*/
|
||||
fun requestHistoryReplay(
|
||||
trigger: String,
|
||||
myNodeNum: Int?,
|
||||
storeForwardConfig: ModuleConfig.StoreForwardConfig?,
|
||||
transport: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates the last requested history marker.
|
||||
*
|
||||
* @param source A string identifying the source of the update (for logging).
|
||||
* @param lastRequest The timestamp or sequence number of the last received history message.
|
||||
* @param transport The transport method being used (for logging).
|
||||
*/
|
||||
fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling incoming mesh data packets and routing them to the appropriate handlers. */
|
||||
interface MeshDataHandler {
|
||||
/**
|
||||
* Processes a received mesh packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param myNodeNum The local node number.
|
||||
* @param logUuid Optional UUID for logging purposes.
|
||||
* @param logInsertJob Optional job that tracks the insertion of the packet into the log.
|
||||
*/
|
||||
fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null)
|
||||
|
||||
/**
|
||||
* Persists a data packet in the history and triggers notifications if necessary.
|
||||
*
|
||||
* @param dataPacket The data packet to remember.
|
||||
* @param myNodeNum The local node number.
|
||||
* @param updateNotification Whether to trigger a notification for this packet.
|
||||
*/
|
||||
fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
|
||||
/** Interface for handling neighbor info responses from the mesh. */
|
||||
interface NeighborInfoHandler {
|
||||
/** Records the start time for a neighbor info request. */
|
||||
fun recordStartTime(requestId: Int)
|
||||
|
||||
/** The latest neighbor info received from the connected radio. */
|
||||
var lastNeighborInfo: NeighborInfo?
|
||||
|
||||
/**
|
||||
* Processes a neighbor info packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
*/
|
||||
fun handleNeighborInfo(packet: MeshPacket)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling Store & Forward (legacy) packets. */
|
||||
interface StoreForwardPacketHandler {
|
||||
/**
|
||||
* Handles a legacy Store & Forward packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param dataPacket The decoded data packet.
|
||||
* @param myNodeNum The local node number.
|
||||
*/
|
||||
fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling telemetry packets from the mesh, including battery notifications. */
|
||||
interface TelemetryPacketHandler {
|
||||
/**
|
||||
* Processes a telemetry packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param dataPacket The decoded data packet.
|
||||
* @param myNodeNum The local node number.
|
||||
*/
|
||||
fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling traceroute responses from the mesh. */
|
||||
interface TracerouteHandler {
|
||||
/** Records the start time for a traceroute request. */
|
||||
fun recordStartTime(requestId: Int)
|
||||
|
||||
/**
|
||||
* Processes a traceroute packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param logUuid Optional UUID for the associated log entry.
|
||||
* @param logInsertJob Optional job for the log entry insertion, to ensure ordering.
|
||||
*/
|
||||
fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?)
|
||||
}
|
||||
Reference in New Issue
Block a user