From 96a3bb86e931338b57e302db3918242bdcf55b4e Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 19 Oct 2025 12:13:32 +0200 Subject: [PATCH] Add telemetry formatting module and overlay metrics (#387) --- .../__tests__/short-info-telemetry.test.js | 162 +++++++ web/public/assets/js/app/main.js | 144 ++----- .../assets/js/app/short-info-telemetry.js | 408 ++++++++++++++++++ 3 files changed, 603 insertions(+), 111 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/short-info-telemetry.test.js create mode 100644 web/public/assets/js/app/short-info-telemetry.js diff --git a/web/public/assets/js/app/__tests__/short-info-telemetry.test.js b/web/public/assets/js/app/__tests__/short-info-telemetry.test.js new file mode 100644 index 0000000..eb05c45 --- /dev/null +++ b/web/public/assets/js/app/__tests__/short-info-telemetry.test.js @@ -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); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index e798708..aaead16 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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} 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. * diff --git a/web/public/assets/js/app/short-info-telemetry.js b/web/public/assets/js/app/short-info-telemetry.js new file mode 100644 index 0000000..0fd9a0a --- /dev/null +++ b/web/public/assets/js/app/short-info-telemetry.js @@ -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} 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)} MΩ`; + } + if (absVal >= 1_000) { + return `${(num / 1_000).toFixed(2)} kΩ`; + } + 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, +};