From 22fa1e1b7aadea3c64714eccb0b32465cbf983d3 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 19:01:09 -0600 Subject: [PATCH 1/3] fix(meshtastic): keep LongFast MQTT on the right channel during radio sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial updateChannelKeys pushes while RF channels stream in were wiping LongFast from channelNameToIndex, so public MQTT traffic fell back to slot 0 (e.g. OnTrail) when cycling radios. Merge topic→index across incremental syncs and debounce channel-key pushes. --- docs/agents/mqtt.md | 2 +- docs/troubleshooting.md | 2 +- src/main/mqtt-manager.test.ts | 142 ++++++++++++++++++ src/main/mqtt-manager.ts | 59 +++++++- src/renderer/lib/timeConstants.ts | 6 + ...htasticRuntime.reconnect-hardening.test.ts | 6 +- src/renderer/runtime/useMeshtasticRuntime.ts | 27 +++- 7 files changed, 230 insertions(+), 14 deletions(-) diff --git a/docs/agents/mqtt.md b/docs/agents/mqtt.md index 122a7443f..0645517b4 100644 --- a/docs/agents/mqtt.md +++ b/docs/agents/mqtt.md @@ -2,4 +2,4 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshtastic/MeshCore MQTT ingest, channel key mapping, or the sticky BLE suppress. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback). **PSK shorthand aliases** (a single decoded byte, e.g. `AQ==` = `0x01`) are firmware-defined channel-key presets, not raw key material — `parsePsk()` and `normalizeMeshtasticPskTo16Bytes()` expand them via `expandMeshtasticPskAlias()` in `src/shared/meshtasticDefaultPublicPsk.ts` (alias `1` → firmware `defaultpsk` `d4f1bb3a20290759f0bcffabcf4e6901`; `2`-`10` → that key's last byte `+ (index - 1)`; `0` / undefined aliases zero-pad, matching "no encryption"/unknown). Zero-padding the raw alias byte instead (the pre-fix behavior) silently produces the wrong AES key and the wrong channel hash for every default/simple-preset-PSK channel — always fix both `MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` and main-process `DEFAULT_PSK` together (doc-commented as required to match) if this ever needs to change again; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. +Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback). **PSK shorthand aliases** (a single decoded byte, e.g. `AQ==` = `0x01`) are firmware-defined channel-key presets, not raw key material — `parsePsk()` and `normalizeMeshtasticPskTo16Bytes()` expand them via `expandMeshtasticPskAlias()` in `src/shared/meshtasticDefaultPublicPsk.ts` (alias `1` → firmware `defaultpsk` `d4f1bb3a20290759f0bcffabcf4e6901`; `2`-`10` → that key's last byte `+ (index - 1)`; `0` / undefined aliases zero-pad, matching "no encryption"/unknown). Zero-padding the raw alias byte instead (the pre-fix behavior) silently produces the wrong AES key and the wrong channel hash for every default/simple-preset-PSK channel — always fix both `MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` and main-process `DEFAULT_PSK` together (doc-commented as required to match) if this ever needs to change again; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). **`updateChannelKeys` topic→index is merge-safe** across incremental RF channel packets (partial OnTrail-only push must not wipe `LongFast@1`); slot takeover evicts another name on the same index; complete-cover pushes drop absent radio names. Runtime also **debounces** channel-key pushes (`MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS`) while configs stream. MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 00b89761b..40e173332 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1743,7 +1743,7 @@ On **Windows**, unread messages use a red taskbar overlay. On **Linux**, launche MQTT ingest must map inbound text to the **receiver's** local channel slot using the MQTT topic channel name (`LongFast`, regional names, etc.) via `channelNameToIndex`. `MeshPacket.channel` in the ServiceEnvelope is the **sender's** local RF slot and must not drive attribution — remote gateways often use a different slot layout (e.g. LongFast on slot 1 while you use slot 0). -Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, or MQTT connecting before RF channel configs arrive (cold-start empty map). +Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, MQTT connecting before RF channel configs arrive (cold-start empty map), or (fixed in current builds) a mid-stream radio sync that temporarily wiped `LongFast` while channel packets arrived one-by-one after cycling radios — topic→index updates are now merge-safe so a partial `OnTrail=0` push cannot drop `LongFast=1`. **Fix** diff --git a/src/main/mqtt-manager.test.ts b/src/main/mqtt-manager.test.ts index 865443249..56c458e00 100644 --- a/src/main/mqtt-manager.test.ts +++ b/src/main/mqtt-manager.test.ts @@ -1534,6 +1534,148 @@ describe('updateChannelKeys', () => { debugSpy.mockRestore(); }); + it('streaming RF channel sync: OnTrail-only partial push keeps LongFast@1 for topic ingest', () => { + // Bundle repro: channelNameToIndex updated (1): OnTrail=0 wiped LongFast before @1 arrived. + const manager = new MQTTManager(); + const access = mqttChannelTestAccess(manager); + stubMqttConnect(manager); + const onTrailPsk = Buffer.alloc(16, 0x11); + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/US/CO/', + autoLaunch: false, + }); + + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: onTrailPsk.toString('base64'), index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + { name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 0x22).toString('base64'), index: 2 }, + ]); + expect(manager.getChannelNameToIndex()).toEqual({ + OnTrail: 0, + LongFast: 1, + 'cm-west-slp': 2, + }); + + // Mid-stream re-push with only primary (as configs arrive one-by-one). + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: onTrailPsk.toString('base64'), index: 0 }, + ]); + expect(manager.getChannelNameToIndex().LongFast).toBe(1); + expect(manager.getChannelNameToIndex().OnTrail).toBe(0); + expect(manager.getChannelNameToIndex()['cm-west-slp']).toBe(2); + + const nodeId = 0xa6d7c69b; + const packetId = 0x4b2a1c01; + const dataBytes = toBinary( + DataSchema, + create(DataSchema, { + portnum: PortNum.TEXT_MESSAGE_APP, + payload: new TextEncoder().encode('within long fast'), + }), + ); + const payload = buildEnvelope({ + nodeId, + packetId, + dataBytes, + psk: DEFAULT_PSK, + channelName: 'LongFast', + channel: 0, + }); + + const messages: unknown[] = []; + manager.on('message', (m) => messages.push(m)); + access.onMessage('msh/US/CO/2/e/LongFast/!a6d7c69b', payload); + + expect(messages).toHaveLength(1); + expect((messages[0] as { channel: number }).channel).toBe(1); + + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: onTrailPsk.toString('base64'), index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + { name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 0x22).toString('base64'), index: 2 }, + ]); + expect(manager.getChannelNameToIndex().LongFast).toBe(1); + }); + + it('layout change: LongFast@1 then LongFast@0 updates topic map to slot 0', () => { + const manager = new MQTTManager(); + stubMqttConnect(manager); + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/', + autoLaunch: false, + }); + + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + ]); + expect(manager.getChannelNameToIndex().LongFast).toBe(1); + + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 0 }]); + expect(manager.getChannelNameToIndex().LongFast).toBe(0); + // Slot takeover: OnTrail must not keep slot 0 once LongFast claims it. + expect(manager.getChannelNameToIndex().OnTrail).toBeUndefined(); + }); + + it('slot takeover: OnTrail@0 evicts prior radio LongFast@0 from topic map', () => { + const manager = new MQTTManager(); + stubMqttConnect(manager); + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/', + autoLaunch: false, + }); + + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 0 }]); + expect(manager.getChannelNameToIndex().LongFast).toBe(0); + + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: Buffer.alloc(16, 3).toString('base64'), index: 0 }, + ]); + expect(manager.getChannelNameToIndex().OnTrail).toBe(0); + expect(manager.getChannelNameToIndex().LongFast).toBeUndefined(); + }); + + it('complete-cover push drops radio names no longer present', () => { + const manager = new MQTTManager(); + stubMqttConnect(manager); + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/', + autoLaunch: false, + }); + + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + { name: 'OldChan', pskBase64: Buffer.alloc(16, 4).toString('base64'), index: 2 }, + ]); + + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + { name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 5).toString('base64'), index: 2 }, + ]); + + expect(manager.getChannelNameToIndex().OldChan).toBeUndefined(); + expect(manager.getChannelNameToIndex()['cm-west-slp']).toBe(2); + expect(manager.getChannelNameToIndex().LongFast).toBe(1); + }); + it('Nathan/Colorado: radio LongFast@1 overrides manual LongFast@0 for topic attribution', () => { const manager = new MQTTManager(); const access = mqttChannelTestAccess(manager); diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index 0e724abb3..666795f56 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -305,6 +305,12 @@ export class MQTTManager extends EventEmitter { private channelKeysByName = new Map(); /** MQTT topic channel name → RF channel index for inbound message attribution. */ private channelNameToIndex = new Map(); + /** + * Topic→index last applied from radio `updateChannelKeys` (merged across partial RF + * channel streams). Kept separately so a mid-stream push of only slot 0 does not wipe + * LongFast@1 / other slots from {@link channelNameToIndex}. + */ + private radioTopicIndexByName = new Map(); /** Names registered by the last updateChannelKeys (radio); cleared on next sync. */ private radioChannelKeyNames = new Set(); /** Connection panel channel PSK lines from last connect (re-applied after radio sync). */ @@ -344,6 +350,7 @@ export class MQTTManager extends EventEmitter { this.currentSettings = settings; this.channelKeysByName.clear(); this.channelNameToIndex.clear(); + this.radioTopicIndexByName.clear(); this.radioChannelKeyNames.clear(); this.manualChannelPskLines = settings.channelPsks ?? []; this.manualChannelKeyNames.clear(); @@ -355,13 +362,19 @@ export class MQTTManager extends EventEmitter { this._doConnect(settings); } - /** Merge channel PSKs from connected radio (Android-like); replaces prior radio sync. */ + /** + * Merge channel PSKs / topic→index from the connected radio. + * PSK material from the prior radio sync is replaced each call; topic→index is + * merge-safe across incremental RF channel packets so a partial push (e.g. only + * OnTrail@0) does not drop LongFast@1 and mis-file public MQTT traffic onto slot 0. + */ updateChannelKeys(entries: MqttChannelKeyEntry[]): void { + // Decrypt keys: replace prior radio PSKs (attribution map is merge-safe below). for (const name of this.radioChannelKeyNames) { this.channelKeysByName.delete(name); - this.channelNameToIndex.delete(name); } this.radioChannelKeyNames.clear(); + const radioTopicIndices = new Map(); for (const entry of entries) { const name = entry.name.trim(); @@ -371,9 +384,6 @@ export class MQTTManager extends EventEmitter { const idx = entry.index >>> 0; if (idx <= 7) { radioTopicIndices.set(name, idx); - // Radio local slot is source of truth for topic→index attribution (even when a - // manual LongFast@0= line exists — Colorado / non-primary public layouts). - this.channelNameToIndex.set(name, idx); } } if (this.manualChannelKeyNames.has(name)) continue; @@ -388,10 +398,43 @@ export class MQTTManager extends EventEmitter { this.channelKeysByName.set(name, psk); this.radioChannelKeyNames.add(name); } - this.applyManualChannelPskLines(this.manualChannelPskLines); - // Manual lines may reset LongFast→0 (bare LongFast= or LongFast@0=). Re-apply radio - // topic indices so local RF layout wins for inbound MQTT channel attribution. + + const priorRadio = new Map(this.radioTopicIndexByName); + const priorIndexes = new Set(priorRadio.values()); + const newIndexes = new Set(radioTopicIndices.values()); + for (const [name, idx] of radioTopicIndices) { + // Slot takeover: another name must not keep this local RF slot for attribution. + for (const [otherName, otherIdx] of this.channelNameToIndex) { + if (otherIdx === idx && otherName !== name) { + this.channelNameToIndex.delete(otherName); + } + } + for (const [otherName, otherIdx] of this.radioTopicIndexByName) { + if (otherIdx === idx && otherName !== name) { + this.radioTopicIndexByName.delete(otherName); + } + } + this.radioTopicIndexByName.set(name, idx); + this.channelNameToIndex.set(name, idx); + } + + // Evict radio names absent from this push only when every prior radio slot is covered + // (full replace). Partial streams (OnTrail only) keep LongFast@1 / siblings. + const coversPrior = priorIndexes.size > 0 && [...priorIndexes].every((i) => newIndexes.has(i)); + if (coversPrior) { + for (const oldName of priorRadio.keys()) { + if (!radioTopicIndices.has(oldName)) { + this.radioTopicIndexByName.delete(oldName); + this.channelNameToIndex.delete(oldName); + } + } + } + + this.applyManualChannelPskLines(this.manualChannelPskLines); + // Manual lines may reset LongFast→0 (bare LongFast= or LongFast@0=). Re-apply the + // merged radio topic map so local RF layout wins for inbound MQTT attribution. + for (const [name, idx] of this.radioTopicIndexByName) { this.channelNameToIndex.set(name, idx); } this.rebuildAllDecryptKeys(); diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index 43febbc49..373a2af69 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -234,6 +234,12 @@ export const MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS = 30 * MS_PER_SECO /** BLE/serial configure stall watchdog — force disconnect if FromRadio progress stalls. */ export const MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS = 120 * MS_PER_SECOND; +/** + * Coalesce `mqtt.updateChannelKeys` while RF channel configs stream in one-by-one. + * Main-process topic→index is also merge-safe; this cuts IPC churn during configure. + */ +export const MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS = 300; + /** * Hard ceiling for one LoRa reconnect open+configure/attach attempt (Meshtastic + MeshCore), * applied to every transport. For BLE, covers createBleConnection attempts (~45–50s) + diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index 395daa566..faaca600b 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -481,10 +481,12 @@ describe('useMeshtasticRuntime Linux BLE reconnect peripheral id backfill', () = it('re-pushes MQTT channel keys when resolvedChannelConfigs change (RF after cold-start MQTT)', () => { // PacketRouter → deviceStore channel configs must re-sync topic→index after MQTT // connects with empty/MQTT-only maps (Colorado public LongFast on non-0 slot). + // Debounced while RF channels stream; main updateChannelKeys is merge-safe. expect(SOURCE).toMatch( - /channelConfigsRef\.current = resolvedChannelConfigs;\s*pushMqttChannelKeys\(\);/, + /channelConfigsRef\.current = resolvedChannelConfigs;\s*schedulePushMqttChannelKeys\(\);/, ); - expect(SOURCE).toMatch(/\[resolvedChannelConfigs, pushMqttChannelKeys\]/); + expect(SOURCE).toMatch(/\[resolvedChannelConfigs, schedulePushMqttChannelKeys\]/); + expect(SOURCE).toMatch(/MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS/); expect(SOURCE).toMatch(/meshtasticMqttChannelKeyEntries\(channelConfigsRef\.current\)/); expect(SOURCE).toMatch(/updateChannelKeys\(\{\s*entries\s*\}\)/); // Hook-state channelConfigs alone must not be the only push trigger (stays empty on RF path). diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 40d31f9cf..2a39a5831 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -248,6 +248,7 @@ import { waypointEventsToMeshWaypointMap, } from '../lib/storeRecordAdapters'; import { + MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS, MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES, MESHTASTIC_PACKET_DEDUP_TTL_MS, MESHTASTIC_POST_REBOOT_RECONNECT_DELAY_MS, @@ -741,6 +742,26 @@ export function useMeshtasticRuntime() { }); }, []); + const mqttChannelKeysDebounceRef = useRef | null>(null); + const schedulePushMqttChannelKeys = useCallback(() => { + if (mqttChannelKeysDebounceRef.current != null) { + clearTimeout(mqttChannelKeysDebounceRef.current); + } + mqttChannelKeysDebounceRef.current = setTimeout(() => { + mqttChannelKeysDebounceRef.current = null; + pushMqttChannelKeys(); + }, MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS); + }, [pushMqttChannelKeys]); + + useEffect(() => { + return () => { + if (mqttChannelKeysDebounceRef.current != null) { + clearTimeout(mqttChannelKeysDebounceRef.current); + mqttChannelKeysDebounceRef.current = null; + } + }; + }, []); + useEffect(() => { void hydrateLastRfSelfNodeIdFromAppSettings() .then((nodeNum) => { @@ -4543,10 +4564,12 @@ export function useMeshtasticRuntime() { // ref MQTT uplink reads must follow the resolved list (store first, hook state // for MQTT-only presets) rather than the hook state alone. Re-push topic→index // when RF channels land after a cold-start MQTT connect (LongFast may be non-0). + // Debounce while channels stream one-by-one; main-process updateChannelKeys is + // also merge-safe so a partial OnTrail-only push cannot wipe LongFast@1. useEffect(() => { channelConfigsRef.current = resolvedChannelConfigs; - pushMqttChannelKeys(); - }, [resolvedChannelConfigs, pushMqttChannelKeys]); + schedulePushMqttChannelKeys(); + }, [resolvedChannelConfigs, schedulePushMqttChannelKeys]); const resolvedModuleConfigs = useMemo(() => { if (!meshtasticIdentityId) return moduleConfigs; From 912a2f6a3e2012adce6af11fe5d048e31d4aa5f3 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 19:16:10 -0600 Subject: [PATCH 2/3] fix(meshtastic): harden MQTT channel sync across radio sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear radio topic→index/PSKs when radioSessionId changes, keep private channel keys across partial updateChannelKeys pushes, and cover debounce with a fake-timer unit test. --- docs/agents/mqtt.md | 2 +- docs/troubleshooting.md | 2 +- src/main/index.ts | 15 +++- src/main/mqtt-manager.test.ts | 89 +++++++++++++++++++ src/main/mqtt-manager.ts | 82 ++++++++++++----- src/preload/index.ts | 6 +- .../meshtasticMqttChannelKeysDebounce.test.ts | 48 ++++++++++ .../meshtasticMqttChannelKeysDebounce.ts | 30 +++++++ ...htasticRuntime.reconnect-hardening.test.ts | 4 +- src/renderer/runtime/useMeshtasticRuntime.ts | 51 +++++++---- src/shared/electron-api.types.ts | 1 + 11 files changed, 285 insertions(+), 45 deletions(-) create mode 100644 src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.ts diff --git a/docs/agents/mqtt.md b/docs/agents/mqtt.md index 0645517b4..20471d0dc 100644 --- a/docs/agents/mqtt.md +++ b/docs/agents/mqtt.md @@ -2,4 +2,4 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshtastic/MeshCore MQTT ingest, channel key mapping, or the sticky BLE suppress. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback). **PSK shorthand aliases** (a single decoded byte, e.g. `AQ==` = `0x01`) are firmware-defined channel-key presets, not raw key material — `parsePsk()` and `normalizeMeshtasticPskTo16Bytes()` expand them via `expandMeshtasticPskAlias()` in `src/shared/meshtasticDefaultPublicPsk.ts` (alias `1` → firmware `defaultpsk` `d4f1bb3a20290759f0bcffabcf4e6901`; `2`-`10` → that key's last byte `+ (index - 1)`; `0` / undefined aliases zero-pad, matching "no encryption"/unknown). Zero-padding the raw alias byte instead (the pre-fix behavior) silently produces the wrong AES key and the wrong channel hash for every default/simple-preset-PSK channel — always fix both `MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` and main-process `DEFAULT_PSK` together (doc-commented as required to match) if this ever needs to change again; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). **`updateChannelKeys` topic→index is merge-safe** across incremental RF channel packets (partial OnTrail-only push must not wipe `LongFast@1`); slot takeover evicts another name on the same index; complete-cover pushes drop absent radio names. Runtime also **debounces** channel-key pushes (`MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS`) while configs stream. MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. +Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback). **PSK shorthand aliases** (a single decoded byte, e.g. `AQ==` = `0x01`) are firmware-defined channel-key presets, not raw key material — `parsePsk()` and `normalizeMeshtasticPskTo16Bytes()` expand them via `expandMeshtasticPskAlias()` in `src/shared/meshtasticDefaultPublicPsk.ts` (alias `1` → firmware `defaultpsk` `d4f1bb3a20290759f0bcffabcf4e6901`; `2`-`10` → that key's last byte `+ (index - 1)`; `0` / undefined aliases zero-pad, matching "no encryption"/unknown). Zero-padding the raw alias byte instead (the pre-fix behavior) silently produces the wrong AES key and the wrong channel hash for every default/simple-preset-PSK channel — always fix both `MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` and main-process `DEFAULT_PSK` together (doc-commented as required to match) if this ever needs to change again; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). **`updateChannelKeys` topic→index and radio PSKs are merge-safe** across incremental RF channel packets (partial OnTrail-only push must not wipe `LongFast@1` or private-channel decrypt keys); slot takeover evicts another name on the same index; complete-cover pushes drop absent radio names. Pass **`radioSessionId`** (`rf:` / `rf:none`) so a new RF identity clears the prior radio's maps before merging. Runtime also **debounces** channel-key pushes (`MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS` via `createDebouncedMqttChannelKeysPush`) while configs stream. MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 40e173332..8323f1c37 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1743,7 +1743,7 @@ On **Windows**, unread messages use a red taskbar overlay. On **Linux**, launche MQTT ingest must map inbound text to the **receiver's** local channel slot using the MQTT topic channel name (`LongFast`, regional names, etc.) via `channelNameToIndex`. `MeshPacket.channel` in the ServiceEnvelope is the **sender's** local RF slot and must not drive attribution — remote gateways often use a different slot layout (e.g. LongFast on slot 1 while you use slot 0). -Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, MQTT connecting before RF channel configs arrive (cold-start empty map), or (fixed in current builds) a mid-stream radio sync that temporarily wiped `LongFast` while channel packets arrived one-by-one after cycling radios — topic→index updates are now merge-safe so a partial `OnTrail=0` push cannot drop `LongFast=1`. +Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, MQTT connecting before RF channel configs arrive (cold-start empty map), or (fixed in current builds) a mid-stream radio sync that temporarily wiped `LongFast` while channel packets arrived one-by-one after cycling radios — topic→index and radio PSKs are now merge-safe so a partial `OnTrail=0` push cannot drop `LongFast=1` or private decrypt keys, and `radioSessionId` clears prior radio maps when the RF identity changes. **Fix** diff --git a/src/main/index.ts b/src/main/index.ts index c2304047c..507e976be 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -949,6 +949,12 @@ function validateMqttUpdateChannelKeysArgs(args: unknown): void { } } } + if (a.radioSessionId !== undefined) { + if (typeof a.radioSessionId !== 'string') + throw new Error('mqtt:updateChannelKeys: radioSessionId must be a string'); + if (a.radioSessionId.length > 64) + throw new Error('mqtt:updateChannelKeys: radioSessionId too long'); + } } function validateMqttUpdateTopicPrefixArgs(args: unknown): void { @@ -3252,8 +3258,13 @@ ipcMain.handle('mqtt:updateChannelKeys', (event, args) => { try { console.debug('[IPC] mqtt:updateChannelKeys'); validateMqttUpdateChannelKeysArgs(args); - const a = args as { entries: { name: string; pskBase64: string }[] }; - mqttManager.updateChannelKeys(a.entries); + const a = args as { + entries: { name: string; pskBase64: string; index?: number }[]; + radioSessionId?: string; + }; + mqttManager.updateChannelKeys(a.entries, { + radioSessionId: a.radioSessionId, + }); } catch (err) { console.error( '[IPC] mqtt:updateChannelKeys failed:', diff --git a/src/main/mqtt-manager.test.ts b/src/main/mqtt-manager.test.ts index 56c458e00..3cc6ab0c5 100644 --- a/src/main/mqtt-manager.test.ts +++ b/src/main/mqtt-manager.test.ts @@ -1676,6 +1676,95 @@ describe('updateChannelKeys', () => { expect(manager.getChannelNameToIndex().LongFast).toBe(1); }); + it('radioSessionId change clears prior radio topic names before merge', () => { + const manager = new MQTTManager(); + stubMqttConnect(manager); + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/', + autoLaunch: false, + }); + + manager.updateChannelKeys( + [ + { name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + { name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 2).toString('base64'), index: 2 }, + ], + { radioSessionId: 'rf:111' }, + ); + expect(manager.getChannelNameToIndex()['cm-west-slp']).toBe(2); + + // Replacement radio: only primary so far — must not keep prior radio's cm-west-slp. + manager.updateChannelKeys( + [{ name: 'Primary', pskBase64: Buffer.alloc(16, 9).toString('base64'), index: 0 }], + { radioSessionId: 'rf:222' }, + ); + expect(manager.getChannelNameToIndex()).toEqual({ Primary: 0 }); + expect(manager.getChannelNameToIndex().LongFast).toBeUndefined(); + expect(manager.getChannelNameToIndex()['cm-west-slp']).toBeUndefined(); + }); + + it('partial push keeps private-channel PSK so inbound decrypt still works', () => { + const manager = new MQTTManager(); + const access = mqttChannelTestAccess(manager); + stubMqttConnect(manager); + const privatePsk = Buffer.alloc(16, 0xab); + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/US/CO/', + autoLaunch: false, + }); + + manager.updateChannelKeys([ + { name: 'OnTrail', pskBase64: privatePsk.toString('base64'), index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + ]); + + // Mid-stream: only LongFast arrives again — OnTrail PSK must remain decryptable. + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); + expect(access.channelKeysByName.get('OnTrail')?.equals(privatePsk)).toBe(true); + expect( + (manager as unknown as { allDecryptKeys: Buffer[] }).allDecryptKeys.some((k) => + k.equals(privatePsk), + ), + ).toBe(true); + + const nodeId = 0x11223344; + const packetId = 0x00000077; + const dataBytes = toBinary( + DataSchema, + create(DataSchema, { + portnum: PortNum.TEXT_MESSAGE_APP, + payload: new TextEncoder().encode('private after partial'), + }), + ); + const payload = buildEnvelope({ + nodeId, + packetId, + dataBytes, + psk: privatePsk, + channelName: 'OnTrail', + channel: 0, + }); + + const messages: unknown[] = []; + manager.on('message', (m) => messages.push(m)); + access.onMessage('msh/US/CO/2/e/OnTrail/!11223344', payload); + + expect(messages).toHaveLength(1); + expect((messages[0] as { payload: string; channel: number }).payload).toBe( + 'private after partial', + ); + expect((messages[0] as { channel: number }).channel).toBe(0); + }); + it('Nathan/Colorado: radio LongFast@1 overrides manual LongFast@0 for topic attribution', () => { const manager = new MQTTManager(); const access = mqttChannelTestAccess(manager); diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index 666795f56..e0318ec9b 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -265,6 +265,17 @@ export interface MqttChannelKeyEntry { index?: number; } +/** Options for {@link MQTTManager.updateChannelKeys}. */ +export interface MqttUpdateChannelKeysOptions { + /** + * RF radio session key (e.g. `rf:`). When it changes, prior radio + * topic→index and radio PSKs are cleared before merging this push so a + * replacement radio cannot inherit the previous radio's channel names. + * Use `rf:none` after RF disconnect while MQTT stays up. + */ + radioSessionId?: string; +} + function coordWarning(lat: number, lon: number): string | null { if (lat === 0 && lon === 0) return 'No GPS fix (0°, 0°)'; if (lat < -90 || lat > 90) return `Latitude out of range: ${lat.toFixed(4)}°`; @@ -311,7 +322,12 @@ export class MQTTManager extends EventEmitter { * LongFast@1 / other slots from {@link channelNameToIndex}. */ private radioTopicIndexByName = new Map(); - /** Names registered by the last updateChannelKeys (radio); cleared on next sync. */ + /** + * Last {@link MqttUpdateChannelKeysOptions.radioSessionId}; change clears radio maps + * so a new RF identity does not inherit the previous radio's topic→index / PSKs. + */ + private radioChannelSessionId: string | null = null; + /** Names registered by radio sync for PSK material; merge-safe like topic→index. */ private radioChannelKeyNames = new Set(); /** Connection panel channel PSK lines from last connect (re-applied after radio sync). */ private manualChannelPskLines: string[] = []; @@ -350,8 +366,8 @@ export class MQTTManager extends EventEmitter { this.currentSettings = settings; this.channelKeysByName.clear(); this.channelNameToIndex.clear(); - this.radioTopicIndexByName.clear(); - this.radioChannelKeyNames.clear(); + this.clearRadioChannelAttribution(); + this.radioChannelSessionId = null; this.manualChannelPskLines = settings.channelPsks ?? []; this.manualChannelKeyNames.clear(); this.decryptOnlyPsks = []; @@ -362,20 +378,43 @@ export class MQTTManager extends EventEmitter { this._doConnect(settings); } - /** - * Merge channel PSKs / topic→index from the connected radio. - * PSK material from the prior radio sync is replaced each call; topic→index is - * merge-safe across incremental RF channel packets so a partial push (e.g. only - * OnTrail@0) does not drop LongFast@1 and mis-file public MQTT traffic onto slot 0. - */ - updateChannelKeys(entries: MqttChannelKeyEntry[]): void { - // Decrypt keys: replace prior radio PSKs (attribution map is merge-safe below). + /** Drop radio-sourced topic→index and PSKs (manual Connection-panel lines stay until re-applied). */ + private clearRadioChannelAttribution(): void { for (const name of this.radioChannelKeyNames) { this.channelKeysByName.delete(name); } + for (const name of this.radioTopicIndexByName.keys()) { + this.channelNameToIndex.delete(name); + } this.radioChannelKeyNames.clear(); + this.radioTopicIndexByName.clear(); + } + + private evictRadioChannelName(name: string): void { + this.radioTopicIndexByName.delete(name); + this.channelNameToIndex.delete(name); + if (this.radioChannelKeyNames.has(name)) { + this.channelKeysByName.delete(name); + this.radioChannelKeyNames.delete(name); + } + } + + /** + * Merge channel PSKs / topic→index from the connected radio. + * Topic→index and radio PSKs are merge-safe across incremental RF channel packets so a + * partial push (e.g. only OnTrail@0) does not drop LongFast@1 or private-channel keys. + * Pass {@link MqttUpdateChannelKeysOptions.radioSessionId} so a new RF identity clears + * the prior radio's maps before merging. + */ + updateChannelKeys(entries: MqttChannelKeyEntry[], options?: MqttUpdateChannelKeysOptions): void { + const sessionId = options?.radioSessionId; + if (sessionId !== undefined && sessionId !== this.radioChannelSessionId) { + this.clearRadioChannelAttribution(); + this.radioChannelSessionId = sessionId; + } const radioTopicIndices = new Map(); + const radioPskByName = new Map(); for (const entry of entries) { const name = entry.name.trim(); const psk = parsePsk(entry.pskBase64); @@ -395,8 +434,7 @@ export class MQTTManager extends EventEmitter { ) { continue; } - this.channelKeysByName.set(name, psk); - this.radioChannelKeyNames.add(name); + radioPskByName.set(name, psk); } const priorRadio = new Map(this.radioTopicIndexByName); @@ -405,28 +443,32 @@ export class MQTTManager extends EventEmitter { for (const [name, idx] of radioTopicIndices) { // Slot takeover: another name must not keep this local RF slot for attribution. - for (const [otherName, otherIdx] of this.channelNameToIndex) { + for (const [otherName, otherIdx] of [...this.channelNameToIndex.entries()]) { if (otherIdx === idx && otherName !== name) { - this.channelNameToIndex.delete(otherName); + this.evictRadioChannelName(otherName); } } - for (const [otherName, otherIdx] of this.radioTopicIndexByName) { + for (const [otherName, otherIdx] of [...this.radioTopicIndexByName.entries()]) { if (otherIdx === idx && otherName !== name) { - this.radioTopicIndexByName.delete(otherName); + this.evictRadioChannelName(otherName); } } this.radioTopicIndexByName.set(name, idx); this.channelNameToIndex.set(name, idx); } + for (const [name, psk] of radioPskByName) { + this.channelKeysByName.set(name, psk); + this.radioChannelKeyNames.add(name); + } + // Evict radio names absent from this push only when every prior radio slot is covered - // (full replace). Partial streams (OnTrail only) keep LongFast@1 / siblings. + // (full replace). Partial streams (OnTrail only) keep LongFast@1 / private PSKs. const coversPrior = priorIndexes.size > 0 && [...priorIndexes].every((i) => newIndexes.has(i)); if (coversPrior) { for (const oldName of priorRadio.keys()) { if (!radioTopicIndices.has(oldName)) { - this.radioTopicIndexByName.delete(oldName); - this.channelNameToIndex.delete(oldName); + this.evictRadioChannelName(oldName); } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index b591e77ce..7c42ffcfa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -556,8 +556,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('mqtt:getClientId', protocol), getCachedNodes: () => ipcRenderer.invoke('mqtt:getCachedNodes'), getChannelNameToIndex: () => ipcRenderer.invoke('mqtt:getChannelNameToIndex'), - updateChannelKeys: (args: { entries: { name: string; pskBase64: string; index?: number }[] }) => - ipcRenderer.invoke('mqtt:updateChannelKeys', args), + updateChannelKeys: (args: { + entries: { name: string; pskBase64: string; index?: number }[]; + radioSessionId?: string; + }) => ipcRenderer.invoke('mqtt:updateChannelKeys', args), updateTopicPrefix: (args: { topicPrefix: string }) => ipcRenderer.invoke('mqtt:updateTopicPrefix', args), publish: (args: { diff --git a/src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.test.ts b/src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.test.ts new file mode 100644 index 000000000..862f09493 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS } from '../../lib/timeConstants'; +import { createDebouncedMqttChannelKeysPush } from './meshtasticMqttChannelKeysDebounce'; + +describe('createDebouncedMqttChannelKeysPush', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('coalesces two schedules into one push with latest closed-over entries', () => { + const updateChannelKeys = vi.fn(); + let entries = [ + { name: 'OnTrail', pskBase64: 'AA==', index: 0 }, + { name: 'LongFast', pskBase64: 'AQ==', index: 1 }, + ]; + const debouncer = createDebouncedMqttChannelKeysPush(() => { + updateChannelKeys({ entries: [...entries] }); + }, MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS); + + debouncer.schedule(); + entries = [{ name: 'OnTrail', pskBase64: 'AA==', index: 0 }]; + debouncer.schedule(); + + expect(updateChannelKeys).not.toHaveBeenCalled(); + vi.advanceTimersByTime(MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS); + expect(updateChannelKeys).toHaveBeenCalledTimes(1); + expect(updateChannelKeys).toHaveBeenCalledWith({ + entries: [{ name: 'OnTrail', pskBase64: 'AA==', index: 0 }], + }); + }); + + it('cancel before fire prevents IPC-style push (unmount while timer pending)', () => { + const updateChannelKeys = vi.fn(); + const debouncer = createDebouncedMqttChannelKeysPush(() => { + updateChannelKeys({ entries: [{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }] }); + }, MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS); + + debouncer.schedule(); + debouncer.cancel(); + vi.advanceTimersByTime(MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS); + expect(updateChannelKeys).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.ts b/src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.ts new file mode 100644 index 000000000..3e5b9477e --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticMqttChannelKeysDebounce.ts @@ -0,0 +1,30 @@ +/** + * Debounce Meshtastic MQTT channel-key pushes while RF channel configs stream in. + * `push` runs with whatever state it closes over / reads from refs at fire time. + */ +export interface DebouncedMqttChannelKeysPush { + schedule: () => void; + cancel: () => void; +} + +export function createDebouncedMqttChannelKeysPush( + push: () => void, + debounceMs: number, +): DebouncedMqttChannelKeysPush { + let timer: ReturnType | null = null; + return { + schedule() { + if (timer != null) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + push(); + }, debounceMs); + }, + cancel() { + if (timer != null) { + clearTimeout(timer); + timer = null; + } + }, + }; +} diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index faaca600b..6a91985e2 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -486,9 +486,11 @@ describe('useMeshtasticRuntime Linux BLE reconnect peripheral id backfill', () = /channelConfigsRef\.current = resolvedChannelConfigs;\s*schedulePushMqttChannelKeys\(\);/, ); expect(SOURCE).toMatch(/\[resolvedChannelConfigs, schedulePushMqttChannelKeys\]/); + expect(SOURCE).toMatch(/createDebouncedMqttChannelKeysPush/); expect(SOURCE).toMatch(/MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS/); + expect(SOURCE).toMatch(/radioSessionId/); expect(SOURCE).toMatch(/meshtasticMqttChannelKeyEntries\(channelConfigsRef\.current\)/); - expect(SOURCE).toMatch(/updateChannelKeys\(\{\s*entries\s*\}\)/); + expect(SOURCE).toMatch(/updateChannelKeys\(\{/); // Hook-state channelConfigs alone must not be the only push trigger (stays empty on RF path). expect(SOURCE).not.toMatch( /pushMqttChannelKeys\(\);\s*\}, \[channelConfigs, mqttStatus, pushMqttChannelKeys\]/, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 2a39a5831..3cd4eed42 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -143,6 +143,7 @@ import { markMeshtasticBroadcastPending, } from '../lib/meshtastic/meshtasticHeardRepeat'; import type { ModulePortEvent, PaxCounterPoint } from '../lib/meshtastic/meshtasticModuleEvents'; +import { createDebouncedMqttChannelKeysPush } from '../lib/meshtastic/meshtasticMqttChannelKeysDebounce'; import { normalizeMeshtasticMqttChatMessage } from '../lib/meshtastic/meshtasticMqttChatNormalize'; import { MeshtasticMqttClientProxyBridge } from '../lib/meshtastic/meshtasticMqttClientProxy'; import { @@ -724,13 +725,21 @@ export function useMeshtasticRuntime() { const pushMqttChannelKeys = useCallback(() => { if (mqttStatusRef.current !== 'connected') return; - let entries = meshtasticMqttChannelKeyEntries(channelConfigsRef.current); - if (entries.length === 0) { + const radioSessionId = + myNodeNumRef.current > 0 && deviceRef.current != null + ? `rf:${myNodeNumRef.current >>> 0}` + : 'rf:none'; + let entries = + radioSessionId === 'rf:none' + ? meshtasticMqttChannelKeyEntriesFromManual() + : meshtasticMqttChannelKeyEntries(channelConfigsRef.current); + if (radioSessionId !== 'rf:none' && entries.length === 0) { entries = meshtasticMqttChannelKeyEntriesFromManual(); } - if (entries.length === 0) return; + // Allow empty entries with rf:none so RF disconnect clears prior radio maps while MQTT stays up. + if (entries.length === 0 && radioSessionId !== 'rf:none') return; void window.electronAPI.mqtt - .updateChannelKeys({ entries }) + .updateChannelKeys({ entries, radioSessionId }) .then(() => window.electronAPI.mqtt.getChannelNameToIndex()) .then((map) => { setDebugSnapshotMeshtasticContext({ mqttChannelNameToIndex: map }); @@ -742,23 +751,23 @@ export function useMeshtasticRuntime() { }); }, []); - const mqttChannelKeysDebounceRef = useRef | null>(null); + const mqttChannelKeysPushLatestRef = useRef(pushMqttChannelKeys); + mqttChannelKeysPushLatestRef.current = pushMqttChannelKeys; + + const mqttChannelKeysDebouncerRef = useRef( + createDebouncedMqttChannelKeysPush(() => { + mqttChannelKeysPushLatestRef.current(); + }, MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS), + ); + const schedulePushMqttChannelKeys = useCallback(() => { - if (mqttChannelKeysDebounceRef.current != null) { - clearTimeout(mqttChannelKeysDebounceRef.current); - } - mqttChannelKeysDebounceRef.current = setTimeout(() => { - mqttChannelKeysDebounceRef.current = null; - pushMqttChannelKeys(); - }, MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS); - }, [pushMqttChannelKeys]); + mqttChannelKeysDebouncerRef.current.schedule(); + }, []); useEffect(() => { + const debouncer = mqttChannelKeysDebouncerRef.current; return () => { - if (mqttChannelKeysDebounceRef.current != null) { - clearTimeout(mqttChannelKeysDebounceRef.current); - mqttChannelKeysDebounceRef.current = null; - } + debouncer.cancel(); }; }, []); @@ -2786,8 +2795,10 @@ export function useMeshtasticRuntime() { batteryPercent: undefined, batteryCharging: undefined, }); + myNodeNumRef.current = 0; + pushMqttChannelKeys(); }, - [clearConfigureTimeout, cleanupSubscriptions, stopWatchdog], + [clearConfigureTimeout, cleanupSubscriptions, stopWatchdog, pushMqttChannelKeys], ); const finalizeDriverDisconnect = useCallback( @@ -2836,6 +2847,9 @@ export function useMeshtasticRuntime() { batteryPercent: undefined, batteryCharging: undefined, }); + myNodeNumRef.current = 0; + // Drop prior radio topic→index / PSKs while MQTT may stay connected across RF swaps. + pushMqttChannelKeys(); setConfigureTargetNodeNumState(null); configureTargetNodeNumRef.current = null; configureTargetPersistRestoredRef.current = false; @@ -2851,6 +2865,7 @@ export function useMeshtasticRuntime() { stopGpsInterval, clearConfigureTimeout, clearPostCommitRebootRecovery, + pushMqttChannelKeys, ], ); const connect = useCallback( diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 567aa1134..9c67f803b 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -830,6 +830,7 @@ export interface ElectronAPI { getChannelNameToIndex: () => Promise>; updateChannelKeys: (args: { entries: { name: string; pskBase64: string; index?: number }[]; + radioSessionId?: string; }) => Promise; updateTopicPrefix: (args: { topicPrefix: string }) => Promise; publish: (args: { From 8c147c979510c7a8046a03e3806553cd22c7ed4d Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 19:23:19 -0600 Subject: [PATCH 3/3] fix(meshtastic): clear MQTT radio maps on connection loss handleConnectionLost left myNodeNum and channelNameToIndex from the prior radio while MQTT stayed up; reset to rf:none after dropping deviceRef. --- ...useMeshtasticRuntime.reconnect-hardening.test.ts | 13 +++++++++++++ src/renderer/runtime/useMeshtasticRuntime.ts | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index 6a91985e2..1e9b9376a 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -233,6 +233,19 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { expect(cleanupIdx).toBeGreaterThan(safeDisconnectIdx); }); + it('handleConnectionLost clears MQTT radio session after deviceRef null (rf:none)', () => { + // MQTT often stays connected across BLE/serial link-loss; without rf:none the prior + // radio's channelNameToIndex / PSKs can mis-file LongFast until the next configure. + const lostBody = extractUseCallbackBody(SOURCE, 'handleConnectionLost'); + const deviceNullIdx = lostBody.indexOf('deviceRef.current = null'); + const myNodeClearIdx = lostBody.indexOf('myNodeNumRef.current = 0'); + const pushIdx = lostBody.indexOf('pushMqttChannelKeys()'); + expect(deviceNullIdx).toBeGreaterThanOrEqual(0); + expect(myNodeClearIdx).toBeGreaterThan(deviceNullIdx); + expect(pushIdx).toBeGreaterThan(myNodeClearIdx); + expect(pushIdx).toBeLessThan(lostBody.indexOf('cleanupSubscriptions()')); + }); + it('flushes deferred reconnects after non-BLE reconnect attempts settle', () => { expect(ATTEMPT_RUNNER).toContain('bleConnectInProgress?.set(false)'); expect(ATTEMPT_RUNNER).toContain('deferredReconnect.get()'); diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 3cd4eed42..9586ff172 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -2147,6 +2147,9 @@ export function useMeshtasticRuntime() { stopWatchdog(); stopGpsInterval(); deviceRef.current = null; + myNodeNumRef.current = 0; + // MQTT may stay up across RF link-loss; drop prior radio topic→index / PSKs (rf:none). + pushMqttChannelKeys(); meshtasticDriverConnectedRef.current = false; meshtasticPendingDriverIdentityRef.current = null; if (staleDevice) { @@ -2188,6 +2191,7 @@ export function useMeshtasticRuntime() { stopWatchdog, stopGpsInterval, clearPostCommitRebootRecovery, + pushMqttChannelKeys, ]); // Keep the ref in sync