Skip to content

Commit d714b22

Browse files
jamesarichclaude
andauthored
chore(deps): bump protobufs to 2.7.26.140 (optional rx_time) (#6510)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7080554 commit d714b22

10 files changed

Lines changed: 118 additions & 19 deletions

File tree

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import org.meshtastic.core.common.util.safeCatching
3737
import org.meshtastic.core.model.MeshLog
3838
import org.meshtastic.core.model.Node
3939
import org.meshtastic.core.model.util.isLora
40+
import org.meshtastic.core.model.util.rxTimeOrNull
4041
import org.meshtastic.core.model.util.toOneLineString
4142
import org.meshtastic.core.model.util.toPIIString
4243
import org.meshtastic.core.repository.FromRadioPacketHandler
@@ -182,13 +183,8 @@ class MeshMessageProcessorImpl(
182183

183184
/** Test seam for packet-only fixtures with explicit transport authority. */
184185
internal suspend fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?, session: RadioSessionContext) {
185-
val rxTime =
186-
if (packet.rx_time == 0) {
187-
nowSeconds.toInt()
188-
} else {
189-
packet.rx_time
190-
}
191-
val preparedPacket = packet.copy(rx_time = rxTime)
186+
// Single normalization point: every consumer downstream of this copy sees a stamped packet.
187+
val preparedPacket = packet.copy(rx_time = packet.rxTimeOrNull() ?: nowSeconds.toInt())
192188

193189
// Require myNodeNum to be known before storing: processReceivedMeshPacket only keys a local packet under
194190
// NODE_NUM_LOCAL when packet.from == myNodeNum. If myNodeNum is still null (early in a (re)connect, before
@@ -317,7 +313,10 @@ class MeshMessageProcessorImpl(
317313
else -> packet.hop_start - packet.hop_limit
318314
}
319315
return node.copy(
320-
lastHeard = clampTimestampToNow(packet.rx_time),
316+
// Packets reach here already stamped, so the fallback is unreachable by design and must stay that way:
317+
// a packet that just arrived SHOULD refresh lastHeard, using the phone's clock when the radio had no
318+
// time source. The fallback only guards a future caller that skips normalization from writing the epoch.
319+
lastHeard = packet.rxTimeOrNull()?.let(::clampTimestampToNow) ?: node.lastHeard,
321320
viaMqtt = viaMqtt,
322321
lastTransport = packet.transport_mechanism.value,
323322
snr = if (updateRadioMetrics) packet.rx_snr else node.snr,

core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import dev.mokkery.answering.throws
2323
import dev.mokkery.every
2424
import dev.mokkery.everySuspend
2525
import dev.mokkery.matcher.any
26+
import dev.mokkery.matcher.matches
2627
import dev.mokkery.mock
2728
import dev.mokkery.verify
2829
import dev.mokkery.verify.VerifyMode
@@ -562,6 +563,27 @@ class MeshMessageProcessorImplTest {
562563
verifySuspend { serviceRepository.emitMeshPacket(any()) }
563564
}
564565

566+
@Test
567+
fun `packets with absent rx_time get current time`() = runTest(testDispatcher) {
568+
processor = createProcessor(backgroundScope)
569+
isNodeDbReady.value = true
570+
571+
val packet =
572+
MeshPacket(
573+
id = 3,
574+
from = myNodeNum,
575+
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
576+
rx_time = null, // radio had no clock at reception
577+
)
578+
579+
processor.handleReceivedMeshPacket(packet, myNodeNum)
580+
advanceUntilIdle()
581+
582+
verifySuspend {
583+
serviceRepository.emitMeshPacket(matches<MeshPacket> { emitted -> (emitted.rx_time ?: 0) > 0 })
584+
}
585+
}
586+
565587
// ---------- handleReceivedMeshPacket: node updates ----------
566588

567589
@Test

core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ data class Message(
151151
val time: String,
152152
/**
153153
* Mesh time in epoch millis (the packet's `rx_time`) — the instant [time] renders. 0 when the radio never stamped
154-
* one; read [displayTime] instead of this field so that case falls back to [receivedTime].
154+
* one (the packet carried no arrival time, or an old-firmware 0); read [displayTime] instead of this field so that
155+
* case falls back to [receivedTime].
155156
*/
156157
val meshTime: Long = 0L,
157158
val read: Boolean,

core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,17 @@ fun MeshPacket.isLora(): Boolean = transport_mechanism == MeshPacket.TransportMe
100100
transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT2 ||
101101
transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT3
102102

103+
/**
104+
* Arrival time in epoch seconds, or null when the radio had no clock at reception.
105+
*
106+
* Firmware that gained explicit presence omits the field; older firmware still sends 0 for the same state. Both mean
107+
* unknown — a 1970 arrival time is never a genuine reading.
108+
*/
109+
fun MeshPacket.rxTimeOrNull(): Int? = rx_time?.takeIf { it != 0 }
110+
103111
/** Returns true if this packet is a direct LoRa signal (not MQTT, and hop count matches). */
104-
fun MeshPacket.isDirectSignal(): Boolean = rx_time > 0 && hop_start == hop_limit && via_mqtt != true && isLora()
112+
fun MeshPacket.isDirectSignal(): Boolean =
113+
rxTimeOrNull() != null && hop_start == hop_limit && via_mqtt != true && isLora()
105114

106115
/** Returns true if this telemetry packet contains valid, plot-able environment metrics. */
107116
fun Telemetry.hasValidEnvironmentMetrics(): Boolean {

core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
3636
return DataPacket(
3737
from = nodeIdLookup.toNodeID(packet.from),
3838
to = nodeIdLookup.toNodeID(packet.to),
39-
time = packet.rx_time * 1000L,
39+
time = (packet.rxTimeOrNull() ?: 0) * 1000L,
4040
id = packet.id,
4141
dataType = decoded.portnum.value,
4242
bytes = decoded.payload.toByteArray().toByteString(),
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.core.model.util
18+
19+
import org.meshtastic.proto.MeshPacket
20+
import kotlin.test.Test
21+
import kotlin.test.assertEquals
22+
import kotlin.test.assertFalse
23+
import kotlin.test.assertNull
24+
import kotlin.test.assertTrue
25+
26+
class RxTimeExtensionsTest {
27+
28+
private fun loraPacket(rxTime: Int?) = MeshPacket(
29+
rx_time = rxTime,
30+
hop_start = 3,
31+
hop_limit = 3,
32+
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
33+
)
34+
35+
@Test
36+
fun `rxTimeOrNull returns the stamped time`() {
37+
assertEquals(1_700_000_000, loraPacket(1_700_000_000).rxTimeOrNull())
38+
}
39+
40+
@Test
41+
fun `rxTimeOrNull treats an absent field as unknown`() {
42+
assertNull(loraPacket(null).rxTimeOrNull())
43+
}
44+
45+
@Test
46+
fun `rxTimeOrNull treats an old-firmware zero as unknown`() {
47+
assertNull(loraPacket(0).rxTimeOrNull())
48+
}
49+
50+
@Test
51+
fun `isDirectSignal requires a known arrival time`() {
52+
assertTrue(loraPacket(1_700_000_000).isDirectSignal())
53+
assertFalse(loraPacket(null).isDirectSignal())
54+
assertFalse(loraPacket(0).isDirectSignal())
55+
}
56+
}

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import org.meshtastic.core.model.TracerouteOverlay
4949
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
5050
import org.meshtastic.core.model.util.GeoConstants
5151
import org.meshtastic.core.model.util.UnitConversions
52+
import org.meshtastic.core.model.util.rxTimeOrNull
5253
import org.meshtastic.core.repository.FileService
5354
import org.meshtastic.core.repository.MeshLogRepository
5455
import org.meshtastic.core.repository.NodeRepository
@@ -452,7 +453,7 @@ open class MetricsViewModel(
452453
uri = uri,
453454
header = "\"date\",\"time\",\"rssi\",\"snr\"\n",
454455
rows = data,
455-
epochSeconds = { it.rx_time.toLong() },
456+
epochSeconds = { (it.rxTimeOrNull() ?: 0).toLong() },
456457
) { p ->
457458
// An absent rssi exports as an empty field, matching the other optional metrics above.
458459
"\"${p.rx_rssi ?: ""}\",\"${p.rx_snr}\""

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import org.meshtastic.core.common.util.MetricFormatter
5858
import org.meshtastic.core.model.TelemetryType
5959
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
6060
import org.meshtastic.core.model.util.formatUptime
61+
import org.meshtastic.core.model.util.rxTimeOrNull
6162
import org.meshtastic.core.resources.Res
6263
import org.meshtastic.core.resources.busy_noise_floor
6364
import org.meshtastic.core.resources.clear
@@ -136,7 +137,7 @@ internal sealed interface SignalLogEntry {
136137
}
137138

138139
data class PacketEntry(val meshPacket: MeshPacket, val index: Int) : SignalLogEntry {
139-
override val timeSeconds: Int = meshPacket.rx_time
140+
override val timeSeconds: Int = meshPacket.rxTimeOrNull() ?: 0
140141

141142
// MeshPacket.id repeats: it is unique only per originating node, and retransmissions are stored per reception.
142143
// The source-list index disambiguates, as it does for local stats.
@@ -152,7 +153,13 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, m
152153
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
153154
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
154155
val threshold = timeFrame.timeThreshold()
155-
val signalData = state.signalMetrics.filter { it.rx_time.toLong() >= threshold }
156+
// An unstamped packet has no place on a time axis. Dropping it here is the invariant every downstream
157+
// `rxTimeOrNull() ?: 0` in this file relies on, so none of them can render or plot 1970.
158+
val signalData =
159+
state.signalMetrics.filter { packet ->
160+
val rxTime = packet.rxTimeOrNull() ?: return@filter false
161+
rxTime.toLong() >= threshold
162+
}
156163
val localStatsData = state.localStats.filter { it.time.toLong() >= threshold && it.local_stats != null }
157164
val data = remember(signalData, localStatsData) { buildSignalLog(signalData, localStatsData) }
158165
val hasNoiseFloor = remember(localStatsData) { localStatsData.any { it.local_stats?.noise_floor != 0 } }
@@ -353,11 +360,13 @@ private fun SignalMetricsChart(
353360
lineModel { series(x = busyFloorData.map { it.time }, y = busyFloorData.map { BUSY_FLOOR_DBM }) }
354361
}
355362
if (rssiData.isNotEmpty()) {
356-
lineModel { series(x = rssiData.map { it.rx_time }, y = rssiData.mapNotNull { it.rx_rssi }) }
363+
lineModel {
364+
series(x = rssiData.map { it.rxTimeOrNull() ?: 0 }, y = rssiData.mapNotNull { it.rx_rssi })
365+
}
357366
}
358367
if (snrData.isNotEmpty()) {
359368
/* Use a separate lineModel call to associate SNR with the right axis. */
360-
lineModel { series(x = snrData.map { it.rx_time }, y = snrData.map { it.rx_snr }) }
369+
lineModel { series(x = snrData.map { it.rxTimeOrNull() ?: 0 }, y = snrData.map { it.rx_snr }) }
361370
}
362371
}
363372
}
@@ -553,7 +562,7 @@ private fun LocalStatsCard(telemetry: Telemetry, isSelected: Boolean, onClick: (
553562

554563
@Composable
555564
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
556-
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
565+
val time = (meshPacket.rxTimeOrNull() ?: 0).toLong() * MS_PER_SEC
557566
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
558567
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
559568
/* Data */

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import org.meshtastic.core.model.DeviceHardware
2121
import org.meshtastic.core.model.DeviceLink
2222
import org.meshtastic.core.model.MeshLog
2323
import org.meshtastic.core.model.Node
24+
import org.meshtastic.core.model.util.rxTimeOrNull
2425
import org.meshtastic.proto.Config
2526
import org.meshtastic.proto.FirmwareEdition
2627
import org.meshtastic.proto.MeshPacket
@@ -79,7 +80,8 @@ data class MetricsState(
7980
fun oldestTimestampSeconds(): Long? {
8081
val telemetryTimes =
8182
(deviceMetrics + localStats + powerMetrics + hostMetrics + airQualityMetrics).map { it.time.toLong() }
82-
val signalTimes = signalMetrics.map { it.rx_time.toLong() }
83+
// Unstamped packets carry no timestamp to compare — dropping them beats letting the epoch win the min.
84+
val signalTimes = signalMetrics.mapNotNull { it.rxTimeOrNull()?.toLong() }
8385
val logTimes =
8486
(tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map {
8587
it.received_date / 1000L

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ mqttastic = "0.8.0"
9999
jmdns = "3.6.3"
100100
qrcode-kotlin = "4.5.0"
101101
takpacket-sdk = "0.8.1"
102-
meshtastic-protobufs = "2.7.26.138-g26db1b5-SNAPSHOT"
102+
meshtastic-protobufs = "2.7.26.140-g6ceceae-SNAPSHOT"
103103

104104
# Gradle Plugins
105105
develocity = "4.5.0"

0 commit comments

Comments
 (0)