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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* web: address review comments

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
l5y
2026-03-30 00:07:24 +02:00
committed by GitHub
parent 4d0d6f8565
commit a5d0008555
11 changed files with 606 additions and 132 deletions
+1
View File
@@ -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`
+25
View File
@@ -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",
@@ -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;
+2 -1
View File
@@ -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);
+104
View File
@@ -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):
@@ -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
@@ -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'")
@@ -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
@@ -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);
});
+91 -14
View File
@@ -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,
+104
View File
@@ -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 } }