Add telemetry formatting module and overlay metrics (#387)

This commit is contained in:
l5y
2025-10-19 12:13:32 +02:00
committed by GitHub
parent 6775de3cca
commit 96a3bb86e9
3 changed files with 603 additions and 111 deletions
@@ -0,0 +1,162 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TELEMETRY_FIELDS,
buildTelemetryDisplayEntries,
collectTelemetryMetrics,
} from '../short-info-telemetry.js';
test('collectTelemetryMetrics extracts values from nested payloads', () => {
const payload = {
battery: '100',
device_metrics: {
voltage: 4.224,
airUtilTx: 0.051,
uptimeSeconds: 305044,
},
environment_metrics: {
temperature: 21.98,
relativeHumidity: 39.5,
barometricPressure: 1017.8,
gasResistance: 1456,
iaq: 83,
distance: 12.5,
lux: 100.25,
whiteLux: 64.5,
irLux: 12.75,
uvLux: 1.6,
windDirection: 270,
windSpeed: 5.9,
windGust: 7.4,
windLull: 4.8,
weight: 32.7,
radiation: 0.45,
rainfall1h: 0.18,
rainfall24h: 1.42,
soilMoisture: 3100,
soilTemperature: 18.9,
},
};
const metrics = collectTelemetryMetrics(payload);
assert.equal(metrics.battery, 100);
assert.equal(metrics.voltage, 4.224);
assert.equal(metrics.airUtil, 0.051);
assert.equal(metrics.uptime, 305044);
assert.equal(metrics.temperature, 21.98);
assert.equal(metrics.humidity, 39.5);
assert.equal(metrics.pressure, 1017.8);
assert.equal(metrics.gasResistance, 1456);
assert.equal(metrics.iaq, 83);
assert.equal(metrics.distance, 12.5);
assert.equal(metrics.lux, 100.25);
assert.equal(metrics.whiteLux, 64.5);
assert.equal(metrics.irLux, 12.75);
assert.equal(metrics.uvLux, 1.6);
assert.equal(metrics.windDirection, 270);
assert.equal(metrics.windSpeed, 5.9);
assert.equal(metrics.windGust, 7.4);
assert.equal(metrics.windLull, 4.8);
assert.equal(metrics.weight, 32.7);
assert.equal(metrics.radiation, 0.45);
assert.equal(metrics.rainfall1h, 0.18);
assert.equal(metrics.rainfall24h, 1.42);
assert.equal(metrics.soilMoisture, 3100);
assert.equal(metrics.soilTemperature, 18.9);
});
test('collectTelemetryMetrics ignores non-numeric values', () => {
const metrics = collectTelemetryMetrics({
battery: '',
voltage: 'abc',
rainfall_1h: null,
wind_speed: undefined,
});
for (const field of TELEMETRY_FIELDS) {
assert.ok(!(field.key in metrics));
}
});
test('buildTelemetryDisplayEntries formats values for overlays', () => {
const telemetry = {
battery: 99,
voltage: 4.224,
current: 0.0715,
uptime: 305044,
channel: 0.5967,
airUtil: 0.03908,
temperature: 21.98,
humidity: 39.5,
pressure: 1017.8,
gasResistance: 1456,
iaq: 83,
distance: 12.5,
lux: 100.25,
whiteLux: 64.5,
irLux: 12.75,
uvLux: 1.6,
windDirection: 270,
windSpeed: 5.9,
windGust: 7.4,
windLull: 4.8,
weight: 32.7,
radiation: 0.45,
rainfall1h: 0.18,
rainfall24h: 1.42,
soilMoisture: 3100,
soilTemperature: 18.9,
};
const entries = buildTelemetryDisplayEntries(telemetry, {
formatUptime: value => `formatted-${value}`,
});
const entryMap = new Map(entries.map(entry => [entry.label, entry.value]));
assert.equal(entryMap.get('Battery'), '99%');
assert.equal(entryMap.get('Voltage'), '4.224V');
assert.equal(entryMap.get('Current'), '71.5 mA');
assert.equal(entryMap.get('Uptime'), 'formatted-305044');
assert.equal(entryMap.get('Channel Util'), '0.597%');
assert.equal(entryMap.get('Air Util Tx'), '0.039%');
assert.equal(entryMap.get('Temperature'), '22.0°C');
assert.equal(entryMap.get('Humidity'), '39.5%');
assert.equal(entryMap.get('Pressure'), '1017.8 hPa');
assert.equal(entryMap.get('Gas Resistance'), '1.46 kΩ');
assert.equal(entryMap.get('IAQ'), '83');
assert.equal(entryMap.get('Distance'), '12.50 m');
assert.equal(entryMap.get('Lux'), '100.3 lx');
assert.equal(entryMap.get('White Lux'), '64.5 lx');
assert.equal(entryMap.get('IR Lux'), '12.8 lx');
assert.equal(entryMap.get('UV Lux'), '1.6 lx');
assert.equal(entryMap.get('Wind Direction'), '270°');
assert.equal(entryMap.get('Wind Speed'), '5.9 m/s');
assert.equal(entryMap.get('Wind Gust'), '7.4 m/s');
assert.equal(entryMap.get('Wind Lull'), '4.8 m/s');
assert.equal(entryMap.get('Weight'), '32.70 kg');
assert.equal(entryMap.get('Radiation'), '0.45 µSv/h');
assert.equal(entryMap.get('Rainfall 1h'), '0.18 mm');
assert.equal(entryMap.get('Rainfall 24h'), '1.42 mm');
assert.equal(entryMap.get('Soil Moisture'), '3100');
assert.equal(entryMap.get('Soil Temperature'), '18.9°C');
});
test('buildTelemetryDisplayEntries omits empty metrics', () => {
const entries = buildTelemetryDisplayEntries({ uptime: null }, {
formatUptime: () => '',
});
assert.equal(entries.length, 0);
});
+33 -111
View File
@@ -20,6 +20,16 @@ import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js';
import {
TELEMETRY_FIELDS,
buildTelemetryDisplayEntries,
collectTelemetryMetrics,
fmtAlt,
fmtHumidity,
fmtPressure,
fmtTemperature,
fmtTx,
} from './short-info-telemetry.js';
import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
@@ -1637,24 +1647,17 @@ export function initializeApp(config) {
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role));
let infoAttr = '';
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
battery: nodeData.battery_level ?? nodeData.battery ?? null,
voltage: nodeData.voltage ?? null,
uptime: nodeData.uptime_seconds ?? nodeData.uptime ?? null,
channel: nodeData.channel_utilization ?? nodeData.channel ?? null,
airUtil: nodeData.air_util_tx ?? nodeData.airUtil ?? null,
temperature: nodeData.temperature ?? nodeData.temp ?? null,
humidity: nodeData.relative_humidity ?? nodeData.relativeHumidity ?? nodeData.humidity ?? null,
pressure: nodeData.barometric_pressure ?? nodeData.barometricPressure ?? nodeData.pressure ?? null,
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
Object.assign(info, collectTelemetryMetrics(nodeData));
const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`];
const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : '';
if (attrNodeIdRaw) {
@@ -1862,23 +1865,6 @@ export function initializeApp(config) {
return value != null && value !== '' ? String(value) : '—';
}
/**
* Append telemetry information to the short-info overlay payload.
*
* @param {Array<string>} lines Output accumulator.
* @param {string} label Field label.
* @param {*} rawValue Raw telemetry value.
* @param {Function} formatter Optional formatter callback.
* @returns {void}
*/
function appendTelemetryLine(lines, label, rawValue, formatter) {
if (!Array.isArray(lines)) return;
if (rawValue == null || rawValue === '') return;
const formatted = formatter ? formatter(rawValue) : rawValue;
if (formatted == null || formatted === '') return;
lines.push(`${escapeHtml(label)}: ${escapeHtml(String(formatted))}`);
}
/**
* Transform a node-shaped payload into the overlay data format.
*
@@ -1921,14 +1907,6 @@ export function initializeApp(config) {
}
const numericPairs = [
['battery', source.battery ?? source.battery_level],
['voltage', source.voltage],
['uptime', source.uptime ?? source.uptime_seconds],
['channel', source.channel ?? source.channel_utilization],
['airUtil', source.airUtil ?? source.air_util_tx],
['temperature', source.temperature],
['humidity', source.humidity ?? source.relative_humidity],
['pressure', source.pressure ?? source.barometric_pressure],
['telemetryTime', source.telemetryTime ?? source.telemetry_time],
['lastHeard', source.lastHeard ?? source.last_heard],
['latitude', source.latitude],
@@ -1944,6 +1922,14 @@ export function initializeApp(config) {
}
}
const telemetryMetrics = collectTelemetryMetrics(source);
for (const field of TELEMETRY_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(telemetryMetrics, field.key)) {
continue;
}
normalized[field.key] = telemetryMetrics[field.key];
}
const lastSeenRaw = source.lastSeenIso ?? source.last_seen_iso;
if (typeof lastSeenRaw === 'string' && lastSeenRaw.trim().length > 0) {
normalized.lastSeenIso = lastSeenRaw.trim();
@@ -2053,14 +2039,10 @@ export function initializeApp(config) {
if (modelValue) {
lines.push(`Model: ${escapeHtml(modelValue)}`);
}
appendTelemetryLine(lines, 'Battery', overlayInfo.battery, value => fmtAlt(value, '%'));
appendTelemetryLine(lines, 'Voltage', overlayInfo.voltage, value => fmtAlt(value, 'V'));
appendTelemetryLine(lines, 'Uptime', overlayInfo.uptime, formatShortInfoUptime);
appendTelemetryLine(lines, 'Channel Util', overlayInfo.channel, fmtTx);
appendTelemetryLine(lines, 'Air Util Tx', overlayInfo.airUtil, fmtTx);
appendTelemetryLine(lines, 'Temperature', overlayInfo.temperature, fmtTemperature);
appendTelemetryLine(lines, 'Humidity', overlayInfo.humidity, fmtHumidity);
appendTelemetryLine(lines, 'Pressure', overlayInfo.pressure, fmtPressure);
const telemetryEntries = buildTelemetryDisplayEntries(overlayInfo, { formatUptime: formatShortInfoUptime });
for (const entry of telemetryEntries) {
lines.push(`${escapeHtml(entry.label)}: ${escapeHtml(entry.value)}`);
}
if (neighborLineHtml) {
lines.push(neighborLineHtml);
}
@@ -2271,66 +2253,6 @@ export function initializeApp(config) {
return Number.isFinite(n) ? n.toFixed(d) : "";
}
/**
* Format altitude values with units.
*
* @param {*} v Raw altitude value.
* @param {string} s Unit suffix.
* @returns {string} Altitude string.
*/
function fmtAlt(v, s) {
return (v == null || v === '') ? "" : `${v}${s}`;
}
/**
* Format transmission utilisation values as percentages.
*
* @param {*} v Raw utilisation value.
* @param {number} [d=3] Decimal precision.
* @returns {string} Percentage string.
*/
function fmtTx(v, d = 3) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(d)}%` : "";
}
/**
* Format temperature telemetry with a degree suffix.
*
* @param {*} v Raw temperature value.
* @returns {string} Temperature string.
*/
function fmtTemperature(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)}°C` : "";
}
/**
* Format humidity telemetry as a percentage.
*
* @param {*} v Raw humidity value.
* @returns {string} Humidity string.
*/
function fmtHumidity(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)}%` : "";
}
/**
* Format barometric pressure telemetry in hPa.
*
* @param {*} v Raw pressure value.
* @returns {string} Pressure string.
*/
function fmtPressure(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)} hPa` : "";
}
/**
* Format SNR readings with a ``dB`` suffix.
*
@@ -0,0 +1,408 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
/**
* Determine whether ``value`` can be treated as a finite number.
*
* @param {*} value Candidate numeric value.
* @returns {boolean} ``true`` when the value parses to a finite number.
*/
function isFiniteNumber(value) {
if (value == null || value === '') return false;
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number);
}
/**
* Retrieve the first defined property from ``container`` using ``keys``.
*
* @param {Object} container Object inspected for values.
* @param {Array<string>} keys Candidate property names.
* @returns {*} First non-nullish value discovered.
*/
function pickFirstValue(container, keys) {
if (!container || typeof container !== 'object') {
return undefined;
}
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(container, key)) {
const candidate = container[key];
if (candidate != null && (candidate !== '' || candidate === 0)) {
return candidate;
}
}
}
return undefined;
}
/**
* Format arbitrary telemetry values using a numeric suffix.
*
* @param {*} value Raw value to format.
* @param {string} suffix Unit suffix appended when formatting succeeds.
* @returns {string} Formatted value or an empty string for invalid input.
*/
export function fmtAlt(value, suffix) {
if (!isFiniteNumber(value) && !(value === 0 || value === '0')) {
return '';
}
return `${Number(value)}${suffix}`;
}
/**
* Format utilisation metrics as percentages.
*
* @param {*} value Raw utilisation value.
* @param {number} [decimals=3] Decimal precision applied to the percentage.
* @returns {string} Formatted percentage string.
*/
export function fmtTx(value, decimals = 3) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(decimals)}%`;
}
/**
* Format temperature telemetry in degrees Celsius.
*
* @param {*} value Raw temperature reading.
* @returns {string} Formatted temperature string.
*/
export function fmtTemperature(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)}°C`;
}
/**
* Format relative humidity telemetry as a percentage.
*
* @param {*} value Raw humidity reading.
* @returns {string} Formatted humidity string.
*/
export function fmtHumidity(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)}%`;
}
/**
* Format barometric pressure telemetry in hectopascals.
*
* @param {*} value Raw pressure value.
* @returns {string} Formatted pressure string.
*/
export function fmtPressure(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} hPa`;
}
/**
* Format current telemetry, automatically scaling to milliamperes when
* appropriate.
*
* @param {*} value Raw current reading expressed in amperes.
* @returns {string} Formatted current string.
*/
export function fmtCurrent(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
if (Math.abs(num) < 1) {
return `${(num * 1000).toFixed(1)} mA`;
}
return `${num.toFixed(2)} A`;
}
/**
* Format gas resistance telemetry using a human readable Ohm prefix.
*
* @param {*} value Raw resistance value expressed in Ohms.
* @returns {string} Formatted resistance string.
*/
export function fmtGasResistance(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
const absVal = Math.abs(num);
if (absVal >= 1_000_000) {
return `${(num / 1_000_000).toFixed(2)}`;
}
if (absVal >= 1_000) {
return `${(num / 1_000).toFixed(2)}`;
}
return `${num.toFixed(2)} Ω`;
}
/**
* Format generic distance telemetry in metres.
*
* @param {*} value Raw distance value.
* @returns {string} Formatted distance string.
*/
export function fmtDistance(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} m`;
}
/**
* Format optical telemetry in lux.
*
* @param {*} value Raw lux reading.
* @returns {string} Formatted lux string.
*/
export function fmtLux(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} lx`;
}
/**
* Format wind direction telemetry in degrees.
*
* @param {*} value Raw wind direction reading.
* @returns {string} Formatted wind direction string.
*/
export function fmtWindDirection(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}°`;
}
/**
* Format wind speed telemetry in metres per second.
*
* @param {*} value Raw wind speed reading.
* @returns {string} Formatted wind speed string.
*/
export function fmtWindSpeed(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} m/s`;
}
/**
* Format weight telemetry in kilograms.
*
* @param {*} value Raw weight value.
* @returns {string} Formatted weight string.
*/
export function fmtWeight(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} kg`;
}
/**
* Format radiation telemetry using microsieverts per hour.
*
* @param {*} value Raw radiation value.
* @returns {string} Formatted radiation string.
*/
export function fmtRadiation(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} µSv/h`;
}
/**
* Format rainfall telemetry using millimetres.
*
* @param {*} value Raw rainfall accumulation value.
* @returns {string} Formatted rainfall string.
*/
export function fmtRainfall(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} mm`;
}
/**
* Format soil moisture telemetry. The metrics are typically raw sensor values
* without defined units, therefore the raw integer is surfaced unchanged.
*
* @param {*} value Raw soil moisture reading.
* @returns {string} Soil moisture string.
*/
export function fmtSoilMoisture(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}`;
}
/**
* Format soil temperature telemetry in degrees Celsius.
*
* @param {*} value Raw soil temperature reading.
* @returns {string} Formatted soil temperature string.
*/
export function fmtSoilTemperature(value) {
return fmtTemperature(value);
}
/**
* Format indoor air quality index values.
*
* @param {*} value Raw IAQ reading.
* @returns {string} IAQ string.
*/
export function fmtIaq(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}`;
}
/**
* Telemetry descriptors consumed by the short-info overlay.
*
* Each descriptor includes a canonical key, display label, candidate source
* property names, and a formatter that converts numeric values into a human
* readable string.
*/
export const TELEMETRY_FIELDS = [
{ key: 'battery', label: 'Battery', sources: ['battery', 'battery_level', 'batteryLevel'], formatter: value => fmtAlt(value, '%') },
{ key: 'voltage', label: 'Voltage', sources: ['voltage'], formatter: value => fmtAlt(value, 'V') },
{ key: 'current', label: 'Current', sources: ['current'], formatter: fmtCurrent },
{ key: 'uptime', label: 'Uptime', sources: ['uptime', 'uptime_seconds', 'uptimeSeconds'], formatter: (value, utils) => (typeof utils.formatUptime === 'function' ? utils.formatUptime(value) : '') },
{
key: 'channel',
label: 'Channel Util',
sources: ['channel', 'channel_utilization', 'channelUtilization'],
formatter: value => fmtTx(value),
},
{
key: 'airUtil',
label: 'Air Util Tx',
sources: ['airUtil', 'air_util_tx', 'airUtilTx'],
formatter: value => fmtTx(value),
},
{ key: 'temperature', label: 'Temperature', sources: ['temperature', 'temp'], formatter: fmtTemperature },
{ key: 'humidity', label: 'Humidity', sources: ['humidity', 'relative_humidity', 'relativeHumidity'], formatter: fmtHumidity },
{ key: 'pressure', label: 'Pressure', sources: ['pressure', 'barometric_pressure', 'barometricPressure'], formatter: fmtPressure },
{ key: 'gasResistance', label: 'Gas Resistance', sources: ['gas_resistance', 'gasResistance'], formatter: fmtGasResistance },
{ key: 'iaq', label: 'IAQ', sources: ['iaq'], formatter: fmtIaq },
{ key: 'distance', label: 'Distance', sources: ['distance'], formatter: fmtDistance },
{ key: 'lux', label: 'Lux', sources: ['lux'], formatter: fmtLux },
{ key: 'whiteLux', label: 'White Lux', sources: ['white_lux', 'whiteLux'], formatter: fmtLux },
{ key: 'irLux', label: 'IR Lux', sources: ['ir_lux', 'irLux'], formatter: fmtLux },
{ key: 'uvLux', label: 'UV Lux', sources: ['uv_lux', 'uvLux'], formatter: fmtLux },
{ key: 'windDirection', label: 'Wind Direction', sources: ['wind_direction', 'windDirection'], formatter: fmtWindDirection },
{ key: 'windSpeed', label: 'Wind Speed', sources: ['wind_speed', 'windSpeed', 'windSpeedMps'], formatter: fmtWindSpeed },
{ key: 'windGust', label: 'Wind Gust', sources: ['wind_gust', 'windGust'], formatter: fmtWindSpeed },
{ key: 'windLull', label: 'Wind Lull', sources: ['wind_lull', 'windLull'], formatter: fmtWindSpeed },
{ key: 'weight', label: 'Weight', sources: ['weight'], formatter: fmtWeight },
{ key: 'radiation', label: 'Radiation', sources: ['radiation', 'radiationLevel'], formatter: fmtRadiation },
{ key: 'rainfall1h', label: 'Rainfall 1h', sources: ['rainfall_1h', 'rainfall1h', 'rainfall1H'], formatter: fmtRainfall },
{ key: 'rainfall24h', label: 'Rainfall 24h', sources: ['rainfall_24h', 'rainfall24h', 'rainfall24H'], formatter: fmtRainfall },
{ key: 'soilMoisture', label: 'Soil Moisture', sources: ['soil_moisture', 'soilMoisture'], formatter: fmtSoilMoisture },
{ key: 'soilTemperature', label: 'Soil Temperature', sources: ['soil_temperature', 'soilTemperature'], formatter: fmtSoilTemperature },
];
/**
* Collect telemetry metrics from arbitrary node payloads.
*
* The function inspects common top-level, device metric, and environment
* metric collections in order to surface numeric telemetry values.
*
* @param {*} source Node payload that may contain telemetry.
* @returns {Object} Object containing numeric telemetry keyed by descriptor.
*/
export function collectTelemetryMetrics(source) {
const metrics = {};
if (!source || typeof source !== 'object') {
return metrics;
}
const containers = [
source,
source.device_metrics,
source.deviceMetrics,
source.environment_metrics,
source.environmentMetrics,
source.telemetry,
].filter(container => container && typeof container === 'object');
for (const field of TELEMETRY_FIELDS) {
const keys = Array.isArray(field.sources) && field.sources.length > 0
? field.sources
: [field.key];
for (const container of containers) {
const raw = pickFirstValue(container, keys);
if (!isFiniteNumber(raw) && !(raw === 0 || raw === '0')) {
continue;
}
const num = Number(raw);
if (Number.isFinite(num)) {
metrics[field.key] = num;
break;
}
}
}
return metrics;
}
/**
* Build display entries for telemetry values suitable for short-info overlays.
*
* @param {Object} telemetry Telemetry metrics keyed by descriptor ``key``.
* @param {{formatUptime?: Function}} [utils] Optional formatter overrides.
* @returns {Array<{label: string, value: string}>} Renderable telemetry entries.
*/
export function buildTelemetryDisplayEntries(telemetry, utils = {}) {
const entries = [];
if (!telemetry || typeof telemetry !== 'object') {
return entries;
}
for (const field of TELEMETRY_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(telemetry, field.key)) {
continue;
}
const value = telemetry[field.key];
if (value == null) {
continue;
}
const formatted = typeof field.formatter === 'function'
? field.formatter(value, utils)
: String(value);
if (formatted == null || formatted === '') {
continue;
}
entries.push({ label: field.label, value: formatted });
}
return entries;
}
export default {
TELEMETRY_FIELDS,
collectTelemetryMetrics,
buildTelemetryDisplayEntries,
fmtAlt,
fmtTx,
fmtTemperature,
fmtHumidity,
fmtPressure,
fmtCurrent,
fmtGasResistance,
fmtDistance,
fmtLux,
fmtWindDirection,
fmtWindSpeed,
fmtWeight,
fmtRadiation,
fmtRainfall,
fmtSoilMoisture,
fmtSoilTemperature,
fmtIaq,
};