Skip to content

Commit 5ac26be

Browse files
RCGV1jamesarichclaude
authored
feat(node): add local stats noise floor metrics (#5782)
Co-authored-by: James Rich <james.a.rich@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1d0dc8b commit 5ac26be

25 files changed

Lines changed: 667 additions & 45 deletions

File tree

.skills/compose-ui/strings-index.txt

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,25 @@ open class MeshLogRepositoryImpl(
174174
dbManager.currentDb.value.meshLogDao().deleteLogs(logId, portNum)
175175
}
176176

177+
/** Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry types. */
178+
override suspend fun deleteLocalStatsLogs(nodeNum: Int) = withContext(dispatchers.io) {
179+
val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum
180+
val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum
181+
val dao = dbManager.currentDb.value.meshLogDao()
182+
val localStatsLogs =
183+
dao.getLogsFrom(logId, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE)
184+
.firstOrNull()
185+
.orEmpty()
186+
.map { it.asExternalModel() }
187+
.filter { parseTelemetryLog(it)?.local_stats != null }
188+
189+
val localStatsLogIds = localStatsLogs.map { it.uuid }
190+
// Chunk to stay under SQLite's bind-variable limit; re-fetch DAO per chunk if the active DB switches.
191+
for (chunk in localStatsLogIds.chunked(DELETE_CHUNK_SIZE)) {
192+
dbManager.currentDb.value.meshLogDao().deleteLogsByUuid(chunk)
193+
}
194+
}
195+
177196
/** Prunes the log database based on the configured [retentionDays]. */
178197
@Suppress("MagicNumber")
179198
override suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) {
@@ -183,5 +202,8 @@ open class MeshLogRepositoryImpl(
183202

184203
companion object {
185204
private const val MILLIS_PER_SEC = 1000L
205+
206+
/** Max UUIDs per DELETE IN-clause; keeps us under SQLite's bind-variable limit. */
207+
private const val DELETE_CHUNK_SIZE = 500
186208
}
187209
}

core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import org.meshtastic.core.model.MeshLog
3232
import org.meshtastic.core.testing.FakeDatabaseProvider
3333
import org.meshtastic.core.testing.FakeMeshLogPrefs
3434
import org.meshtastic.proto.Data
35+
import org.meshtastic.proto.DeviceMetrics
3536
import org.meshtastic.proto.EnvironmentMetrics
3637
import org.meshtastic.proto.FromRadio
38+
import org.meshtastic.proto.LocalStats
3739
import org.meshtastic.proto.MeshPacket
3840
import org.meshtastic.proto.PortNum
3941
import org.meshtastic.proto.Telemetry
@@ -144,4 +146,73 @@ abstract class CommonMeshLogRepositoryTest {
144146
val logs = repository.getAllLogsUnbounded().first()
145147
assertTrue(logs.isEmpty())
146148
}
149+
150+
@Test
151+
fun `deleteLocalStatsLogs deletes only local stats telemetry`() = runTest(testDispatcher) {
152+
val nodeNum = 1234
153+
val localStatsLog =
154+
telemetryLog(
155+
uuid = "local-stats",
156+
nodeNum = nodeNum,
157+
telemetry = Telemetry(local_stats = LocalStats(noise_floor = -112)),
158+
receivedDate = nowMillis + 3,
159+
)
160+
val deviceLog =
161+
telemetryLog(
162+
uuid = "device",
163+
nodeNum = nodeNum,
164+
telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 80)),
165+
receivedDate = nowMillis + 2,
166+
)
167+
val environmentLog =
168+
telemetryLog(
169+
uuid = "environment",
170+
nodeNum = nodeNum,
171+
telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 21f)),
172+
receivedDate = nowMillis + 1,
173+
)
174+
val localStatsRequestLog =
175+
telemetryLog(
176+
uuid = "local-stats-request",
177+
nodeNum = nodeNum,
178+
telemetry = Telemetry(local_stats = LocalStats()),
179+
receivedDate = nowMillis,
180+
wantResponse = true,
181+
)
182+
183+
listOf(localStatsLog, deviceLog, environmentLog, localStatsRequestLog).forEach { repository.insert(it) }
184+
185+
repository.deleteLocalStatsLogs(nodeNum)
186+
187+
val remainingIds = repository.getAllLogsUnbounded().first().map { it.uuid }.toSet()
188+
assertEquals(setOf("device", "environment", "local-stats-request"), remainingIds)
189+
}
190+
191+
private fun telemetryLog(
192+
uuid: String,
193+
nodeNum: Int,
194+
telemetry: Telemetry,
195+
receivedDate: Long,
196+
wantResponse: Boolean = false,
197+
) = MeshLog(
198+
uuid = uuid,
199+
message_type = "telemetry",
200+
received_date = receivedDate,
201+
raw_message = "",
202+
fromNum = nodeNum,
203+
portNum = PortNum.TELEMETRY_APP.value,
204+
fromRadio =
205+
FromRadio(
206+
packet =
207+
MeshPacket(
208+
from = nodeNum,
209+
decoded =
210+
Data(
211+
payload = telemetry.encode().toByteString(),
212+
portnum = PortNum.TELEMETRY_APP,
213+
want_response = wantResponse,
214+
),
215+
),
216+
),
217+
)
147218
}

core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ interface MeshLogDao {
5353
@Query("DELETE FROM log WHERE uuid = :uuid")
5454
suspend fun deleteLog(uuid: String)
5555

56+
@Query("DELETE FROM log WHERE uuid IN (:uuids)")
57+
suspend fun deleteLogsByUuid(uuids: List<String>)
58+
5659
@Query("DELETE FROM log WHERE from_num = :fromNum AND port_num = :portNum")
5760
suspend fun deleteLogs(fromNum: Int, portNum: Int)
5861

core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ interface MeshLogRepository {
7373
/** Deletes all logs associated with a specific [nodeNum] and [portNum]. */
7474
suspend fun deleteLogs(nodeNum: Int, portNum: Int)
7575

76+
/** Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry logs. */
77+
suspend fun deleteLocalStatsLogs(nodeNum: Int)
78+
7679
/** Prunes the log database based on the configured [retentionDays]. */
7780
suspend fun deleteLogsOlderThan(retentionDays: Int)
7881

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
<string name="bold_heading">Bold Heading</string>
138138
<string name="bottom_nav_settings">Settings</string>
139139
<string name="broadcast_interval">Broadcast Interval</string>
140+
<string name="busy_noise_floor">Busy floor</string>
140141
<string name="button_gpio">Button GPIO</string>
141142
<string name="buzzer_gpio">Buzzer GPIO</string>
142143
<string name="calculating">Calculating…</string>
@@ -1008,6 +1009,9 @@
10081009
<string name="nodes_empty_searching_hint">Nearby nodes will appear here as they're discovered.</string>
10091010
<string name="nodes_empty_searching_title">Searching for nodes</string>
10101011
<string name="nodes_queued_for_deletion">%1$d nodes queued for deletion:</string>
1012+
<string name="noise_floor">Noise Floor</string>
1013+
<string name="noise_floor_definition">The background RF noise measured by the radio receiver. Lower values usually indicate a quieter receiver environment; -85 dBm is a busy reference point, not a hard failure threshold.</string>
1014+
<string name="noise_floor_no_reading">Noise Floor: No reading</string>
10111015
<string name="none">None (disable)</string>
10121016
<string name="none_quality">None</string>
10131017
<string name="not_connected">Not connected</string>
@@ -1193,6 +1197,7 @@
11931197
<string name="request_device_metrics">Device Metrics</string>
11941198
<string name="request_environment_metrics">Environment Metrics</string>
11951199
<string name="request_host_metrics">Host Metrics</string>
1200+
<string name="request_local_stats">Local Stats</string>
11961201
<string name="request_metadata">Metadata</string>
11971202
<string name="request_pax_metrics">Pax Metrics</string>
11981203
<string name="request_power_metrics">Power Metrics</string>
@@ -1244,6 +1249,7 @@
12441249
<string name="routing_error_timeout">Timeout</string>
12451250
<string name="routing_error_too_large">Packet too large</string>
12461251
<string name="rssi">RSSI</string>
1252+
<string name="rssi_definition">Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection.</string>
12471253
<string name="rsyslog_server">rsyslog server</string>
12481254
<string name="sample_message" translatable="false">hey I found the cache, it is over here next to the big tiger. I'm kinda scared.</string>
12491255
<string name="sats">Sats</string>
@@ -1338,6 +1344,7 @@
13381344
<string name="slot">Slot</string>
13391345
<string name="smart_position">Smart Position</string>
13401346
<string name="snr">SNR</string>
1347+
<string name="snr_definition">Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission.</string>
13411348
<string name="soil_moisture">Soil Moist</string>
13421349
<string name="soil_temperature">Soil Temp</string>
13431350
<string name="speed">Speed</string>
@@ -1576,4 +1583,3 @@
15761583
<string name="zh_CN" translatable="false">简体中文</string>
15771584
<string name="zh_TW" translatable="false">繁體中文</string>
15781585
</resources>
1579-

core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,14 @@ class FakeMeshLogRepository :
4141
var deleteAllCalled = false
4242
private set
4343

44+
var lastDeletedLocalStatsNodeNum: Int? = null
45+
private set
46+
4447
override fun reset() {
4548
super.reset()
4649
lastDeletedOlderThan = null
4750
deleteAllCalled = false
51+
lastDeletedLocalStatsNodeNum = null
4852
}
4953

5054
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> = logsFlow.map { it.take(maxItem) }
@@ -82,11 +86,26 @@ class FakeMeshLogRepository :
8286
logsFlow.value = logsFlow.value.filterNot { it.fromNum == nodeNum && it.portNum == portNum }
8387
}
8488

89+
override suspend fun deleteLocalStatsLogs(nodeNum: Int) {
90+
lastDeletedLocalStatsNodeNum = nodeNum
91+
logsFlow.value =
92+
logsFlow.value.filterNot { log ->
93+
log.fromNum == nodeNum && log.portNum == PortNum.TELEMETRY_APP.value && log.hasLocalStatsTelemetry()
94+
}
95+
}
96+
8597
override suspend fun deleteLogsOlderThan(retentionDays: Int) {
8698
lastDeletedOlderThan = retentionDays
8799
}
88100

89101
fun setLogs(logs: List<MeshLog>) {
90102
logsFlow.value = logs
91103
}
104+
105+
private fun MeshLog.hasLocalStatsTelemetry(): Boolean = runCatching {
106+
val decoded = fromRadio.packet?.decoded ?: return false
107+
if (decoded.want_response == true) return false
108+
Telemetry.ADAPTER.decode(decoded.payload).local_stats != null
109+
}
110+
.getOrDefault(false)
92111
}

docs/en/user/node-metrics.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: Node Metrics
33
parent: User Guide
44
nav_order: 5
5-
last_updated: 2026-06-11
5+
last_updated: 2026-06-16
66
description: Telemetry dashboards for each mesh node — device health, environment sensors, air quality, signal quality, power, traceroute, and position history.
77
aliases:
88
- metrics
@@ -87,6 +87,7 @@ Radio signal quality information:
8787
|--------|-------------|
8888
| SNR | Signal-to-Noise Ratio (higher is better) |
8989
| RSSI | Received Signal Strength Indicator (closer to 0 is better) |
90+
| Noise Floor | Local background RF noise in dBm (more negative is quieter) |
9091
| Hop Count | Number of mesh hops for last message |
9192

9293
### Signal Quality Reference
@@ -98,6 +99,8 @@ Radio signal quality information:
9899
| -10 to 0 dB | Fair |
99100
| < -10 dB | Poor |
100101

102+
Local Stats from your connected radio are also shown in Signal Quality when available. These logs include noise floor, traffic counters, relay counters, online node counts, and radio uptime. The noise floor chart uses a dashed reference line at -85 dBm to help identify a busy RF environment. Use **Request** to ask the connected radio for a fresh Local Stats telemetry report, **Clear** to remove Local Stats logs for that node, and **Save** to export the visible Local Stats history as CSV.
103+
101104
## Power Metrics
102105

103106
Power management telemetry (requires INA sensor or compatible hardware):
@@ -159,4 +162,3 @@ The position tab shows location data for nodes that share GPS:
159162
- [Units & Locale](units-and-locale) — temperature, distance, and speed display formats
160163

161164
---
162-

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,28 @@ fun TelemetricActionsSectionEmptyPreview() {
136136
}
137137
}
138138

139+
@PreviewLightDark
140+
@Suppress("PreviewPublic")
141+
@Composable
142+
fun TelemetricActionsSectionLocalPreview() {
143+
val node = previewData.mickeyMouse
144+
AppTheme {
145+
Surface {
146+
TelemetricActionsSection(
147+
node = node,
148+
ourNode = node,
149+
availableLogs = emptySet(),
150+
lastTracerouteTime = null,
151+
lastRequestNeighborsTime = null,
152+
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
153+
isFahrenheit = false,
154+
onAction = {},
155+
isLocal = true,
156+
)
157+
}
158+
}
159+
}
160+
139161
// ---------------------------------------------------------------------------
140162
// PositionInlineContent preview
141163
// ---------------------------------------------------------------------------

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ private fun rememberTelemetricFeatures(
159159
icon = LogsType.SIGNAL.icon,
160160
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
161161
logsType = LogsType.SIGNAL,
162-
isVisible = { !isLocal },
163162
),
164163
TelemetricFeature(
165164
titleRes = LogsType.DEVICE.titleRes,

0 commit comments

Comments
 (0)