mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-09 10:22:52 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user