web: define meshcore modem presets (#696)

* web: define meshcore modem presets

* web: address review comments
This commit is contained in:
l5y
2026-04-05 13:37:58 +02:00
committed by GitHub
parent adc122fce0
commit 3647cb125b
5 changed files with 356 additions and 8 deletions
@@ -178,3 +178,57 @@ test('normalizePresetSlot enforces placeholders and uppercase output', () => {
assert.equal(normalizePresetSlot(''), PRESET_PLACEHOLDER);
assert.equal(normalizePresetSlot(null), PRESET_PLACEHOLDER);
});
// ---------------------------------------------------------------------------
// abbreviatePreset — MeshCore SF/BW/CR presets
// ---------------------------------------------------------------------------
// [description, preset, freqMHz, expectedCode]
const ABBREVIATE_MESHCORE_CASES = [
['AU/NZ Wide → Wi', 'SF10/BW250/CR5', null, 'Wi'],
['EU/UK Narrow → Na', 'SF8/BW62/CR8', null, 'Na'],
['CZ/SK Narrow at 868 MHz → Na', 'SF7/BW62/CR5', 868, 'Na'],
['US/CA Narrow at 915 MHz → Na', 'SF7/BW62/CR5', 915, 'Na'],
['US/CA Narrow at exact 900 MHz boundary', 'SF7/BW62/CR5', 900, 'Na'],
['BW fallback Na when freq unknown', 'SF7/BW62/CR5', null, 'Na'],
['125 kHz BW fallback → St', 'SF9/BW125/CR6', null, 'St'],
['unknown BW → null', 'SF12/BW500/CR7', null, null],
];
for (const [desc, preset, freq, expected] of ABBREVIATE_MESHCORE_CASES) {
test(`abbreviatePreset MeshCore: ${desc}`, () => {
assert.equal(abbreviatePreset(preset, freq), expected);
});
}
test('abbreviatePreset leaves Meshtastic named presets unaffected', () => {
assert.equal(abbreviatePreset('MediumFast', null), 'MF');
assert.equal(abbreviatePreset('LongSlow', null), 'LS');
});
// ---------------------------------------------------------------------------
// extractChatMessageMetadata — SF/BW/CR preset + frequency
// ---------------------------------------------------------------------------
test('extractChatMessageMetadata produces Wi code for AU/NZ Wide with freq', () => {
const result = extractChatMessageMetadata({
region_frequency: 915,
modem_preset: 'SF10/BW250/CR5',
});
assert.equal(result.presetCode, 'Wi');
assert.equal(result.frequency, '915');
});
test('extractChatMessageMetadata produces Na code for EU/UK Narrow with freq', () => {
const result = extractChatMessageMetadata({
lora_freq: 868,
modem_preset: 'SF8/BW62/CR8',
});
assert.equal(result.presetCode, 'Na');
});
test('extractChatMessageMetadata uses BW fallback Na when freq is absent', () => {
const result = extractChatMessageMetadata({
modem_preset: 'SF7/BW62/CR5',
});
assert.equal(result.presetCode, 'Na');
});
@@ -16,7 +16,16 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay, __testUtils } from '../node-modem-metadata.js';
import {
extractModemMetadata,
formatLoraFrequencyMHz,
formatModemDisplay,
resolveMeshcorePresetDisplay,
formatPresetDisplay,
__testUtils,
} from '../node-modem-metadata.js';
const { toTrimmedString, parseMeshcorePresetTokens, bwToShortCode } = __testUtils;
describe('node-modem-metadata', () => {
it('extracts modem preset and frequency from mixed payloads', () => {
@@ -42,6 +51,12 @@ describe('node-modem-metadata', () => {
});
});
it('returns null metadata for null and non-object input', () => {
assert.deepEqual(extractModemMetadata(null), { modemPreset: null, loraFreq: null });
assert.deepEqual(extractModemMetadata('string'), { modemPreset: null, loraFreq: null });
assert.deepEqual(extractModemMetadata(42), { modemPreset: null, loraFreq: null });
});
it('formats positive frequencies with MHz suffix', () => {
assert.equal(formatLoraFrequencyMHz(915), '915MHz');
assert.equal(formatLoraFrequencyMHz(867.5), '867.5MHz');
@@ -49,17 +64,141 @@ describe('node-modem-metadata', () => {
assert.equal(formatLoraFrequencyMHz(null), null);
});
it('combines preset and frequency for overlay display', () => {
it('combines preset and frequency for overlay display — Meshtastic named preset', () => {
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('combines named MeshCore preset and frequency for overlay display', () => {
assert.equal(formatModemDisplay('SF10/BW250/CR5', 868), 'AU/NZ Wide (868MHz)');
});
it('handles string frequency in formatModemDisplay for MeshCore presets', () => {
// frequency is a string here; exercises the Number(frequency) branch.
assert.equal(formatModemDisplay('SF10/BW250/CR5', '915'), 'AU/NZ Wide (915MHz)');
});
it('passes null frequency to formatModemDisplay gracefully', () => {
assert.equal(formatModemDisplay('SF10/BW62/CR5', null), 'AU/NZ Narrow');
});
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);
});
// ---------------------------------------------------------------------------
// parseMeshcorePresetTokens
// ---------------------------------------------------------------------------
describe('parseMeshcorePresetTokens', () => {
it('returns null for non-SF/BW/CR strings', () => {
assert.equal(parseMeshcorePresetTokens('MediumFast'), null);
assert.equal(parseMeshcorePresetTokens('LongSlow'), null);
assert.equal(parseMeshcorePresetTokens(''), null);
assert.equal(parseMeshcorePresetTokens(null), null);
});
it('returns null when any token is missing', () => {
assert.equal(parseMeshcorePresetTokens('SF10/BW250'), null);
assert.equal(parseMeshcorePresetTokens('BW250/CR5'), null);
});
it('returns null when a token does not match the expected format', () => {
assert.equal(parseMeshcorePresetTokens('SF10/BW250/XX5'), null);
assert.equal(parseMeshcorePresetTokens('SF10/BW250/CR'), null);
});
it('parses a valid SF/BW/CR string', () => {
assert.deepEqual(parseMeshcorePresetTokens('SF10/BW250/CR5'), { sf: 10, bw: 250, cr: 5 });
});
it('is case-insensitive', () => {
assert.deepEqual(parseMeshcorePresetTokens('sf10/bw62/cr5'), { sf: 10, bw: 62, cr: 5 });
});
it('accepts tokens in any order', () => {
assert.deepEqual(parseMeshcorePresetTokens('CR5/SF7/BW62'), { sf: 7, bw: 62, cr: 5 });
});
it('handles decimal bandwidth values like 62.5', () => {
assert.deepEqual(parseMeshcorePresetTokens('SF7/BW62.5/CR5'), { sf: 7, bw: 62.5, cr: 5 });
});
});
// ---------------------------------------------------------------------------
// bwToShortCode
// ---------------------------------------------------------------------------
describe('bwToShortCode', () => {
it('maps 62 to Na', () => assert.equal(bwToShortCode(62), 'Na'));
it('maps 62.5 to Na', () => assert.equal(bwToShortCode(62.5), 'Na'));
it('maps 125 to St', () => assert.equal(bwToShortCode(125), 'St'));
it('maps 250 to Wi', () => assert.equal(bwToShortCode(250), 'Wi'));
it('returns null for unknown bandwidths', () => {
assert.equal(bwToShortCode(500), null);
assert.equal(bwToShortCode(31), null);
});
});
// ---------------------------------------------------------------------------
// resolveMeshcorePresetDisplay
// ---------------------------------------------------------------------------
describe('resolveMeshcorePresetDisplay', () => {
it('returns null for non-SF/BW/CR input', () => {
assert.equal(resolveMeshcorePresetDisplay('MediumFast', null), null);
assert.equal(resolveMeshcorePresetDisplay(null, null), null);
});
// Named preset table: [description, preset, freqMHz, expected]
const NAMED_CASES = [
['AU/NZ Wide', 'SF10/BW250/CR5', 915, { longName: 'AU/NZ Wide', shortCode: 'Wi', displayString: 'AU/NZ Wide' }],
['AU/NZ Narrow', 'SF10/BW62/CR5', 915, { longName: 'AU/NZ Narrow', shortCode: 'Na', displayString: 'AU/NZ Narrow' }],
['EU/UK Wide', 'SF11/BW250/CR5', 868, { longName: 'EU/UK Wide', shortCode: 'Wi', displayString: 'EU/UK Wide' }],
['EU/UK Narrow', 'SF8/BW62/CR8', 868, { longName: 'EU/UK Narrow', shortCode: 'Na', displayString: 'EU/UK Narrow' }],
['CZ/SK Narrow (freq < 900)', 'SF7/BW62/CR5', 868, { longName: 'CZ/SK Narrow', shortCode: 'Na', displayString: 'CZ/SK Narrow' }],
['US/CA Narrow (freq >= 900)', 'SF7/BW62/CR5', 915, { longName: 'US/CA Narrow', shortCode: 'Na', displayString: 'US/CA Narrow' }],
['US/CA Narrow (exact 900 boundary)', 'SF7/BW62/CR5', 900, { longName: 'US/CA Narrow', shortCode: 'Na', displayString: 'US/CA Narrow' }],
];
for (const [desc, preset, freq, expected] of NAMED_CASES) {
it(`resolves ${desc}`, () => {
assert.deepEqual(resolveMeshcorePresetDisplay(preset, freq), expected);
});
}
// Fallback cases: [description, preset, freqMHz, expected]
const FALLBACK_CASES = [
['SF7/BW62/CR5 with unknown freq uses BW fallback', 'SF7/BW62/CR5', null, { longName: null, shortCode: 'Na', displayString: 'BW62/SF7/CR5' }],
['unknown BW has no short code', 'SF12/BW500/CR7', null, { longName: null, shortCode: null, displayString: 'BW500/SF12/CR7' }],
['125 kHz BW gives St short code', 'SF9/BW125/CR6', null, { longName: null, shortCode: 'St', displayString: 'BW125/SF9/CR6' }],
];
for (const [desc, preset, freq, expected] of FALLBACK_CASES) {
it(`falls back: ${desc}`, () => {
assert.deepEqual(resolveMeshcorePresetDisplay(preset, freq), expected);
});
}
});
// ---------------------------------------------------------------------------
// formatPresetDisplay
// ---------------------------------------------------------------------------
describe('formatPresetDisplay', () => {
it('returns long name for named MeshCore presets', () => {
assert.equal(formatPresetDisplay('SF10/BW250/CR5', 915), 'AU/NZ Wide');
});
it('returns re-ordered BW/SF/CR for unknown SF/BW/CR presets', () => {
assert.equal(formatPresetDisplay('SF12/BW500/CR7', null), 'BW500/SF12/CR7');
});
it('returns raw string for non-SF/BW/CR presets', () => {
assert.equal(formatPresetDisplay('MediumFast', null), 'MediumFast');
});
it('returns null when preset is absent', () => {
assert.equal(formatPresetDisplay(null, null), null);
assert.equal(formatPresetDisplay(' ', null), null);
});
});
});
+15 -2
View File
@@ -41,7 +41,8 @@ export function extractChatMessageMetadata(message) {
);
const modemPreset = normalizePresetString(resolveModemPresetCandidate(message));
const presetCode = modemPreset ? abbreviatePreset(modemPreset) : null;
const numericFreq = frequency != null ? Number(frequency) : null;
const presetCode = modemPreset ? abbreviatePreset(modemPreset, numericFreq) : null;
return { frequency, channelName, presetCode };
}
@@ -186,6 +187,7 @@ function firstNonNull(...candidates) {
// normalizeString is the canonical implementation in utils.js; imported here
// so callers of chat-format.js that use it directly continue to work.
import { normalizeString } from './utils.js';
import { resolveMeshcorePresetDisplay } from './node-modem-metadata.js';
/**
* Convert various frequency representations into clean strings.
@@ -273,13 +275,24 @@ function normalizePresetString(value) {
/**
* Produce a two-character abbreviation for a modem preset.
*
* SF/BW/CR preset strings (MeshCore) are resolved via
* {@link resolveMeshcorePresetDisplay} so they bypass the Meshtastic
* initials-derivation path entirely. Meshtastic named presets (e.g.
* ``"MediumFast"``) are unaffected.
*
* @param {string} preset Normalized preset string.
* @param {number|null} [freqMHz] Frequency in MHz, used for frequency-gated lookups.
* @returns {string|null} Uppercase abbreviation or ``null``.
*/
function abbreviatePreset(preset) {
function abbreviatePreset(preset, freqMHz = null) {
if (!preset) {
return null;
}
// MeshCore SF/BW/CR presets take priority over the Meshtastic lookup table.
const resolved = resolveMeshcorePresetDisplay(preset, freqMHz);
if (resolved !== null) {
return resolved.shortCode;
}
const token = preset.replace(/[^A-Za-z]/g, '').toLowerCase();
if (token && PRESET_ABBREVIATIONS[token]) {
return PRESET_ABBREVIATIONS[token];
+3 -2
View File
@@ -56,7 +56,7 @@ import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { createNodeDetailOverlayManager } from './node-detail-overlay.js';
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay } from './node-modem-metadata.js';
import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay, formatPresetDisplay } from './node-modem-metadata.js';
import {
TELEMETRY_FIELDS,
buildTelemetryDisplayEntries,
@@ -4009,7 +4009,8 @@ export function initializeApp(config) {
const modemMetadata = extractModemMetadata(n);
const loraFrequencyText = formatLoraFrequencyMHz(modemMetadata.loraFreq);
const loraFrequencyDisplay = loraFrequencyText ? escapeHtml(loraFrequencyText) : '';
const modemPresetDisplay = modemMetadata.modemPreset ? escapeHtml(modemMetadata.modemPreset) : '';
const resolvedPreset = formatPresetDisplay(modemMetadata.modemPreset, modemMetadata.loraFreq);
const modemPresetDisplay = resolvedPreset ? escapeHtml(resolvedPreset) : '';
const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id);
const protocolIconCell = protocolIconPrefixHtml(n.protocol);
tr.innerHTML = `
+142 -1
View File
@@ -68,6 +68,144 @@ export function formatLoraFrequencyMHz(value) {
return `${formatter.format(numeric)}MHz`;
}
/**
* Named MeshCore modem preset definitions.
*
* Each entry describes a specific SF/BW/CR combination together with an
* optional frequency gate. Frequency-gated entries are skipped when
* ``freqMHz`` is not known.
*
* @type {Array<{
* sf: number, bw: number, cr: number,
* longName: string,
* minFreqMHz?: number, maxFreqMHz?: number
* }>}
*/
const MESHCORE_NAMED_PRESETS = [
{ sf: 10, bw: 250, cr: 5, longName: 'AU/NZ Wide' },
{ sf: 10, bw: 62, cr: 5, longName: 'AU/NZ Narrow' },
{ sf: 11, bw: 250, cr: 5, longName: 'EU/UK Wide' },
{ sf: 8, bw: 62, cr: 8, longName: 'EU/UK Narrow' },
// SF7/BW62/CR5 is region-disambiguated by frequency threshold.
{ sf: 7, bw: 62, cr: 5, longName: 'CZ/SK Narrow', maxFreqMHz: 900 },
{ sf: 7, bw: 62, cr: 5, longName: 'US/CA Narrow', minFreqMHz: 900 },
];
/**
* Parse an SF/BW/CR preset token string into its numeric components.
*
* Accepts any ordering of the three tokens separated by ``/``. Returns
* ``null`` when the string does not look like an SF/BW/CR pattern (e.g.
* Meshtastic named presets such as ``"MediumFast"``).
*
* @param {*} preset Candidate preset string.
* @returns {{ sf: number, bw: number, cr: number } | null} Parsed tokens or ``null``.
*/
function parseMeshcorePresetTokens(preset) {
const str = toTrimmedString(preset);
if (!str) return null;
const parts = str.split('/');
if (parts.length !== 3) return null;
const values = { sf: null, bw: null, cr: null };
for (const part of parts) {
const match = part.match(/^(SF|BW|CR)(\d+(?:\.\d+)?)$/i);
if (!match) return null;
const key = match[1].toLowerCase();
values[key] = Number(match[2]);
}
if (values.sf === null || values.bw === null || values.cr === null) return null;
return { sf: values.sf, bw: values.bw, cr: values.cr };
}
/**
* Map a LoRa bandwidth to the canonical short code used in preset display.
*
* Covers the three standard bandwidths (62 kHz / 62.5 kHz, 125 kHz,
* 250 kHz). Any other value returns ``null``.
*
* @param {number} bw Bandwidth in kHz.
* @returns {'Na'|'St'|'Wi'|null} Short code or ``null``.
*/
function bwToShortCode(bw) {
// Accept both 62 and 62.5 as the narrow band value.
if (bw === 62 || bw === 62.5) return 'Na';
if (bw === 125) return 'St';
if (bw === 250) return 'Wi';
return null;
}
/**
* Resolve a MeshCore SF/BW/CR preset into display metadata.
*
* Returns ``null`` for any preset that is not in SF/BW/CR format (e.g.
* Meshtastic named presets), so callers can fall back to their own
* handling.
*
* Algorithm:
* 1. Parse the preset into ``{sf, bw, cr}`` return ``null`` if not parseable.
* 2. Derive ``shortCode`` from BW alone (always BW-driven, not table-driven).
* 3. Walk ``MESHCORE_NAMED_PRESETS`` for a matching ``{sf, bw, cr}`` entry
* respecting any frequency gate. Frequency-gated entries are skipped when
* ``freqMHz`` is ``null``.
* 4. Named match ``displayString = longName``.
* No named match ``longName = null``, ``displayString = "BW{bw}/SF{sf}/CR{cr}"``.
*
* @param {*} preset Raw preset string.
* @param {number|null} freqMHz Frequency in MHz, or ``null`` when unknown.
* @returns {{ longName: string|null, shortCode: string|null, displayString: string } | null}
* Display metadata, or ``null`` when not an SF/BW/CR string.
*/
export function resolveMeshcorePresetDisplay(preset, freqMHz) {
const tokens = parseMeshcorePresetTokens(preset);
if (!tokens) return null;
const { sf, bw, cr } = tokens;
const shortCode = bwToShortCode(bw);
const match = MESHCORE_NAMED_PRESETS.find(entry => {
if (entry.sf !== sf || entry.bw !== bw || entry.cr !== cr) return false;
if (entry.maxFreqMHz !== undefined) {
if (freqMHz === null || freqMHz === undefined) return false;
if (freqMHz >= entry.maxFreqMHz) return false;
}
if (entry.minFreqMHz !== undefined) {
if (freqMHz === null || freqMHz === undefined) return false;
if (freqMHz < entry.minFreqMHz) return false;
}
return true;
});
if (match) {
return { longName: match.longName, shortCode, displayString: match.longName };
}
return {
longName: null,
shortCode,
displayString: `BW${bw}/SF${sf}/CR${cr}`,
};
}
/**
* Return the best available display string for a modem preset.
*
* For SF/BW/CR presets this returns either the named long name or the
* re-ordered ``BW/SF/CR`` fallback. For Meshtastic named presets and any
* other non-SF/BW/CR strings the raw trimmed value is returned unchanged.
*
* @param {*} preset Raw preset value.
* @param {number|null} [freqMHz] Frequency in MHz, used for frequency-gated lookups.
* @returns {string|null} Display string or ``null`` when no preset is available.
*/
export function formatPresetDisplay(preset, freqMHz = null) {
const resolved = resolveMeshcorePresetDisplay(preset, freqMHz);
if (resolved !== null) return resolved.displayString;
return toTrimmedString(preset) ?? null;
}
/**
* Produce a combined modem preset and frequency description suitable for overlays.
*
@@ -76,7 +214,8 @@ export function formatLoraFrequencyMHz(value) {
* @returns {string|null} Human-readable description or ``null`` when no data available.
*/
export function formatModemDisplay(preset, frequency) {
const presetText = toTrimmedString(preset);
const numericFreq = typeof frequency === 'number' ? frequency : Number(frequency);
const presetText = formatPresetDisplay(preset, Number.isFinite(numericFreq) && numericFreq > 0 ? numericFreq : null);
const freqText = formatLoraFrequencyMHz(frequency);
if (!presetText && !freqText) {
@@ -92,4 +231,6 @@ export function formatModemDisplay(preset, frequency) {
export const __testUtils = {
toTrimmedString,
parseMeshcorePresetTokens,
bwToShortCode,
};