mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 17:03:27 +02:00
Add telemetry formatting module and overlay metrics (#387)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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)} 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,
|
||||
};
|
||||
Reference in New Issue
Block a user