Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.FromRadioPacketHandler
Expand Down Expand Up @@ -182,13 +183,8 @@ class MeshMessageProcessorImpl(

/** Test seam for packet-only fixtures with explicit transport authority. */
internal suspend fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?, session: RadioSessionContext) {
val rxTime =
if (packet.rx_time == 0) {
nowSeconds.toInt()
} else {
packet.rx_time
}
val preparedPacket = packet.copy(rx_time = rxTime)
// Single normalization point: every consumer downstream of this copy sees a stamped packet.
val preparedPacket = packet.copy(rx_time = packet.rxTimeOrNull() ?: nowSeconds.toInt())
Comment thread
jamesarich marked this conversation as resolved.

// Require myNodeNum to be known before storing: processReceivedMeshPacket only keys a local packet under
// NODE_NUM_LOCAL when packet.from == myNodeNum. If myNodeNum is still null (early in a (re)connect, before
Expand Down Expand Up @@ -317,7 +313,8 @@ class MeshMessageProcessorImpl(
else -> packet.hop_start - packet.hop_limit
}
return node.copy(
lastHeard = clampTimestampToNow(packet.rx_time),
// Packets reach here normalized, but an unstamped one must not reset lastHeard to the epoch.
lastHeard = packet.rxTimeOrNull()?.let(::clampTimestampToNow) ?: node.lastHeard,
viaMqtt = viaMqtt,
lastTransport = packet.transport_mechanism.value,
snr = if (updateRadioMetrics) packet.rx_snr else node.snr,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import dev.mokkery.answering.throws
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.matcher.matches
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
Expand Down Expand Up @@ -562,6 +563,27 @@ class MeshMessageProcessorImplTest {
verifySuspend { serviceRepository.emitMeshPacket(any()) }
}

@Test
fun `packets with absent rx_time get current time`() = runTest(testDispatcher) {
processor = createProcessor(backgroundScope)
isNodeDbReady.value = true

val packet =
MeshPacket(
id = 3,
from = myNodeNum,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = null, // radio had no clock at reception
)

processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()

verifySuspend {
serviceRepository.emitMeshPacket(matches<MeshPacket> { emitted -> (emitted.rx_time ?: 0) > 0 })
}
}

// ---------- handleReceivedMeshPacket: node updates ----------

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ data class Message(
val time: String,
/**
* Mesh time in epoch millis (the packet's `rx_time`) — the instant [time] renders. 0 when the radio never stamped
* one; read [displayTime] instead of this field so that case falls back to [receivedTime].
* one (the packet carried no arrival time, or an old-firmware 0); read [displayTime] instead of this field so that
* case falls back to [receivedTime].
*/
val meshTime: Long = 0L,
val read: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,17 @@ fun MeshPacket.isLora(): Boolean = transport_mechanism == MeshPacket.TransportMe
transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT2 ||
transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT3

/**
* Arrival time in epoch seconds, or null when the radio had no clock at reception.
*
* Firmware that gained explicit presence omits the field; older firmware still sends 0 for the same state. Both mean
* unknown — a 1970 arrival time is never a genuine reading.
*/
fun MeshPacket.rxTimeOrNull(): Int? = rx_time?.takeIf { it != 0 }

/** Returns true if this packet is a direct LoRa signal (not MQTT, and hop count matches). */
fun MeshPacket.isDirectSignal(): Boolean = rx_time > 0 && hop_start == hop_limit && via_mqtt != true && isLora()
fun MeshPacket.isDirectSignal(): Boolean =
rxTimeOrNull() != null && hop_start == hop_limit && via_mqtt != true && isLora()

/** Returns true if this telemetry packet contains valid, plot-able environment metrics. */
fun Telemetry.hasValidEnvironmentMetrics(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
return DataPacket(
from = nodeIdLookup.toNodeID(packet.from),
to = nodeIdLookup.toNodeID(packet.to),
time = packet.rx_time * 1000L,
time = (packet.rxTimeOrNull() ?: 0) * 1000L,
id = packet.id,
dataType = decoded.portnum.value,
bytes = decoded.payload.toByteArray().toByteString(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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.model.util

import org.meshtastic.proto.MeshPacket
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

class RxTimeExtensionsTest {

private fun loraPacket(rxTime: Int?) = MeshPacket(
rx_time = rxTime,
hop_start = 3,
hop_limit = 3,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)

@Test
fun `rxTimeOrNull returns the stamped time`() {
assertEquals(1_700_000_000, loraPacket(1_700_000_000).rxTimeOrNull())
}

@Test
fun `rxTimeOrNull treats an absent field as unknown`() {
assertNull(loraPacket(null).rxTimeOrNull())
}

@Test
fun `rxTimeOrNull treats an old-firmware zero as unknown`() {
assertNull(loraPacket(0).rxTimeOrNull())
}

@Test
fun `isDirectSignal requires a known arrival time`() {
assertTrue(loraPacket(1_700_000_000).isDirectSignal())
assertFalse(loraPacket(null).isDirectSignal())
assertFalse(loraPacket(0).isDirectSignal())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.GeoConstants
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
Expand Down Expand Up @@ -452,7 +453,7 @@ open class MetricsViewModel(
uri = uri,
header = "\"date\",\"time\",\"rssi\",\"snr\"\n",
rows = data,
epochSeconds = { it.rx_time.toLong() },
epochSeconds = { (it.rxTimeOrNull() ?: 0).toLong() },
) { p ->
// An absent rssi exports as an empty field, matching the other optional metrics above.
"\"${p.rx_rssi ?: ""}\",\"${p.rx_snr}\""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.busy_noise_floor
import org.meshtastic.core.resources.clear
Expand Down Expand Up @@ -136,7 +137,7 @@ internal sealed interface SignalLogEntry {
}

data class PacketEntry(val meshPacket: MeshPacket, val index: Int) : SignalLogEntry {
override val timeSeconds: Int = meshPacket.rx_time
override val timeSeconds: Int = meshPacket.rxTimeOrNull() ?: 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// MeshPacket.id repeats: it is unique only per originating node, and retransmissions are stored per reception.
// The source-list index disambiguates, as it does for local stats.
Expand All @@ -152,7 +153,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, m
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val threshold = timeFrame.timeThreshold()
val signalData = state.signalMetrics.filter { it.rx_time.toLong() >= threshold }
val signalData = state.signalMetrics.filter { (it.rxTimeOrNull() ?: 0).toLong() >= threshold }
val localStatsData = state.localStats.filter { it.time.toLong() >= threshold && it.local_stats != null }
val data = remember(signalData, localStatsData) { buildSignalLog(signalData, localStatsData) }
val hasNoiseFloor = remember(localStatsData) { localStatsData.any { it.local_stats?.noise_floor != 0 } }
Expand Down Expand Up @@ -353,11 +354,13 @@ private fun SignalMetricsChart(
lineModel { series(x = busyFloorData.map { it.time }, y = busyFloorData.map { BUSY_FLOOR_DBM }) }
}
if (rssiData.isNotEmpty()) {
lineModel { series(x = rssiData.map { it.rx_time }, y = rssiData.mapNotNull { it.rx_rssi }) }
lineModel {
series(x = rssiData.map { it.rxTimeOrNull() ?: 0 }, y = rssiData.mapNotNull { it.rx_rssi })
}
}
if (snrData.isNotEmpty()) {
/* Use a separate lineModel call to associate SNR with the right axis. */
lineModel { series(x = snrData.map { it.rx_time }, y = snrData.map { it.rx_snr }) }
lineModel { series(x = snrData.map { it.rxTimeOrNull() ?: 0 }, y = snrData.map { it.rx_snr }) }
}
}
}
Expand Down Expand Up @@ -553,7 +556,7 @@ private fun LocalStatsCard(telemetry: Telemetry, isSelected: Boolean, onClick: (

@Composable
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
val time = (meshPacket.rxTimeOrNull() ?: 0).toLong() * MS_PER_SEC
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Data */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.proto.Config
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
Expand Down Expand Up @@ -79,7 +80,8 @@ data class MetricsState(
fun oldestTimestampSeconds(): Long? {
val telemetryTimes =
(deviceMetrics + localStats + powerMetrics + hostMetrics + airQualityMetrics).map { it.time.toLong() }
val signalTimes = signalMetrics.map { it.rx_time.toLong() }
// Unstamped packets carry no timestamp to compare — dropping them beats letting the epoch win the min.
val signalTimes = signalMetrics.mapNotNull { it.rxTimeOrNull()?.toLong() }
val logTimes =
(tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map {
it.received_date / 1000L
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ mqttastic = "0.8.0"
jmdns = "3.6.3"
qrcode-kotlin = "4.5.0"
takpacket-sdk = "0.8.1"
meshtastic-protobufs = "2.7.26.138-g26db1b5-SNAPSHOT"
meshtastic-protobufs = "2.7.26.140-g6ceceae-SNAPSHOT"

# Gradle Plugins
develocity = "4.5.0"
Expand Down
Loading