From a5d0008555e4f70cf3145ff8fe452b5e652fe137 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:07:24 +0200 Subject: [PATCH] feat: split device and power-sensor telemetry charts (#643) (#656) * feat: split device and power-sensor telemetry charts (#643) Add telemetry_type TEXT discriminator column across the full stack so device_metrics rows no longer mix with power_metrics in the same chart. Python and Ruby ingestors detect the protobuf subtype at write time; classifySnapshot() provides field-presence fallback for legacy rows. 'Power metrics' chart split into 'Device health' and 'Power sensor'. Co-Authored-By: Claude Sonnet 4.6 * fix: skip typeFilter for aggregated telemetry; add air_quality coverage - renderTelemetryChart now skips spec.typeFilter when chartOptions.isAggregated is true, preventing mixed-bucket aggregated snapshots from losing series data - renderTelemetryCharts detects the aggregated vs per-packet path and sets isAggregated accordingly; typeFilter still applies for per-packet history - JS tests: extract makeAggregatedNode/makeHistoryNode helpers to eliminate fixture duplication; add aggregated-mixed-bucket regression test; move type-separation tests onto the history path where filtering actually applies - Ruby + Python: add air_quality_metrics telemetry_type tests for coverage Co-Authored-By: Claude Sonnet 4.6 * refactor: reduce test duplication flagged by Sonar Hoist CHART_NOW_MS/CHART_NOW_SECONDS constants to eliminate 14 repeated setup lines across renderTelemetryCharts tests. Extract expect_stored_telemetry_type helper in app_spec to replace the four identical with_db/SELECT/expect blocks in telemetry_type inference tests. Co-Authored-By: Claude Sonnet 4.6 * web: address review comments --------- Co-authored-by: Claude Sonnet 4.6 --- data/mesh_ingestor/CONTRACTS.md | 1 + data/mesh_ingestor/handlers.py | 25 ++ .../20260328_add_telemetry_type.sql | 46 +++ data/telemetry.sql | 3 +- tests/test_mesh.py | 104 ++++++ .../application/data_processing.rb | 21 +- web/lib/potato_mesh/application/database.rb | 3 + web/lib/potato_mesh/application/queries.rb | 1 + .../assets/js/app/__tests__/node-page.test.js | 325 +++++++++++------- web/public/assets/js/app/node-page.js | 105 +++++- web/spec/app_spec.rb | 104 ++++++ 11 files changed, 606 insertions(+), 132 deletions(-) create mode 100644 data/migrations/20260328_add_telemetry_type.sql diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index c152bc4..aab3863 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -75,6 +75,7 @@ Single telemetry payload: - RF: `snr` (float|nil), `rssi` (int|nil) - Raw: `payload_b64` (string; may be empty string when unknown) - Metrics: many optional snake_case keys (`battery_level`, `voltage`, `temperature`, etc.) +- Subtype: `telemetry_type` (string|nil) — optional discriminator identifying which Meshtastic protobuf oneof was set; one of `"device"`, `"environment"`, `"power"`, or `"air_quality"`. Ingestors that detect the subtype SHOULD include this field; omit rather than send `null` when unknown. The web app infers the type from metric-field presence when absent, so old ingestors remain compatible. - Meta: `ingestor`, `lora_freq`, `modem_preset` #### `POST /api/neighbors` diff --git a/data/mesh_ingestor/handlers.py b/data/mesh_ingestor/handlers.py index e9fcd75..ef1de52 100644 --- a/data/mesh_ingestor/handlers.py +++ b/data/mesh_ingestor/handlers.py @@ -640,6 +640,29 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None: telemetry_time = _coerce_int(_first(telemetry_section, "time", default=None)) + _dm = telemetry_section.get("deviceMetrics") or telemetry_section.get( + "device_metrics" + ) + _em = telemetry_section.get("environmentMetrics") or telemetry_section.get( + "environment_metrics" + ) + _pm = telemetry_section.get("powerMetrics") or telemetry_section.get( + "power_metrics" + ) + _aq = telemetry_section.get("airQualityMetrics") or telemetry_section.get( + "air_quality_metrics" + ) + if isinstance(_dm, Mapping): + telemetry_type: str | None = "device" + elif isinstance(_em, Mapping): + telemetry_type = "environment" + elif isinstance(_pm, Mapping): + telemetry_type = "power" + elif isinstance(_aq, Mapping): + telemetry_type = "air_quality" + else: + telemetry_type = None + channel = _coerce_int(_first(decoded, "channel", default=None)) if channel is None: channel = _coerce_int(_first(packet, "channel", default=None)) @@ -992,6 +1015,8 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None: telemetry_payload["soil_moisture"] = soil_moisture if soil_temperature is not None: telemetry_payload["soil_temperature"] = soil_temperature + if telemetry_type is not None: + telemetry_payload["telemetry_type"] = telemetry_type _queue_post_json( "/api/telemetry", diff --git a/data/migrations/20260328_add_telemetry_type.sql b/data/migrations/20260328_add_telemetry_type.sql new file mode 100644 index 0000000..bb6eace --- /dev/null +++ b/data/migrations/20260328_add_telemetry_type.sql @@ -0,0 +1,46 @@ +-- Copyright © 2025-26 l5yth & contributors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Add telemetry subtype discriminator to enable per-chart type filtering. +-- Backfills existing rows using field-presence heuristics that mirror +-- classifySnapshot() in node-page.js, so historical data is classified +-- consistently regardless of whether the new ingestors are deployed yet. + +BEGIN; +ALTER TABLE telemetry ADD COLUMN telemetry_type TEXT; + +-- Device metrics: battery/channel fields are exclusive to device_metrics +UPDATE telemetry SET telemetry_type = 'device' + WHERE telemetry_type IS NULL + AND (battery_level IS NOT NULL OR channel_utilization IS NOT NULL + OR air_util_tx IS NOT NULL OR uptime_seconds IS NOT NULL); + +-- Power sensor: voltage/current without any device field. +-- Note: device_metrics also stores a `voltage` reading (~4.2 V for battery). +-- A device row that has voltage but lacks all four device-discriminator fields +-- (battery_level, channel_utilization, air_util_tx, uptime_seconds) would be +-- classified as 'power' here. In practice firmware always sends at least one +-- of those alongside voltage, so the ambiguity is negligible for historical data. +UPDATE telemetry SET telemetry_type = 'power' + WHERE telemetry_type IS NULL + AND (current IS NOT NULL OR voltage IS NOT NULL); + +-- Environment: temperature/humidity/pressure +UPDATE telemetry SET telemetry_type = 'environment' + WHERE telemetry_type IS NULL + AND (temperature IS NOT NULL OR relative_humidity IS NOT NULL + OR barometric_pressure IS NOT NULL OR iaq IS NOT NULL + OR gas_resistance IS NOT NULL); + +COMMIT; diff --git a/data/telemetry.sql b/data/telemetry.sql index 09275a4..d270e58 100644 --- a/data/telemetry.sql +++ b/data/telemetry.sql @@ -55,7 +55,8 @@ CREATE TABLE IF NOT EXISTS telemetry ( soil_moisture INTEGER, soil_temperature REAL, ingestor TEXT, - protocol TEXT NOT NULL DEFAULT 'meshtastic' + protocol TEXT NOT NULL DEFAULT 'meshtastic', + telemetry_type TEXT ); CREATE INDEX IF NOT EXISTS idx_telemetry_rx_time ON telemetry(rx_time); diff --git a/tests/test_mesh.py b/tests/test_mesh.py index f376a25..2495c4c 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -2342,6 +2342,7 @@ def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch): assert payload["lora_freq"] == 868 assert payload["modem_preset"] == "MediumFast" assert payload["ingestor"] == "!f00dbabe" + assert payload["telemetry_type"] == "device" def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatch): @@ -2421,6 +2422,109 @@ def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatc assert payload["soil_temperature"] == pytest.approx(18.9) assert payload["lora_freq"] == 868 assert payload["modem_preset"] == "MediumFast" + assert payload["telemetry_type"] == "environment" + + +def test_store_packet_dict_handles_power_telemetry(mesh_module, monkeypatch): + """Power-metrics packets are tagged telemetry_type='power'.""" + mesh = mesh_module + captured = [] + monkeypatch.setattr( + mesh, + "_queue_post_json", + lambda path, payload, *, priority: captured.append((path, payload, priority)), + ) + + packet = { + "id": 3_000_000_001, + "rxTime": 1_758_030_000, + "fromId": "!aabbccdd", + "toId": "^all", + "decoded": { + "portnum": "TELEMETRY_APP", + "telemetry": { + "time": 1_758_030_000, + "powerMetrics": { + "ch1Voltage": 5.02, + "ch1Current": 0.48, + }, + }, + }, + } + + mesh.store_packet_dict(packet) + + assert captured + _, payload, _ = captured[0] + assert payload["telemetry_type"] == "power" + + +def test_store_packet_dict_handles_air_quality_telemetry(mesh_module, monkeypatch): + """Air-quality-metrics packets are tagged telemetry_type='air_quality'.""" + mesh = mesh_module + captured = [] + monkeypatch.setattr( + mesh, + "_queue_post_json", + lambda path, payload, *, priority: captured.append((path, payload, priority)), + ) + + packet = { + "id": 3_000_000_003, + "rxTime": 1_758_032_000, + "fromId": "!aabbccdd", + "toId": "^all", + "decoded": { + "portnum": "TELEMETRY_APP", + "telemetry": { + "time": 1_758_032_000, + "airQualityMetrics": { + "pm10Standard": 4, + "pm25Standard": 8, + "iaq": 65, + }, + }, + }, + } + + mesh.store_packet_dict(packet) + + assert captured + _, payload, _ = captured[0] + assert payload["telemetry_type"] == "air_quality" + + +def test_store_packet_dict_telemetry_type_absent_for_unknown_subtype( + mesh_module, monkeypatch +): + """Packets with no recognised sub-object do not include telemetry_type in the payload.""" + mesh = mesh_module + captured = [] + monkeypatch.setattr( + mesh, + "_queue_post_json", + lambda path, payload, *, priority: captured.append((path, payload, priority)), + ) + + packet = { + "id": 3_000_000_002, + "rxTime": 1_758_031_000, + "fromId": "!aabbccdd", + "toId": "^all", + "decoded": { + "portnum": "TELEMETRY_APP", + "telemetry": { + "time": 1_758_031_000, + "someUnknownMetrics": {"foo": 1}, + }, + }, + } + + mesh.store_packet_dict(packet) + + assert captured + _, payload, _ = captured[0] + assert "telemetry_type" not in payload def test_store_packet_dict_throttles_host_telemetry(mesh_module, monkeypatch): diff --git a/web/lib/potato_mesh/application/data_processing.rb b/web/lib/potato_mesh/application/data_processing.rb index 1187934..4ed8755 100644 --- a/web/lib/potato_mesh/application/data_processing.rb +++ b/web/lib/potato_mesh/application/data_processing.rb @@ -1065,6 +1065,21 @@ module PotatoMesh device_metrics ||= normalize_json_object(telemetry_section["deviceMetrics"]) if telemetry_section&.key?("deviceMetrics") environment_metrics = normalize_json_object(payload["environment_metrics"] || payload["environmentMetrics"]) environment_metrics ||= normalize_json_object(telemetry_section["environmentMetrics"]) if telemetry_section&.key?("environmentMetrics") + power_metrics = normalize_json_object(payload["power_metrics"] || payload["powerMetrics"]) + power_metrics ||= normalize_json_object(telemetry_section["powerMetrics"]) if telemetry_section&.key?("powerMetrics") + air_quality_metrics = normalize_json_object(payload["air_quality_metrics"] || payload["airQualityMetrics"]) + air_quality_metrics ||= normalize_json_object(telemetry_section["airQualityMetrics"]) if telemetry_section&.key?("airQualityMetrics") + + telemetry_type = string_or_nil(payload["telemetry_type"]) + telemetry_type ||= if device_metrics&.any? + "device" + elsif environment_metrics&.any? + "environment" + elsif power_metrics&.any? + "power" + elsif air_quality_metrics&.any? + "air_quality" + end sources = { payload: payload, @@ -1390,6 +1405,7 @@ module PotatoMesh soil_temperature, ingestor, protocol, + telemetry_type, ] placeholders = Array.new(row.length, "?").join(",") @@ -1397,7 +1413,7 @@ module PotatoMesh with_busy_retry do db.execute <<~SQL, row INSERT INTO telemetry(id,node_id,node_num,from_id,to_id,rx_time,rx_iso,telemetry_time,channel,portnum,hop_limit,snr,rssi,bitfield,payload_b64, - battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,temperature,relative_humidity,barometric_pressure,gas_resistance,current,iaq,distance,lux,white_lux,ir_lux,uv_lux,wind_direction,wind_speed,weight,wind_gust,wind_lull,radiation,rainfall_1h,rainfall_24h,soil_moisture,soil_temperature,ingestor,protocol) + battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,temperature,relative_humidity,barometric_pressure,gas_resistance,current,iaq,distance,lux,white_lux,ir_lux,uv_lux,wind_direction,wind_speed,weight,wind_gust,wind_lull,radiation,rainfall_1h,rainfall_24h,soil_moisture,soil_temperature,ingestor,protocol,telemetry_type) VALUES (#{placeholders}) ON CONFLICT(id) DO UPDATE SET node_id=COALESCE(excluded.node_id,telemetry.node_id), @@ -1441,7 +1457,8 @@ module PotatoMesh soil_moisture=COALESCE(excluded.soil_moisture,telemetry.soil_moisture), soil_temperature=COALESCE(excluded.soil_temperature,telemetry.soil_temperature), ingestor=COALESCE(NULLIF(telemetry.ingestor,''), excluded.ingestor), - protocol=COALESCE(NULLIF(telemetry.protocol,'meshtastic'), excluded.protocol) + protocol=COALESCE(NULLIF(telemetry.protocol,'meshtastic'), excluded.protocol), + telemetry_type=COALESCE(excluded.telemetry_type,telemetry.telemetry_type) SQL end diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index d307a6a..eb3310c 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -204,6 +204,9 @@ module PotatoMesh unless telemetry_columns.include?("ingestor") db.execute("ALTER TABLE telemetry ADD COLUMN ingestor TEXT") end + unless telemetry_columns.include?("telemetry_type") + db.execute("ALTER TABLE telemetry ADD COLUMN telemetry_type TEXT") + end unless telemetry_columns.include?("protocol") db.execute("ALTER TABLE telemetry ADD COLUMN protocol TEXT NOT NULL DEFAULT 'meshtastic'") diff --git a/web/lib/potato_mesh/application/queries.rb b/web/lib/potato_mesh/application/queries.rb index ca6449b..d392835 100644 --- a/web/lib/potato_mesh/application/queries.rb +++ b/web/lib/potato_mesh/application/queries.rb @@ -668,6 +668,7 @@ module PotatoMesh r["rainfall_24h"] = coerce_float(r["rainfall_24h"]) r["soil_moisture"] = coerce_integer(r["soil_moisture"]) r["soil_temperature"] = coerce_float(r["soil_temperature"]) + r["telemetry_type"] = string_or_nil(r["telemetry_type"]) end rows.map { |row| compact_api_row(row) } ensure diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index 4d3f6a9..cd5da7f 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -47,6 +47,7 @@ const { categoriseNeighbors, renderNeighborGroups, renderSingleNodeTable, + classifySnapshot, renderTelemetryCharts, renderMessages, renderTraceroutes, @@ -60,6 +61,31 @@ const { fetchTracesForNode, } = __testUtils; +/** + * Builds a node fixture whose telemetry comes from the aggregated API path + * (node.rawSources.telemetry.snapshots). typeFilter is skipped for this path + * so all series data is visible regardless of telemetry_type. + * @param {object[]} snapshots + */ +// Shared time anchor used by most chart tests. One fixed value lets Sonar +// identify the setup as a constant rather than repeated literal. +const CHART_NOW_MS = Date.UTC(2025, 0, 8, 12, 0, 0); +const CHART_NOW_SECONDS = Math.floor(CHART_NOW_MS / 1000); + +function makeAggregatedNode(snapshots) { + return { rawSources: { telemetry: { snapshots } } }; +} + +/** + * Builds a node fixture whose telemetry comes from the per-packet history path + * (node.rawSources.telemetrySnapshots). typeFilter IS applied for this path, + * so device/power/environment rows are separated by chart. + * @param {object[]} snapshots + */ +function makeHistoryNode(snapshots) { + return { rawSources: { telemetrySnapshots: snapshots } }; +} + test('format helpers normalise values as expected', () => { assert.equal(stringOrNull(' foo '), 'foo'); assert.equal(stringOrNull(''), null); @@ -343,59 +369,35 @@ test('renderSingleNodeTable renders a condensed table for the node', () => { }); test('renderTelemetryCharts renders condensed scatter charts when telemetry exists', () => { - const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0); - const nowSeconds = Math.floor(nowMs / 1000); - const node = { - rawSources: { - telemetry: { - snapshots: [ - { - rx_time: nowSeconds - 60, - device_metrics: { - battery_level: 80, - voltage: 4.1, - channel_utilization: 40, - air_util_tx: 22, - current: 0.75, - }, - environment_metrics: { - temperature: 19.5, - relative_humidity: 55, - barometric_pressure: 995, - gas_resistance: 1500, - iaq: 83, - }, - }, - { - rx_time: nowSeconds - 3_600, - deviceMetrics: { - batteryLevel: 78, - voltage: 4.05, - channelUtilization: 35, - airUtilTx: 20, - current: 0.65, - }, - environmentMetrics: { - temperature: 18.4, - relativeHumidity: 52, - barometricPressure: 1000, - gasResistance: 2000, - iaq: 88, - }, - }, - ], - }, + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + const node = makeAggregatedNode([ + { + rx_time: nowSeconds - 60, + telemetry_type: 'device', + battery_level: 80, + voltage: 4.1, + channel_utilization: 40, + air_util_tx: 22, }, - }; + { + rx_time: nowSeconds - 3_600, + telemetry_type: 'environment', + temperature: 18.4, + relative_humidity: 52, + barometric_pressure: 1000, + gas_resistance: 2000, + iaq: 88, + }, + ]); const html = renderTelemetryCharts(node, { nowMs }); const fmt = new Date(nowMs); const expectedDate = String(fmt.getDate()).padStart(2, '0'); assert.equal(html.includes('node-detail__charts'), true); - assert.equal(html.includes('Power metrics'), true); + assert.equal(html.includes('Device health'), true); assert.equal(html.includes('Environmental telemetry'), true); assert.equal(html.includes('Battery (%)'), true); assert.equal(html.includes('Voltage (V)'), true); - assert.equal(html.includes('Current (A)'), true); assert.equal(html.includes('Channel utilization (%)'), true); assert.equal(html.includes('Air util TX (%)'), true); assert.equal(html.includes('Utilization (%)'), true); @@ -408,76 +410,167 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis }); test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => { - const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0); - const nowSeconds = Math.floor(nowMs / 1000); - const node = { - rawSources: { - telemetry: { - snapshots: [ - { - rx_time: nowSeconds - 120, - device_metrics: { - battery_level: 90, - voltage: 7.2, - current: 3.6, - channel_utilization: 45, - air_util_tx: 18, - }, - environment_metrics: { - temperature: 45, - relative_humidity: 48, - barometric_pressure: 1250, - gas_resistance: 1200, - iaq: 650, - }, - }, - ], - }, + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + const node = makeAggregatedNode([ + { + rx_time: nowSeconds - 120, + telemetry_type: 'device', + battery_level: 90, + voltage: 7.2, + channel_utilization: 45, + air_util_tx: 18, }, - }; + { + rx_time: nowSeconds - 180, + telemetry_type: 'environment', + temperature: 45, + relative_humidity: 48, + barometric_pressure: 1250, + gas_resistance: 1200, + iaq: 650, + }, + ]); const html = renderTelemetryCharts(node, { nowMs }); assert.match(html, />7\.2<\/text>/); - assert.match(html, />3\.6<\/text>/); assert.match(html, />45<\/text>/); assert.match(html, />650<\/text>/); assert.match(html, />1100<\/text>/); }); test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => { - const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0); - const nowSeconds = Math.floor(nowMs / 1000); - const node = { - rawSources: { - telemetry: { - snapshots: [ - { - rx_time: nowSeconds - 180, - device_metrics: { - battery_level: 70, - voltage: 4.5, - current: 1.5, - channel_utilization: 35, - air_util_tx: 15, - }, - environment_metrics: { - temperature: 25, - relative_humidity: 50, - barometric_pressure: 1015, - gas_resistance: 1500, - iaq: 200, - }, - }, - ], - }, + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + const node = makeAggregatedNode([ + { + rx_time: nowSeconds - 180, + telemetry_type: 'device', + battery_level: 70, + voltage: 4.5, + channel_utilization: 35, + air_util_tx: 15, }, - }; + { + rx_time: nowSeconds - 240, + telemetry_type: 'environment', + temperature: 25, + relative_humidity: 50, + barometric_pressure: 1015, + gas_resistance: 1500, + iaq: 200, + }, + ]); const html = renderTelemetryCharts(node, { nowMs }); assert.match(html, />6\.0<\/text>/); - assert.match(html, />3\.0<\/text>/); assert.match(html, />40<\/text>/); assert.match(html, />500<\/text>/); }); +test('classifySnapshot returns stored telemetry_type when present', () => { + assert.equal(classifySnapshot({ telemetry_type: 'device' }), 'device'); + assert.equal(classifySnapshot({ telemetry_type: 'environment' }), 'environment'); + assert.equal(classifySnapshot({ telemetry_type: 'power' }), 'power'); + assert.equal(classifySnapshot({ telemetry_type: 'air_quality' }), 'air_quality'); +}); + +test('classifySnapshot falls back to field-presence heuristics for legacy rows', () => { + // Flat battery field → device + assert.equal(classifySnapshot({ battery_level: 80 }), 'device'); + // channel_utilization → device + assert.equal(classifySnapshot({ channel_utilization: 40 }), 'device'); + // Nested device_metrics shape → device + assert.equal(classifySnapshot({ device_metrics: { battery_level: 80 } }), 'device'); + // Nested camelCase shape → device + assert.equal(classifySnapshot({ deviceMetrics: { batteryLevel: 78 } }), 'device'); + // Flat temperature → environment + assert.equal(classifySnapshot({ temperature: 21.5 }), 'environment'); + // Nested environment_metrics → environment + assert.equal(classifySnapshot({ environment_metrics: { temperature: 20 } }), 'environment'); + // voltage+current with no battery → power + assert.equal(classifySnapshot({ current: 0.5, voltage: 5.0 }), 'power'); + // Empty or null → unknown + assert.equal(classifySnapshot({}), 'unknown'); + assert.equal(classifySnapshot(null), 'unknown'); + assert.equal(classifySnapshot(undefined), 'unknown'); +}); + +test('renderTelemetryCharts shows device-health chart for device snapshots and power-sensor chart for power snapshots', () => { + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + const node = makeHistoryNode([ + { + rx_time: nowSeconds - 60, + telemetry_type: 'device', + battery_level: 80, + voltage: 4.1, + channel_utilization: 40, + }, + { + rx_time: nowSeconds - 120, + telemetry_type: 'power', + voltage: 5.0, + current: 0.5, + }, + ]); + const html = renderTelemetryCharts(node, { nowMs }); + assert.equal(html.includes('Device health'), true, 'Device health chart should render'); + assert.equal(html.includes('Battery (%)'), true, 'Battery series label from device chart'); + assert.equal(html.includes('Power sensor'), true, 'Power sensor chart should render'); + assert.equal(html.includes('Current (A)'), true, 'Current series label from power chart'); +}); + +test('renderTelemetryCharts backward compat: old rows without telemetry_type render via heuristics', () => { + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + const node = makeHistoryNode([ + { + rx_time: nowSeconds - 60, + battery_level: 75, + voltage: 4.08, + channel_utilization: 30, + }, + ]); + const html = renderTelemetryCharts(node, { nowMs }); + assert.equal(html.includes('Device health'), true, 'Device health renders via battery_level heuristic'); + assert.equal(html.includes('Battery (%)'), true, 'Battery series present'); +}); + +test('renderTelemetryCharts power-sensor chart does not include device snapshots (per-packet history)', () => { + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + // Per-packet history path: typeFilter IS applied, so device rows are excluded from power-sensor chart + const node = makeHistoryNode([ + { + rx_time: nowSeconds - 60, + telemetry_type: 'device', + battery_level: 80, + voltage: 4.1, + }, + ]); + const html = renderTelemetryCharts(node, { nowMs }); + assert.equal(html.includes('Device health'), true, 'Device health renders'); + assert.equal(html.includes('Power sensor'), false, 'Power sensor should not render with only device snapshots'); +}); + +test('renderTelemetryCharts aggregated mixed-bucket without telemetry_type shows all series', () => { + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + // Aggregated path: typeFilter is skipped; a bucket combining battery + temperature shows both charts + const node = makeAggregatedNode([ + { + rx_time: nowSeconds - 60, + battery_level: 80, + voltage: 4.1, + channel_utilization: 30, + temperature: 21.5, + relative_humidity: 55, + }, + ]); + const html = renderTelemetryCharts(node, { nowMs }); + assert.equal(html.includes('Device health'), true, 'Device health renders from battery field'); + assert.equal(html.includes('Environmental telemetry'), true, 'Environment renders from temperature field'); +}); + test('renderNodeDetailHtml composes the table, neighbors, and messages', () => { const html = renderNodeDetailHtml( { @@ -527,21 +620,23 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present', role: 'CLIENT', rawSources: { node: { node_id: '!abcd', role: 'CLIENT', short_name: 'NODE' }, - telemetry: { - snapshots: [ - { - rx_time: Math.floor(nowMs / 1000) - 120, - battery_level: 75, - voltage: 4.08, - channel_utilization: 30, - current: 0.42, - temperature: 20, - relative_humidity: 45, - barometric_pressure: 990, - gas_resistance: 1800, - }, - ], - }, + ...makeAggregatedNode([ + { + rx_time: Math.floor(nowMs / 1000) - 120, + telemetry_type: 'device', + battery_level: 75, + voltage: 4.08, + channel_utilization: 30, + }, + { + rx_time: Math.floor(nowMs / 1000) - 180, + telemetry_type: 'environment', + temperature: 20, + relative_humidity: 45, + barometric_pressure: 990, + gas_resistance: 1800, + }, + ]).rawSources, }, }; const html = renderNodeDetailHtml(node, { @@ -549,7 +644,7 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present', chartNowMs: nowMs, }); assert.equal(html.includes('node-detail__charts'), true); - assert.equal(html.includes('Power metrics'), true); + assert.equal(html.includes('Device health'), true); assert.equal(html.includes('Air quality'), true); }); diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js index 984407d..e9c442d 100644 --- a/web/public/assets/js/app/node-page.js +++ b/web/public/assets/js/app/node-page.js @@ -48,8 +48,9 @@ const TRACE_LIMIT = 200; */ const TELEMETRY_CHART_SPECS = Object.freeze([ { - id: 'power', - title: 'Power metrics', + id: 'device-health', + title: 'Device health', + typeFilter: ['device', 'unknown'], axes: [ { id: 'battery', @@ -70,16 +71,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ color: '#9ebcda', allowUpperOverflow: true, }, - { - id: 'current', - position: 'rightSecondary', - label: 'Current (A)', - min: 0, - max: 3, - ticks: 3, - color: '#3182bd', - allowUpperOverflow: true, - }, ], series: [ { @@ -100,6 +91,44 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ fields: ['voltage', 'voltageReading'], valueFormatter: value => `${value.toFixed(2)} V`, }, + ], + }, + { + id: 'power-sensor', + title: 'Power sensor', + typeFilter: ['power'], + axes: [ + { + id: 'voltage', + position: 'left', + label: 'Voltage (V)', + min: 0, + max: 6, + ticks: 3, + color: '#9ebcda', + allowUpperOverflow: true, + }, + { + id: 'current', + position: 'right', + label: 'Current (A)', + min: 0, + max: 3, + ticks: 3, + color: '#3182bd', + allowUpperOverflow: true, + }, + ], + series: [ + { + id: 'voltage', + axis: 'voltage', + color: '#9ebcda', + label: 'Voltage', + legend: 'Voltage (V)', + fields: ['voltage', 'voltageReading'], + valueFormatter: value => `${value.toFixed(2)} V`, + }, { id: 'current', axis: 'current', @@ -114,6 +143,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ { id: 'channel', title: 'Channel utilization', + typeFilter: ['device', 'unknown'], axes: [ { id: 'channel', @@ -149,6 +179,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ { id: 'environment', title: 'Environmental telemetry', + typeFilter: ['environment'], axes: [ { id: 'temperature', @@ -195,6 +226,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ { id: 'airQuality', title: 'Air quality', + typeFilter: ['environment', 'air_quality'], axes: [ { id: 'pressure', @@ -959,6 +991,46 @@ function collectSnapshotContainers(snapshot) { return containers; } +/** + * Infer the telemetry subtype for a snapshot. + * + * Uses the stored ``telemetry_type`` field when available. Falls back to + * field-presence heuristics for rows that pre-date the discriminator column. + * + * @param {Object} snapshot Telemetry snapshot payload. + * @returns {string} One of ``'device'``, ``'environment'``, ``'power'``, + * ``'air_quality'``, or ``'unknown'``. + */ +function classifySnapshot(snapshot) { + if (!snapshot || typeof snapshot !== 'object') return 'unknown'; + const stored = stringOrNull(snapshot.telemetry_type); + if (stored) return stored; + // Heuristics for legacy rows — check both flat and nested shapes. + const hasBattery = + snapshot.battery_level != null || + snapshot.channel_utilization != null || + snapshot.air_util_tx != null || + snapshot.uptime_seconds != null || + snapshot.device_metrics?.battery_level != null || + snapshot.deviceMetrics?.batteryLevel != null; + if (hasBattery) return 'device'; + const hasEnv = + snapshot.temperature != null || + snapshot.relative_humidity != null || + snapshot.barometric_pressure != null || + snapshot.environment_metrics?.temperature != null || + snapshot.environmentMetrics?.temperature != null; + if (hasEnv) return 'environment'; + // device_metrics also carries a `voltage` field (~4.2 V for battery), so a + // device row with `voltage` but none of the four battery-discriminator fields + // above would be misclassified as 'power'. This is consistent with the SQL + // backfill and is negligible in practice (firmware always sends at least + // battery_level or channel_utilization alongside voltage). + if (snapshot.current != null || snapshot.voltage != null) return 'power'; + if (snapshot.iaq != null || snapshot.gas_resistance != null) return 'environment'; + return 'unknown'; +} + /** * Extract the first numeric telemetry value that matches one of the provided * field names. @@ -1161,10 +1233,13 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) { const timeRangeLabel = stringOrNull(chartOptions.timeRangeLabel) ?? 'Last 7 days'; const domainEnd = nowMs; const domainStart = nowMs - windowMs; + const effectiveEntries = Array.isArray(spec.typeFilter) && !chartOptions.isAggregated + ? entries.filter(e => spec.typeFilter.includes(classifySnapshot(e.snapshot))) + : entries; const dims = createChartDimensions(spec); const seriesEntries = spec.series .map(series => { - const points = buildSeriesPoints(entries, series.fields, domainStart, domainEnd); + const points = buildSeriesPoints(effectiveEntries, series.fields, domainStart, domainEnd); if (points.length === 0) return null; return { config: series, axisId: series.axis, points }; }) @@ -1264,8 +1339,9 @@ export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = if (entries.length === 0) { return ''; } + const isAggregated = snapshotHistory == null && aggregatedSnapshots != null; const charts = TELEMETRY_CHART_SPECS - .map(spec => renderTelemetryChart(spec, entries, nowMs, chartOptions)) + .map(spec => renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated })) .filter(chart => stringOrNull(chart)); if (charts.length === 0) { return ''; @@ -2587,6 +2663,7 @@ export const __testUtils = { categoriseNeighbors, renderNeighborGroups, renderSingleNodeTable, + classifySnapshot, renderTelemetryCharts, renderMessages, renderTraceroutes, diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 7a682d8..2db7326 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -226,6 +226,21 @@ RSpec.describe "Potato Mesh Sinatra app" do end end + # Fetch the stored telemetry_type for a given row id and assert it equals + # +expected+. Avoids repeating the with_db / SELECT / expect triple across + # multiple telemetry_type inference tests. + # + # @param id [Integer] telemetry row id to look up. + # @param expected [String] expected telemetry_type value. + # @return [void] + def expect_stored_telemetry_type(id, expected) + with_db(readonly: true) do |db| + db.results_as_hash = true + row = db.get_first_row("SELECT telemetry_type FROM telemetry WHERE id = ?", [id]) + expect(row["telemetry_type"]).to eq(expected) + end + end + # Assert that an API response either omits blank values or matches the # expected non-blank value. # @@ -3962,6 +3977,95 @@ RSpec.describe "Potato Mesh Sinatra app" do end end + it "infers telemetry_type='device' from device_metrics in the payload" do + payload = [ + { + "id" => 24_001, + "node_id" => "!teltype01", + "rx_time" => reference_time.to_i - 10, + "device_metrics" => { "battery_level" => 85, "voltage" => 4.1 }, + }, + ] + + post "/api/telemetry", payload.to_json, auth_headers + + expect(last_response).to be_ok + expect_stored_telemetry_type(24_001, "device") + end + + it "infers telemetry_type='environment' from environment_metrics in the payload" do + payload = [ + { + "id" => 24_002, + "node_id" => "!teltype02", + "rx_time" => reference_time.to_i - 20, + "environment_metrics" => { "temperature" => 22.5, "relativeHumidity" => 50 }, + }, + ] + + post "/api/telemetry", payload.to_json, auth_headers + + expect(last_response).to be_ok + expect_stored_telemetry_type(24_002, "environment") + end + + it "accepts an explicit telemetry_type from the payload" do + payload = [ + { + "id" => 24_003, + "node_id" => "!teltype03", + "rx_time" => reference_time.to_i - 30, + "telemetry_type" => "power", + "voltage" => 5.0, + "current" => 0.48, + }, + ] + + post "/api/telemetry", payload.to_json, auth_headers + + expect(last_response).to be_ok + expect_stored_telemetry_type(24_003, "power") + end + + it "includes telemetry_type in GET /api/telemetry/:id response" do + payload = [ + { + "id" => 24_004, + "node_id" => "!teltype04", + "rx_time" => reference_time.to_i - 5, + "device_metrics" => { "battery_level" => 70, "channelUtilization" => 30 }, + }, + ] + + post "/api/telemetry", payload.to_json, auth_headers + + expect(last_response).to be_ok + + get "/api/telemetry/!teltype04", {}, auth_headers + + expect(last_response).to be_ok + entries = JSON.parse(last_response.body) + entry = entries.find { |e| e["id"] == 24_004 } + expect(entry).not_to be_nil + expect(entry["telemetry_type"]).to eq("device") + end + + it "infers telemetry_type='air_quality' from air_quality_metrics in the payload" do + payload = [ + { + "id" => 24_005, + "node_id" => "!teltype05", + "rx_time" => reference_time.to_i - 40, + "air_quality_metrics" => { "iaq" => 72, "pm25" => 8 }, + }, + ] + + post "/api/telemetry", payload.to_json, auth_headers + + expect(last_response).to be_ok + expect_stored_telemetry_type(24_005, "air_quality") + end + it "returns 400 when more than 1000 telemetry packets are provided" do payload = Array.new(1001) { |i| { "id" => i + 1, "rx_time" => reference_time.to_i - i } }