mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 09:22:53 +02:00
* 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:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user