From a3aef8cadd98d610370d9349a93aed61eb71a0fb Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 14 Oct 2025 20:59:47 +0200 Subject: [PATCH] Add modem preset display to node overlay (#340) * Add modem metadata line to node overlays * Ensure modem metadata loads for all overlays --- .../js/app/__tests__/node-details.test.js | 24 ++++- .../app/__tests__/node-modem-metadata.test.js | 65 +++++++++++++ web/public/assets/js/app/main.js | 13 +++ web/public/assets/js/app/node-details.js | 29 ++++++ .../assets/js/app/node-modem-metadata.js | 95 +++++++++++++++++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 web/public/assets/js/app/__tests__/node-modem-metadata.test.js create mode 100644 web/public/assets/js/app/node-modem-metadata.js diff --git a/web/public/assets/js/app/__tests__/node-details.test.js b/web/public/assets/js/app/__tests__/node-details.test.js index 2967fb3..bbe40e7 100644 --- a/web/public/assets/js/app/__tests__/node-details.test.js +++ b/web/public/assets/js/app/__tests__/node-details.test.js @@ -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); diff --git a/web/public/assets/js/app/__tests__/node-modem-metadata.test.js b/web/public/assets/js/app/__tests__/node-modem-metadata.test.js new file mode 100644 index 0000000..44f9145 --- /dev/null +++ b/web/public/assets/js/app/__tests__/node-modem-metadata.test.js @@ -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); + }); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 7f4138f..a7e2284 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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)}`); diff --git a/web/public/assets/js/app/node-details.js b/web/public/assets/js/app/node-details.js index 2cb4503..9bd9a9b 100644 --- a/web/public/assets/js/app/node-details.js +++ b/web/public/assets/js/app/node-details.js @@ -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, diff --git a/web/public/assets/js/app/node-modem-metadata.js b/web/public/assets/js/app/node-modem-metadata.js new file mode 100644 index 0000000..80b3eec --- /dev/null +++ b/web/public/assets/js/app/node-modem-metadata.js @@ -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, +};