From 3647cb125b1a3de19cc2da9406b9eae2b58d3f08 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:37:58 +0200 Subject: [PATCH] web: define meshcore modem presets (#696) * web: define meshcore modem presets * web: address review comments --- .../js/app/__tests__/chat-format.test.js | 54 +++++++ .../app/__tests__/node-modem-metadata.test.js | 145 +++++++++++++++++- web/public/assets/js/app/chat-format.js | 17 +- web/public/assets/js/app/main.js | 5 +- .../assets/js/app/node-modem-metadata.js | 143 ++++++++++++++++- 5 files changed, 356 insertions(+), 8 deletions(-) diff --git a/web/public/assets/js/app/__tests__/chat-format.test.js b/web/public/assets/js/app/__tests__/chat-format.test.js index 9492298..34427b6 100644 --- a/web/public/assets/js/app/__tests__/chat-format.test.js +++ b/web/public/assets/js/app/__tests__/chat-format.test.js @@ -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'); +}); 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 index 2d7c548..8f822e1 100644 --- a/web/public/assets/js/app/__tests__/node-modem-metadata.test.js +++ b/web/public/assets/js/app/__tests__/node-modem-metadata.test.js @@ -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); + }); + }); }); diff --git a/web/public/assets/js/app/chat-format.js b/web/public/assets/js/app/chat-format.js index 21a795b..03b3f13 100644 --- a/web/public/assets/js/app/chat-format.js +++ b/web/public/assets/js/app/chat-format.js @@ -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]; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 8a405cd..c57470b 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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 = ` diff --git a/web/public/assets/js/app/node-modem-metadata.js b/web/public/assets/js/app/node-modem-metadata.js index 833d0e8..d002606 100644 --- a/web/public/assets/js/app/node-modem-metadata.js +++ b/web/public/assets/js/app/node-modem-metadata.js @@ -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, };