Add modem preset display to node overlay (#340)

* Add modem metadata line to node overlays

* Ensure modem metadata loads for all overlays
This commit is contained in:
l5y
2025-10-14 20:59:47 +02:00
committed by GitHub
parent cff89a8c88
commit a3aef8cadd
5 changed files with 225 additions and 1 deletions
@@ -26,6 +26,7 @@ const {
extractNumber,
assignString,
assignNumber,
mergeModemMetadata,
mergeNodeFields,
mergeTelemetry,
mergePosition,
@@ -49,6 +50,8 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
short_name: 'TST',
battery_level: null,
last_heard: 1_000,
modem_preset: 'MediumFast',
lora_freq: '868.1',
})],
['/api/telemetry/!test?limit=1', createResponse(200, [{
node_id: '!test',
@@ -87,6 +90,8 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
assert.equal(node.battery, 73.5);
assert.equal(node.voltage, 4.1);
assert.equal(node.role, 'CLIENT');
assert.equal(node.modemPreset, 'MediumFast');
assert.equal(node.loraFreq, 868.1);
assert.equal(node.lastHeard, 1_200);
assert.equal(node.telemetryTime, 1_180);
assert.equal(node.latitude, 52.5);
@@ -123,7 +128,7 @@ test('refreshNodeInformation preserves fallback metrics when telemetry is unavai
return response ?? createResponse(404, { error: 'not found' });
};
const fallback = { nodeNum: 42, battery: 12.5, role: 'CLIENT' };
const fallback = { nodeNum: 42, battery: 12.5, role: 'CLIENT', modemPreset: 'FallbackPreset', loraFreq: 915 };
const node = await refreshNodeInformation({ nodeNum: 42, fallback }, { fetchImpl });
assert.equal(node.nodeId, '!num');
@@ -131,6 +136,8 @@ test('refreshNodeInformation preserves fallback metrics when telemetry is unavai
assert.equal(node.shortName, 'NUM');
assert.equal(node.battery, 12.5);
assert.equal(node.role, 'CLIENT');
assert.equal(node.modemPreset, 'FallbackPreset');
assert.equal(node.loraFreq, 915);
assert.equal(Array.isArray(node.neighbors) && node.neighbors.length, 0);
});
@@ -196,6 +203,21 @@ test('refreshNodeInformation enforces a fetch implementation', async () => {
}
});
test('mergeModemMetadata respects preference flags', () => {
const target = {};
mergeModemMetadata(target, { modem_preset: 'Base', lora_freq: '915.5' });
assert.equal(target.modemPreset, 'Base');
assert.equal(target.loraFreq, 915.5);
mergeModemMetadata(target, { modem_preset: 'New', lora_freq: '433' }, { preferExisting: true });
assert.equal(target.modemPreset, 'Base');
assert.equal(target.loraFreq, 915.5);
mergeModemMetadata(target, { modem_preset: 'Updated', lora_freq: '433' }, { preferExisting: false });
assert.equal(target.modemPreset, 'Updated');
assert.equal(target.loraFreq, 433);
});
test('helper utilities normalise primitive values', () => {
assert.equal(toTrimmedString(' hello '), 'hello');
assert.equal(toTrimmedString(''), null);
@@ -0,0 +1,65 @@
/*
* 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 { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay, __testUtils } from '../node-modem-metadata.js';
describe('node-modem-metadata', () => {
it('extracts modem preset and frequency from mixed payloads', () => {
const payload = {
modem_preset: ' MediumFast ',
lora_freq: '915',
};
assert.deepEqual(extractModemMetadata(payload), { modemPreset: 'MediumFast', loraFreq: 915 });
});
it('falls back across naming conventions when extracting metadata', () => {
const payload = {
modemPreset: 'LongSlow',
frequency: 868,
};
assert.deepEqual(extractModemMetadata(payload), { modemPreset: 'LongSlow', loraFreq: 868 });
});
it('ignores invalid modem metadata entries', () => {
assert.deepEqual(extractModemMetadata({ modem_preset: ' ', lora_freq: 'NaN' }), {
modemPreset: null,
loraFreq: null,
});
});
it('formats positive frequencies with MHz suffix', () => {
assert.equal(formatLoraFrequencyMHz(915), '915MHz');
assert.equal(formatLoraFrequencyMHz(867.5), '867.5MHz');
assert.equal(formatLoraFrequencyMHz('433.1234'), '433.123MHz');
assert.equal(formatLoraFrequencyMHz(null), null);
});
it('combines preset and frequency for overlay display', () => {
assert.equal(formatModemDisplay('MediumFast', 868), 'MediumFast (868MHz)');
assert.equal(formatModemDisplay('ShortSlow', null), 'ShortSlow');
assert.equal(formatModemDisplay(null, 433), '433MHz');
assert.equal(formatModemDisplay(undefined, undefined), null);
});
it('exposes trimmed string helper for targeted assertions', () => {
const { toTrimmedString } = __testUtils;
assert.equal(toTrimmedString(' hello '), 'hello');
assert.equal(toTrimmedString(''), null);
assert.equal(toTrimmedString(null), null);
});
});
+13
View File
@@ -19,6 +19,7 @@ import { createMapAutoFitController } from './map-auto-fit-controller.js';
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js';
import {
extractChatMessageMetadata,
formatChatMessagePrefix,
@@ -1866,6 +1867,14 @@ export function initializeApp(config) {
normalized.hwModel = source.hwModel ?? source.hw_model;
}
const modemMetadata = extractModemMetadata(source);
if (modemMetadata.modemPreset) {
normalized.modemPreset = modemMetadata.modemPreset;
}
if (modemMetadata.loraFreq != null) {
normalized.loraFreq = modemMetadata.loraFreq;
}
const numericPairs = [
['battery', source.battery ?? source.battery_level],
['voltage', source.voltage],
@@ -1975,6 +1984,10 @@ export function initializeApp(config) {
if (shortParts.length) {
lines.push(shortParts.join(' '));
}
const modemDisplay = formatModemDisplay(overlayInfo.modemPreset, overlayInfo.loraFreq);
if (modemDisplay) {
lines.push(escapeHtml(modemDisplay));
}
const roleValue = shortInfoValueOrDash(overlayInfo.role || 'CLIENT');
if (roleValue !== '—') {
lines.push(`Role: ${escapeHtml(roleValue)}`);
+29
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { extractModemMetadata } from './node-modem-metadata.js';
const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' });
const TELEMETRY_LIMIT = 1;
const POSITION_LIMIT = 1;
@@ -130,6 +132,30 @@ function assignNumber(target, key, value, { preferExisting = false } = {}) {
target[key] = numericValue;
}
/**
* Merge modem preset and frequency metadata into the aggregate node object.
*
* @param {Object} target Mutable aggregate node reference.
* @param {*} source Source record inspected for modem attributes.
* @param {{ preferExisting?: boolean }} [options] Behaviour modifiers.
* @returns {void}
*/
function mergeModemMetadata(target, source, { preferExisting = false } = {}) {
if (!isObject(target)) return;
if (!source || typeof source !== 'object') return;
const metadata = extractModemMetadata(source);
if (metadata.modemPreset) {
if (!preferExisting || toTrimmedString(target.modemPreset) == null) {
target.modemPreset = metadata.modemPreset;
}
}
if (metadata.loraFreq != null) {
if (!preferExisting || toFiniteNumber(target.loraFreq) == null) {
target.loraFreq = metadata.loraFreq;
}
}
}
/**
* Merge base node fields from an arbitrary record into the aggregate node object.
*
@@ -145,6 +171,7 @@ function mergeNodeFields(target, record) {
assignString(target, 'longName', extractString(record, ['longName', 'long_name']));
assignString(target, 'role', extractString(record, ['role']));
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
mergeModemMetadata(target, record);
assignNumber(target, 'snr', extractNumber(record, ['snr']));
assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel']));
assignNumber(target, 'voltage', extractNumber(record, ['voltage']));
@@ -176,6 +203,7 @@ function mergeTelemetry(target, telemetry) {
target.telemetry = telemetry;
assignString(target, 'nodeId', extractString(telemetry, ['node_id', 'nodeId']), { preferExisting: true });
assignNumber(target, 'nodeNum', extractNumber(telemetry, ['node_num', 'nodeNum']), { preferExisting: true });
mergeModemMetadata(target, telemetry, { preferExisting: true });
assignNumber(target, 'battery', extractNumber(telemetry, ['battery_level', 'batteryLevel']), { preferExisting: true });
assignNumber(target, 'voltage', extractNumber(telemetry, ['voltage']), { preferExisting: true });
assignNumber(target, 'uptime', extractNumber(telemetry, ['uptime_seconds', 'uptimeSeconds']), { preferExisting: true });
@@ -408,6 +436,7 @@ export const __testUtils = {
extractNumber,
assignString,
assignNumber,
mergeModemMetadata,
mergeNodeFields,
mergeTelemetry,
mergePosition,
@@ -0,0 +1,95 @@
/*
* 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.
*/
/**
* Convert arbitrary input into a trimmed string representation.
*
* @param {*} value Candidate value.
* @returns {string|null} Trimmed string or ``null`` when empty.
*/
function toTrimmedString(value) {
if (value == null) return null;
const stringValue = String(value).trim();
return stringValue.length > 0 ? stringValue : null;
}
/**
* Normalize modem-related metadata from a node-shaped record.
*
* @param {*} source Arbitrary payload that may contain modem attributes.
* @returns {{ modemPreset: (string|null), loraFreq: (number|null) }} Normalized modem metadata.
*/
export function extractModemMetadata(source) {
if (!source || typeof source !== 'object') {
return { modemPreset: null, loraFreq: null };
}
const presetCandidate =
source.modemPreset ?? source.modem_preset ?? source.modempreset ?? source.ModemPreset ?? null;
const modemPreset = toTrimmedString(presetCandidate);
const freqCandidate = source.loraFreq ?? source.lora_freq ?? source.frequency ?? null;
const parsedFreq = Number(freqCandidate);
const loraFreq = Number.isFinite(parsedFreq) && parsedFreq > 0 ? parsedFreq : null;
return { modemPreset, loraFreq };
}
/**
* Format a numeric LoRa frequency in MHz with up to three fractional digits.
*
* @param {*} value Numeric frequency in MHz.
* @returns {string|null} Formatted frequency with units or ``null`` when invalid.
*/
export function formatLoraFrequencyMHz(value) {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return null;
}
const formatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
return `${formatter.format(numeric)}MHz`;
}
/**
* Produce a combined modem preset and frequency description suitable for overlays.
*
* @param {*} preset Raw modem preset value.
* @param {*} frequency Raw frequency value expressed in MHz.
* @returns {string|null} Human-readable description or ``null`` when no data available.
*/
export function formatModemDisplay(preset, frequency) {
const presetText = toTrimmedString(preset);
const freqText = formatLoraFrequencyMHz(frequency);
if (!presetText && !freqText) {
return null;
}
if (presetText && freqText) {
return `${presetText} (${freqText})`;
}
return presetText ?? freqText;
}
export const __testUtils = {
toTrimmedString,
};