diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 0015b9b420..305a933992 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -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 @@ -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()) // 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 @@ -317,7 +313,10 @@ class MeshMessageProcessorImpl( else -> packet.hop_start - packet.hop_limit } return node.copy( - lastHeard = clampTimestampToNow(packet.rx_time), + // Packets reach here already stamped, so the fallback is unreachable by design and must stay that way: + // a packet that just arrived SHOULD refresh lastHeard, using the phone's clock when the radio had no + // time source. The fallback only guards a future caller that skips normalization from writing 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, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt index 32f0c552bc..1d3948c9e6 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt @@ -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 @@ -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 { emitted -> (emitted.rx_time ?: 0) > 0 }) + } + } + // ---------- handleReceivedMeshPacket: node updates ---------- @Test diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt index 253eb3a67d..7b98e19377 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt @@ -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, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt index 0c257291d2..586902bd82 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -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 { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt index b9527e99c9..24833b0fa5 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt @@ -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(), diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/RxTimeExtensionsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/RxTimeExtensionsTest.kt new file mode 100644 index 0000000000..c6a3834947 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/RxTimeExtensionsTest.kt @@ -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 . + */ +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()) + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 27d0853597..1031f8167d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -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 @@ -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}\"" diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 482c1cd144..8a317745e3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -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 @@ -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 // 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. @@ -152,7 +153,13 @@ 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 } + // An unstamped packet has no place on a time axis. Dropping it here is the invariant every downstream + // `rxTimeOrNull() ?: 0` in this file relies on, so none of them can render or plot 1970. + val signalData = + state.signalMetrics.filter { packet -> + val rxTime = packet.rxTimeOrNull() ?: return@filter false + rxTime.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 } } @@ -353,11 +360,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 }) } } } } @@ -553,7 +562,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 */ diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt index c53b6c8270..d2754d7291 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -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 @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f107730a69..a03ba256e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"