From c29eeddf593800e21522729efe51f8c6ed5ccec8 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 09:41:18 -0600 Subject: [PATCH 1/2] fix: MeshMapper host, MQTT channel map, and MeshCore BLE ghost nodes Migrate mqtt.meshmapper.cc to .net (TLS), let radio topic indices win over manual LongFast@0 for Colorado layouts, expose channelNameToIndex in debug snapshots, and suppress Meshtastic last_heard for the connected MeshCore BLE MAC. --- docs/meshcore-meshtastic-parity.md | 2 +- docs/troubleshooting.md | 19 ++- src/main/index.ipc-security.test.ts | 1 + src/main/index.ts | 12 ++ src/main/mqtt-manager.test.ts | 113 ++++++++++++++++++ src/main/mqtt-manager.ts | 26 ++-- src/preload/index.ts | 1 + src/renderer/lib/connectedMeshcoreBleMac.ts | 17 +++ .../connectionPanelStorageMigrations.test.ts | 57 ++++++++- .../lib/connectionPanelStorageMigrations.ts | 36 +++++- src/renderer/lib/debugSnapshot.test.ts | 4 + src/renderer/lib/debugSnapshot.ts | 11 ++ .../debugSnapshotMeshtasticContext.test.ts | 21 +++- .../lib/debugSnapshotMeshtasticContext.ts | 8 ++ src/renderer/lib/letsMeshJwt.test.ts | 14 +++ src/renderer/lib/letsMeshJwt.ts | 15 ++- .../meshcoreBleMacMeshtasticNodeId.test.ts | 41 +++++++ .../lib/meshcoreBleMacMeshtasticNodeId.ts | 29 +++++ src/renderer/lib/meshcoreMqttPresets.test.ts | 19 ++- src/renderer/lib/meshcoreMqttUserHint.test.ts | 7 ++ src/renderer/lib/meshcoreMqttUserHint.ts | 7 ++ .../meshtasticRawPacketSideEffects.test.ts | 108 +++++++++++++++++ .../meshtasticRawPacketSideEffects.ts | 5 + src/renderer/locales/cs/translation.json | 5 +- src/renderer/locales/de/translation.json | 5 +- src/renderer/locales/en/translation.json | 5 +- src/renderer/locales/es/translation.json | 5 +- src/renderer/locales/fr/translation.json | 5 +- src/renderer/locales/id/translation.json | 5 +- src/renderer/locales/it/translation.json | 5 +- src/renderer/locales/ja/translation.json | 5 +- src/renderer/locales/ko/translation.json | 5 +- src/renderer/locales/nl/translation.json | 5 +- src/renderer/locales/pl/translation.json | 5 +- src/renderer/locales/pt-BR/translation.json | 5 +- src/renderer/locales/ru/translation.json | 5 +- src/renderer/locales/tr/translation.json | 5 +- src/renderer/locales/uk/translation.json | 5 +- src/renderer/locales/zh/translation.json | 5 +- src/renderer/runtime/useMeshcoreRuntime.ts | 4 + src/renderer/runtime/useMeshtasticRuntime.ts | 24 +++- src/renderer/vitest.electronApiMock.ts | 1 + src/shared/electron-api.types.ts | 1 + 43 files changed, 627 insertions(+), 56 deletions(-) create mode 100644 src/renderer/lib/connectedMeshcoreBleMac.ts create mode 100644 src/renderer/lib/meshcoreBleMacMeshtasticNodeId.test.ts create mode 100644 src/renderer/lib/meshcoreBleMacMeshtasticNodeId.ts diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 266c2542a..b1b020dc3 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -187,7 +187,7 @@ In **MeshCore** mode only, [`ConnectionPanel.tsx`](../src/renderer/components/Co | Preset | Broker host | Port | Notes | | --------------- | ------------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | LetsMesh | `mqtt-us-v1.letsmesh.net` or `mqtt-eu-v1.letsmesh.net` | 443 | **Default for new users.** **WebSocket** (`wss`). Topic prefix `meshcore/test`. JWT auth; see [Authentication](#meshcore-mqtt-authentication) below. Optional **Packet logger** publishes to `meshcore/packets`. See [`letsmesh-mqtt-auth.md`](letsmesh-mqtt-auth.md). | -| MeshMapper | `mqtt.meshmapper.cc` | 443 | **WebSocket** (`wss`). Topic prefix `meshcore/test`. | +| MeshMapper | `mqtt.meshmapper.net` | 443 | **WebSocket** (`wss`). Topic prefix `meshcore/test`. | | Colorado Mesh | `mqtt.meshcore.coloradomesh.org` | 443 | **Colorado residents only.** **WebSocket** (`wss`). Topic prefix `meshcore/DEN`. Confirm on select; one-time stay-or-switch gate for existing Colorado users. JWT device-signing auth. | | Ripple Networks | `mqtt.ripplenetworks.com.au` | 8883 | TLS; preset fills default shared credentials and **insecure TLS** for self-signed / non–public CA chains. Topic prefix `meshcore`. | | Custom | (user) | — | No automatic changes; use for private brokers | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4f8c54611..11891f8a9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1399,9 +1399,26 @@ Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: 1. Update to a build that prefers the **topic channel name** for MQTT text ingest (sampled log `mqtt-channel-topic-mismatch:*` when topic index disagrees with packet channel). 2. Connect the radio so channel keys and slot indexes sync to MQTT (`mqtt:updateChannelKeys` in logs after configure). 3. **MQTT-only (no radio):** add `ChannelName@index=base64` lines in Connection → Channel PSKs (e.g. `LongFast@1=AQ==` for Colorado-mesh slot-1 public). The Connection panel shows an inline hint when no radio is configured and no `@index` lines are present. -4. On **Export for Developer** / **Copy Debug Snapshot**, check `meshtastic.channelPills`, `meshtastic.channelConfigsSummary`, and `meshtastic.mqttChannelKeyEntryCount` — slot 1 with empty name and `isDefaultPublicPsk: true` is the common Colorado-mesh layout. +4. On **Export for Developer** / **Copy Debug Snapshot**, check `meshtastic.channelPills`, `meshtastic.channelConfigsSummary`, `meshtastic.mqttChannelKeyEntryCount`, and `meshtastic.mqttChannelNameToIndex` (main-process topic→slot map; e.g. `{ "LongFast": 1 }` for Colorado-style public on slot 1). Slot 1 with empty name and `isDefaultPublicPsk: true` is the common Colorado-mesh layout. 5. When reporting, note whether mis-filed messages are **MQTT-only**, **RF-only**, or **both**, and attach a Radio tab screenshot of channel names + slot indices. +### MeshCore-flashed radio still “Just now” on Meshtastic Nodes + +**Symptoms** + +- After flashing a radio from Meshtastic to MeshCore, the old Meshtastic NodeDB row (often short name / MAC-derived `!xxxxxxxx`) stays **online** / **Just now** on the Meshtastic tab while that hardware is the connected MeshCore BLE radio. +- Hops often show **0**; MQTT column may be `-` (RF-style refresh). + +**Cause** + +Meshtastic node numbers are frequently the lower 32 bits of the BLE MAC. With both a Meshtastic radio and that MeshCore radio on the same band, Blck (Meshtastic) can still “hear” MeshCore TX and bump `last_heard` on the **existing** stale NodeDB row (raw packet SNR path / MQTT minimal updates) without a real Meshtastic NodeInfo. + +**Fix** + +1. Update to a build that suppresses Meshtastic `last_heard` bumps for node IDs matching the **connected MeshCore BLE MAC**. +2. Until then: delete the ghost node on Meshtastic Nodes (it may return while both radios are on-air on older builds). +3. Confirm MeshCore Connection is BLE to that peripheral; Diagnostics foreign-LoRa is separate from the Nodes list. + ### Phantom chat unread on channels not on the radio **Symptoms** diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index c71e9ab8f..a4bfd5489 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -475,6 +475,7 @@ describe('privileged IPC sender validation (source contract)', () => { 'log:getPath', 'log:getRecentLines', 'mqtt:getCachedNodes', + 'mqtt:getChannelNameToIndex', 'mqtt:getClientId', 'storage:isAvailable', 'support:exportBundle', diff --git a/src/main/index.ts b/src/main/index.ts index 4a00ca815..edd089ae3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3261,6 +3261,18 @@ ipcMain.handle('mqtt:getCachedNodes', (event) => { throw err; } }); +ipcMain.handle('mqtt:getChannelNameToIndex', (event) => { + assertIpcSender(event, 'mqtt:getChannelNameToIndex'); + try { + return mqttManager.getChannelNameToIndex(); + } catch (err) { + console.error( + '[IPC] mqtt:getChannelNameToIndex failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } +}); ipcMain.handle('mqtt:publishNodeInfo', (event, args) => { assertIpcSender(event, 'mqtt:publishNodeInfo'); try { diff --git a/src/main/mqtt-manager.test.ts b/src/main/mqtt-manager.test.ts index a7b668d1d..b4b5559ca 100644 --- a/src/main/mqtt-manager.test.ts +++ b/src/main/mqtt-manager.test.ts @@ -1361,6 +1361,119 @@ describe('updateChannelKeys', () => { const nameToIndex: Map = (manager as any).channelNameToIndex; expect(nameToIndex.get('LongFast')).toBe(1); + expect(manager.getChannelNameToIndex()).toEqual({ LongFast: 1 }); + }); + + it('Nathan/Colorado: radio LongFast@1 overrides manual LongFast@0 for topic attribution', () => { + const manager = new MQTTManager(); + (manager as any)._doConnect = () => {}; + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/US/CO/', + autoLaunch: false, + channelPsks: ['LongFast@0=AQ=='], + }); + + expect((manager as any).channelNameToIndex.get('LongFast')).toBe(0); + + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); + + expect(manager.getChannelNameToIndex().LongFast).toBe(1); + // Manual default-public PSK preserved when radio also pushes AQ== + const byName: Map = (manager as any).channelKeysByName; + expect(byName.get('LongFast')?.equals(DEFAULT_PSK)).toBe(true); + }); + + it('Nathan/Colorado: inbound LongFast topic + packet channel 0 attributes to slot 1 after radio sync overrides @0', () => { + const manager = new MQTTManager(); + (manager as any)._doConnect = () => {}; + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/US/CO/', + autoLaunch: false, + channelPsks: ['LongFast@0=AQ=='], + }); + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); + + const nodeId = 0x095cf12b; + const packetId = 0x00000099; + const dataBytes = toBinary( + DataSchema, + create(DataSchema, { + portnum: PortNum.TEXT_MESSAGE_APP, + payload: new TextEncoder().encode('Good morning everyone!'), + }), + ); + const payload = buildEnvelope({ + nodeId, + packetId, + dataBytes, + psk: DEFAULT_PSK, + channelName: 'LongFast', + channel: 0, + }); + + const messages: unknown[] = []; + manager.on('message', (m) => messages.push(m)); + (manager as any).onMessage('msh/US/CO/2/e/LongFast/!095cf12b', payload); + + expect(messages).toHaveLength(1); + expect((messages[0] as { channel: number }).channel).toBe(1); + }); + + it('Nathan/Colorado: JSON LongFast with manual @0 then radio @1 attributes to slot 1', () => { + const manager = new MQTTManager(); + (manager as any)._doConnect = () => {}; + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/US/CO/', + autoLaunch: false, + channelPsks: ['LongFast@0=AQ=='], + }); + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); + + const nodeId = 0xaabbccdd; + const json = { + type: 'text', + from: nodeId, + channel: 0, + text: 'json public chat', + }; + + const messages: unknown[] = []; + manager.on('message', (m) => messages.push(m)); + (manager as any).onMessage( + 'msh/US/CO/2/json/LongFast/!aabbccdd', + Buffer.from(JSON.stringify(json)), + ); + + expect(messages).toHaveLength(1); + expect((messages[0] as { channel: number }).channel).toBe(1); + }); + + it('keeps LongFast@1 when manual and radio both say slot 1', () => { + const manager = new MQTTManager(); + (manager as any)._doConnect = () => {}; + manager.connect({ + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/', + autoLaunch: false, + channelPsks: ['LongFast@1=AQ=='], + }); + manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); + expect(manager.getChannelNameToIndex().LongFast).toBe(1); }); it('preserves manual Garber PSK when radio sync pushes a different key', () => { diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index cf77374a2..d04c3151c 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -360,9 +360,9 @@ export class MQTTManager extends EventEmitter { const idx = entry.index >>> 0; if (idx <= 7) { radioTopicIndices.set(name, idx); - if (!this.manualChannelKeyNames.has(name)) { - this.channelNameToIndex.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; @@ -378,15 +378,10 @@ export class MQTTManager extends EventEmitter { 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. for (const [name, idx] of radioTopicIndices) { - if (!this.manualChannelKeyNames.has(name)) continue; - const manualHasExplicitIndex = this.manualChannelPskLines.some((line) => { - const parsed = parseChannelPskLine(line); - return parsed?.name === name && parsed.index !== undefined; - }); - if (!manualHasExplicitIndex) { - this.channelNameToIndex.set(name, idx); - } + this.channelNameToIndex.set(name, idx); } this.rebuildAllDecryptKeys(); } @@ -1188,6 +1183,15 @@ export class MQTTManager extends EventEmitter { return this.status; } + /** Sanitized topic channel name → local slot (0–7) for debug triage — no PSKs. */ + getChannelNameToIndex(): Record { + const out: Record = {}; + for (const [name, idx] of this.channelNameToIndex) { + out[name] = idx; + } + return out; + } + getClientId(): string { return this.clientId; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 775d7b1a4..bf6df9abb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -531,6 +531,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getClientId: (protocol?: MeshProtocol): Promise => 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), updateTopicPrefix: (args: { topicPrefix: string }) => diff --git a/src/renderer/lib/connectedMeshcoreBleMac.ts b/src/renderer/lib/connectedMeshcoreBleMac.ts new file mode 100644 index 000000000..0fe4deafb --- /dev/null +++ b/src/renderer/lib/connectedMeshcoreBleMac.ts @@ -0,0 +1,17 @@ +/** BLE MAC / peripheral id of the live MeshCore RF link (null when not BLE-connected). */ +let connectedMeshcoreBleMac: string | null = null; + +/** Called from MeshCore runtime when a BLE session connects or disconnects. */ +export function setConnectedMeshcoreBleMac(mac: string | null): void { + const trimmed = mac?.trim() ?? ''; + connectedMeshcoreBleMac = trimmed.length > 0 ? trimmed : null; +} + +export function getConnectedMeshcoreBleMac(): string | null { + return connectedMeshcoreBleMac; +} + +/** Test helper — reset module state between cases. */ +export function resetConnectedMeshcoreBleMacForTests(): void { + connectedMeshcoreBleMac = null; +} diff --git a/src/renderer/lib/connectionPanelStorageMigrations.test.ts b/src/renderer/lib/connectionPanelStorageMigrations.test.ts index b74c8275c..cae6434ef 100644 --- a/src/renderer/lib/connectionPanelStorageMigrations.test.ts +++ b/src/renderer/lib/connectionPanelStorageMigrations.test.ts @@ -8,9 +8,11 @@ import { MESHCORE_MQTT_SETTINGS_KEY, MESHCORE_TOPIC_IATA_MIGRATION_KEY, MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, + MESHMAPPER_HOST_LEGACY_CC, + MESHMAPPER_HOST_NET_MIGRATION_KEY, runConnectionPanelStorageMigrations, } from './connectionPanelStorageMigrations'; -import { COLORADO_MESH_HOST, LETSMESH_HOST_US } from './letsMeshJwt'; +import { COLORADO_MESH_HOST, LETSMESH_HOST_US, MESHMAPPER_HOST } from './letsMeshJwt'; const store = new Map(); @@ -248,4 +250,57 @@ describe('runConnectionPanelStorageMigrations', () => { // Preset reconcile stamps DEN; shape migration also uppercases den → DEN. expect(parsed.topicPrefix).toBe('meshcore/DEN'); }); + + it('migrates MeshMapper mqtt.meshmapper.cc to mqtt.meshmapper.net', () => { + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'meshmapper'); + localStorage.setItem( + MESHCORE_MQTT_SETTINGS_KEY, + JSON.stringify({ + server: MESHMAPPER_HOST_LEGACY_CC, + topicPrefix: 'meshcore/test', + port: 443, + useWebSocket: true, + tlsEnabled: true, + wsPath: '/ws', + keepalive: 30, + password: '', + }), + ); + localStorage.setItem(MESHCORE_TOPIC_IATA_MIGRATION_KEY, '1'); + localStorage.setItem(COLORADO_MESH_PORT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_LETSMESH_DEFAULT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, '1'); + + runConnectionPanelStorageMigrations(); + + const parsed = JSON.parse(localStorage.getItem(MESHCORE_MQTT_SETTINGS_KEY) ?? '{}') as { + server?: string; + }; + expect(parsed.server).toBe(MESHMAPPER_HOST); + expect(localStorage.getItem(MESHMAPPER_HOST_NET_MIGRATION_KEY)).toBe('1'); + }); + + it('rewrites MeshMapper .cc even when migration marker is already set', () => { + localStorage.setItem(MESHMAPPER_HOST_NET_MIGRATION_KEY, '1'); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'custom'); + localStorage.setItem( + MESHCORE_MQTT_SETTINGS_KEY, + JSON.stringify({ + server: MESHMAPPER_HOST_LEGACY_CC, + topicPrefix: 'meshcore/test', + port: 443, + }), + ); + localStorage.setItem(MESHCORE_TOPIC_IATA_MIGRATION_KEY, '1'); + localStorage.setItem(COLORADO_MESH_PORT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_LETSMESH_DEFAULT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, '1'); + + runConnectionPanelStorageMigrations(); + + const parsed = JSON.parse(localStorage.getItem(MESHCORE_MQTT_SETTINGS_KEY) ?? '{}') as { + server?: string; + }; + expect(parsed.server).toBe(MESHMAPPER_HOST); + }); }); diff --git a/src/renderer/lib/connectionPanelStorageMigrations.ts b/src/renderer/lib/connectionPanelStorageMigrations.ts index 9df6ae7cd..b4fb34fb1 100644 --- a/src/renderer/lib/connectionPanelStorageMigrations.ts +++ b/src/renderer/lib/connectionPanelStorageMigrations.ts @@ -1,4 +1,10 @@ -import { COLORADO_MESH_HOST, isLetsMeshSettings } from './letsMeshJwt'; +import { + COLORADO_MESH_HOST, + isLetsMeshSettings, + MESHMAPPER_HOST, + MESHMAPPER_HOST_LEGACY_CC, + migrateMeshmapperServerHost, +} from './letsMeshJwt'; import { applyMeshcoreMqttPreset, MESHCORE_MQTT_PRESET_STORAGE_KEY, @@ -17,6 +23,7 @@ const MESHCORE_TOPIC_IATA_MIGRATION_KEY = 'mesh-client:migrated:meshcore-topic-i const COLORADO_MESH_PORT_MIGRATION_KEY = 'mesh-client:migrated:colorado-mesh-port-443-v1'; const MESHCORE_LETSMESH_DEFAULT_MIGRATION_KEY = 'mesh-client:migrated:meshcore-letsmesh-default-v1'; const MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY = 'mesh-client:migrated:meshcore-topic-iata-shape-v1'; +const MESHMAPPER_HOST_NET_MIGRATION_KEY = 'mesh-client:migrated:meshmapper-host-net-v1'; /** Set after the one-time Colorado-region ConfirmModal (or Colorado preset confirm). */ export const COLORADO_MQTT_REGION_ACK_KEY = 'mesh-client:coloradoMqttRegionAck-v1'; @@ -143,6 +150,29 @@ function migrateMeshcoreTopicIataShapeOnce(): void { localStorage.setItem(MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, '1'); } +/** + * Rewrite mqtt.meshmapper.cc → mqtt.meshmapper.net (TLS on `.cc` fails with alert 80). + * Also runs when migration marker is set if the legacy host is still present (idempotent rewrite). + */ +function migrateMeshmapperHostToNetOnce(): void { + const raw = localStorage.getItem(MESHCORE_MQTT_SETTINGS_KEY); + if (raw) { + const parsed = parseStoredJson>(raw, 'migrateMeshmapperHostToNetOnce'); + if (parsed && typeof parsed.server === 'string') { + const nextServer = migrateMeshmapperServerHost(parsed.server); + if (nextServer !== parsed.server) { + localStorage.setItem( + MESHCORE_MQTT_SETTINGS_KEY, + JSON.stringify({ ...parsed, server: nextServer }), + ); + } + } + } + if (localStorage.getItem(MESHMAPPER_HOST_NET_MIGRATION_KEY) === null) { + localStorage.setItem(MESHMAPPER_HOST_NET_MIGRATION_KEY, '1'); + } +} + /** Re-apply saved MeshCore network preset defaults when stored fields are stale. */ function reconcileMeshcoreMqttPresetSettings(): void { const preset = readStoredMeshcoreMqttPreset(); @@ -166,6 +196,7 @@ export function runConnectionPanelStorageMigrations(): void { migrateColoradoMeshPortOnce(); seedMeshcoreLetsMeshDefaultOnce(); migrateMeshcoreTopicIataShapeOnce(); + migrateMeshmapperHostToNetOnce(); reconcileMeshcoreMqttPresetSettings(); } @@ -176,4 +207,7 @@ export { MESHCORE_MQTT_SETTINGS_KEY, MESHCORE_TOPIC_IATA_MIGRATION_KEY, MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, + MESHMAPPER_HOST, + MESHMAPPER_HOST_LEGACY_CC, + MESHMAPPER_HOST_NET_MIGRATION_KEY, }; diff --git a/src/renderer/lib/debugSnapshot.test.ts b/src/renderer/lib/debugSnapshot.test.ts index a830afb75..251cbfc14 100644 --- a/src/renderer/lib/debugSnapshot.test.ts +++ b/src/renderer/lib/debugSnapshot.test.ts @@ -83,6 +83,7 @@ function makeMeshtasticBucketOverrides( channelPills: overrides.channelPills ?? [], channelConfigsSummary: overrides.channelConfigsSummary ?? [], mqttChannelKeyEntryCount: overrides.mqttChannelKeyEntryCount ?? null, + mqttChannelNameToIndex: overrides.mqttChannelNameToIndex ?? null, }; } @@ -214,6 +215,7 @@ describe('buildDebugSnapshot', () => { expect(snap.meshtastic.channelPills).toEqual([]); expect(snap.meshtastic.channelConfigsSummary).toEqual([]); expect(snap.meshtastic.mqttChannelKeyEntryCount).toBeNull(); + expect(snap.meshtastic.mqttChannelNameToIndex).toBeNull(); expect(snap.warnings).toEqual([]); }); @@ -241,6 +243,7 @@ describe('buildDebugSnapshot', () => { }, ], mqttChannelKeyEntryCount: 2, + mqttChannelNameToIndex: { LongFast: 1, Private: 0 }, }); const snap = buildDebugSnapshot(); @@ -252,6 +255,7 @@ describe('buildDebugSnapshot', () => { expect(snap.meshtastic.channelConfigsSummary).toHaveLength(2); expect(snap.meshtastic.channelConfigsSummary[1]?.isDefaultPublicPsk).toBe(true); expect(snap.meshtastic.mqttChannelKeyEntryCount).toBe(2); + expect(snap.meshtastic.mqttChannelNameToIndex).toEqual({ LongFast: 1, Private: 0 }); }); it('uses activeProtocol from ui context for activeTab including reticulum', () => { diff --git a/src/renderer/lib/debugSnapshot.ts b/src/renderer/lib/debugSnapshot.ts index d85061afe..b3f475e1b 100644 --- a/src/renderer/lib/debugSnapshot.ts +++ b/src/renderer/lib/debugSnapshot.ts @@ -13,8 +13,10 @@ import { type DebugSnapshotMeshtasticChannelConfigSummary, type DebugSnapshotMeshtasticChannelPill, getDebugSnapshotMeshtasticContext, + setDebugSnapshotMeshtasticContext, } from './debugSnapshotMeshtasticContext'; import { getDebugSnapshotUiContext } from './debugSnapshotUiContext'; +import { errLikeToLogString } from './errLikeToLogString'; import { resolveIdentityIdForProtocol, resolvePrimaryIdentityIdForProtocol, @@ -105,6 +107,8 @@ export interface DebugMeshtasticBucketSnapshot extends DebugIdentityBucketSnapsh channelPills: DebugSnapshotMeshtasticChannelPill[]; channelConfigsSummary: DebugSnapshotMeshtasticChannelConfigSummary[]; mqttChannelKeyEntryCount: number | null; + /** Main MQTTManager topic→slot map (no PSKs). */ + mqttChannelNameToIndex: Record | null; } export interface DebugChannelLastReadTriageRow { @@ -509,6 +513,7 @@ function buildMeshtasticBucketSnapshot(): DebugMeshtasticBucketSnapshot { channelPills: channelCtx.channelPills, channelConfigsSummary: channelCtx.channelConfigsSummary, mqttChannelKeyEntryCount: channelCtx.mqttChannelKeyEntryCount, + mqttChannelNameToIndex: channelCtx.mqttChannelNameToIndex, }; } @@ -587,6 +592,12 @@ export function buildDebugSnapshot(): DebugSnapshot { /** Full support snapshot including live Reticulum sidecar state for GitHub/developer bundles. */ export async function buildDebugSnapshotAsync(): Promise { const reticulumSidecar = await fetchReticulumDiagnosticSnapshot(); + try { + const map = await window.electronAPI.mqtt.getChannelNameToIndex(); + setDebugSnapshotMeshtasticContext({ mqttChannelNameToIndex: map }); + } catch (e: unknown) { + console.warn('[debugSnapshot] mqtt.getChannelNameToIndex failed ' + errLikeToLogString(e)); + } const base = buildDebugSnapshotBase(reticulumSidecar); const meshcoreContactPathDiagnostics = await fetchMeshcoreContactPathDiagnostics(); return { diff --git a/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts b/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts index 009c23d5a..3193e6284 100644 --- a/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts +++ b/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts @@ -1,19 +1,33 @@ -// @vitest-environment jsdom -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES } from '@/shared/meshtasticDefaultPublicPsk'; -import { buildDebugSnapshotMeshtasticContextFromRuntime } from './debugSnapshotMeshtasticContext'; +import { + buildDebugSnapshotMeshtasticContextFromRuntime, + resetDebugSnapshotMeshtasticContext, + setDebugSnapshotMeshtasticContext, +} from './debugSnapshotMeshtasticContext'; describe('buildDebugSnapshotMeshtasticContextFromRuntime', () => { + beforeEach(() => { + resetDebugSnapshotMeshtasticContext(); + }); + it('returns empty channel fields and null mqttChannelKeyEntryCount when configs are empty', () => { expect(buildDebugSnapshotMeshtasticContextFromRuntime([], [])).toEqual({ channelPills: [], channelConfigsSummary: [], mqttChannelKeyEntryCount: null, + mqttChannelNameToIndex: null, }); }); + it('preserves mqttChannelNameToIndex from prior setDebugSnapshotMeshtasticContext', () => { + setDebugSnapshotMeshtasticContext({ mqttChannelNameToIndex: { LongFast: 1 } }); + const ctx = buildDebugSnapshotMeshtasticContextFromRuntime([], []); + expect(ctx.mqttChannelNameToIndex).toEqual({ LongFast: 1 }); + }); + it('maps channel pills and config summary including default-public PSK detection', () => { const ctx = buildDebugSnapshotMeshtasticContextFromRuntime( [ @@ -53,5 +67,6 @@ describe('buildDebugSnapshotMeshtasticContextFromRuntime', () => { isDefaultPublicPsk: true, }); expect(ctx.mqttChannelKeyEntryCount).toBeGreaterThan(0); + expect(ctx.mqttChannelNameToIndex).toBeNull(); }); }); diff --git a/src/renderer/lib/debugSnapshotMeshtasticContext.ts b/src/renderer/lib/debugSnapshotMeshtasticContext.ts index 0e32856b8..b198d0f21 100644 --- a/src/renderer/lib/debugSnapshotMeshtasticContext.ts +++ b/src/renderer/lib/debugSnapshotMeshtasticContext.ts @@ -21,6 +21,11 @@ export interface DebugSnapshotMeshtasticContext { channelConfigsSummary: DebugSnapshotMeshtasticChannelConfigSummary[]; /** Count from meshtasticMqttChannelKeyEntries when configs are known; null when empty. */ mqttChannelKeyEntryCount: number | null; + /** + * Main-process topic channel name → local slot (from mqtt.getChannelNameToIndex). + * Null when unknown / not yet fetched; empty object when MQTT map is empty. + */ + mqttChannelNameToIndex: Record | null; } export interface MeshtasticRuntimeChannelPillInput { @@ -40,6 +45,7 @@ const defaultMeshtasticContext: DebugSnapshotMeshtasticContext = { channelPills: [], channelConfigsSummary: [], mqttChannelKeyEntryCount: null, + mqttChannelNameToIndex: null, }; let meshtasticContext: DebugSnapshotMeshtasticContext = { ...defaultMeshtasticContext }; @@ -60,6 +66,8 @@ export function buildDebugSnapshotMeshtasticContextFromRuntime( isDefaultPublicPsk: isMeshtasticDefaultPublicPsk(c.psk), })), mqttChannelKeyEntryCount: keyEntries.length > 0 ? keyEntries.length : null, + // Preserved via setDebugSnapshotMeshtasticContext merge from MQTT push / async snapshot fetch. + mqttChannelNameToIndex: meshtasticContext.mqttChannelNameToIndex, }; } diff --git a/src/renderer/lib/letsMeshJwt.test.ts b/src/renderer/lib/letsMeshJwt.test.ts index a42619a50..2e741447d 100644 --- a/src/renderer/lib/letsMeshJwt.test.ts +++ b/src/renderer/lib/letsMeshJwt.test.ts @@ -13,6 +13,8 @@ import { MESHCORE_PUBLIC_KEY_LENGTH, meshcoreIdentityHasPrivateKey, MESHMAPPER_HOST, + MESHMAPPER_HOST_LEGACY_CC, + migrateMeshmapperServerHost, readMeshcoreIdentity, tryPersistMeshcoreIdentityFromRadioExport, tryPersistMeshcorePublicKeyFromRadio, @@ -47,6 +49,18 @@ describe('letsMeshJwt', () => { it('isLetsMeshSettings matches MeshMapper host', () => { expect(isLetsMeshSettings(MESHMAPPER_HOST)).toBe(true); + expect(MESHMAPPER_HOST).toBe('mqtt.meshmapper.net'); + }); + + it('isLetsMeshSettings still matches legacy MeshMapper .cc host', () => { + expect(isLetsMeshSettings(MESHMAPPER_HOST_LEGACY_CC)).toBe(true); + }); + + it('migrateMeshmapperServerHost rewrites .cc to .net', () => { + expect(migrateMeshmapperServerHost('mqtt.meshmapper.cc')).toBe(MESHMAPPER_HOST); + expect(migrateMeshmapperServerHost(' mqtt.meshmapper.cc ')).toBe(MESHMAPPER_HOST); + expect(migrateMeshmapperServerHost(MESHMAPPER_HOST)).toBe(MESHMAPPER_HOST); + expect(migrateMeshmapperServerHost(LETSMESH_HOST_US)).toBe(LETSMESH_HOST_US); }); it('letsMeshJwtAudience uses trimmed MQTT server hostname as aud', () => { diff --git a/src/renderer/lib/letsMeshJwt.ts b/src/renderer/lib/letsMeshJwt.ts index 004ea5608..10b0ae144 100644 --- a/src/renderer/lib/letsMeshJwt.ts +++ b/src/renderer/lib/letsMeshJwt.ts @@ -17,8 +17,13 @@ export const MESHCORE_ENC_PK_KEY = 'mesh-client:meshcoreIdentityEncPK'; export const LETSMESH_HOST_US = 'mqtt-us-v1.letsmesh.net'; /** EU LetsMesh broker (WebSocket TLS on 443). */ export const LETSMESH_HOST_EU = 'mqtt-eu-v1.letsmesh.net'; -/** MeshMapper broker (WebSocket TLS on 443). */ -export const MESHMAPPER_HOST = 'mqtt.meshmapper.cc'; +/** MeshMapper broker (WebSocket TLS on 443). Canonical host is `.net` (wiki / working TLS). */ +export const MESHMAPPER_HOST = 'mqtt.meshmapper.net'; +/** + * Legacy MeshMapper hostname (TLS alert on `.cc` SNI). Migrated to {@link MESHMAPPER_HOST} on load. + * Still treated as a device-signing host so mid-session JWT paths do not break before rewrite. + */ +export const MESHMAPPER_HOST_LEGACY_CC = 'mqtt.meshmapper.cc'; /** Colorado Mesh broker (WebSocket TLS on 443). */ export const COLORADO_MESH_HOST = 'mqtt.meshcore.coloradomesh.org'; @@ -30,6 +35,7 @@ const DEVICE_SIGNING_HOSTS = new Set([ LETSMESH_HOST_US, LETSMESH_HOST_EU, MESHMAPPER_HOST, + MESHMAPPER_HOST_LEGACY_CC, COLORADO_MESH_HOST, ]); @@ -37,6 +43,11 @@ export function isLetsMeshSettings(server: string): boolean { return DEVICE_SIGNING_HOSTS.has(server.trim()); } +/** Rewrite legacy MeshMapper `.cc` host to the canonical `.net` broker. */ +export function migrateMeshmapperServerHost(server: string): string { + return server.trim() === MESHMAPPER_HOST_LEGACY_CC ? MESHMAPPER_HOST : server; +} + /** * JWT `aud` for `createAuthToken`: trimmed MQTT server hostname (must match broker * `AUTH_EXPECTED_AUDIENCE` when set). Public LetsMesh US/EU use the regional broker host diff --git a/src/renderer/lib/meshcoreBleMacMeshtasticNodeId.test.ts b/src/renderer/lib/meshcoreBleMacMeshtasticNodeId.test.ts new file mode 100644 index 000000000..eef2fd02a --- /dev/null +++ b/src/renderer/lib/meshcoreBleMacMeshtasticNodeId.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { + meshcoreBleMacToMeshtasticNodeId, + shouldSuppressMeshtasticNodeHear, +} from './meshcoreBleMacMeshtasticNodeId'; + +describe('meshcoreBleMacToMeshtasticNodeId', () => { + it('maps Nathan Blue MAC cc:2e:e3:da:2e:2f to 0xe3da2e2f', () => { + expect(meshcoreBleMacToMeshtasticNodeId('cc:2e:e3:da:2e:2f')).toBe(0xe3da2e2f); + }); + + it('accepts colon-free and mixed-case MACs', () => { + expect(meshcoreBleMacToMeshtasticNodeId('CC2EE3DA2E2F')).toBe(0xe3da2e2f); + expect(meshcoreBleMacToMeshtasticNodeId('cc-2e-e3-da-2e-2f')).toBe(0xe3da2e2f); + }); + + it('returns null for invalid MACs', () => { + expect(meshcoreBleMacToMeshtasticNodeId('')).toBeNull(); + expect(meshcoreBleMacToMeshtasticNodeId('aabb')).toBeNull(); + expect(meshcoreBleMacToMeshtasticNodeId('not-a-mac')).toBeNull(); + }); +}); + +describe('shouldSuppressMeshtasticNodeHear', () => { + it('suppresses matching node while MeshCore BLE MAC is connected', () => { + expect(shouldSuppressMeshtasticNodeHear(0xe3da2e2f, 'cc:2e:e3:da:2e:2f')).toBe(true); + expect(shouldSuppressMeshtasticNodeHear(3822726703, 'cc2ee3da2e2f')).toBe(true); + }); + + it('does not suppress when MeshCore BLE is disconnected', () => { + expect(shouldSuppressMeshtasticNodeHear(0xe3da2e2f, null)).toBe(false); + expect(shouldSuppressMeshtasticNodeHear(0xe3da2e2f, undefined)).toBe(false); + expect(shouldSuppressMeshtasticNodeHear(0xe3da2e2f, '')).toBe(false); + }); + + it('does not suppress unrelated nodes or self-style ids while MeshCore connected', () => { + expect(shouldSuppressMeshtasticNodeHear(0x3183af1e, 'cc:2e:e3:da:2e:2f')).toBe(false); + expect(shouldSuppressMeshtasticNodeHear(0, 'cc:2e:e3:da:2e:2f')).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshcoreBleMacMeshtasticNodeId.ts b/src/renderer/lib/meshcoreBleMacMeshtasticNodeId.ts new file mode 100644 index 000000000..e71691f7e --- /dev/null +++ b/src/renderer/lib/meshcoreBleMacMeshtasticNodeId.ts @@ -0,0 +1,29 @@ +import { normalizeBleMac } from '@/shared/normalizeBleMac'; + +/** + * Meshtastic nodeNum from a BLE MAC: lower 32 bits (last 4 octets). + * Example: `cc:2e:e3:da:2e:2f` → `0xe3da2e2f`. + */ +export function meshcoreBleMacToMeshtasticNodeId(mac: string): number | null { + const normalized = normalizeBleMac(mac); + const hex = normalized.replace(/[^0-9a-f]/gi, '').toLowerCase(); + if (hex.length !== 12) return null; + const lower32 = Number.parseInt(hex.slice(4), 16); + if (!Number.isFinite(lower32)) return null; + return lower32 >>> 0; +} + +/** + * True when `nodeId` is the MAC-derived Meshtastic identity of the currently + * connected MeshCore BLE radio — co-channel MeshCore TX must not keep that + * ghost Meshtastic NodeDB row "Just now". + */ +export function shouldSuppressMeshtasticNodeHear( + nodeId: number, + connectedMeshcoreBleMac: string | null | undefined, +): boolean { + if (nodeId === 0 || !connectedMeshcoreBleMac) return false; + const macNodeId = meshcoreBleMacToMeshtasticNodeId(connectedMeshcoreBleMac); + if (macNodeId == null) return false; + return nodeId >>> 0 === macNodeId; +} diff --git a/src/renderer/lib/meshcoreMqttPresets.test.ts b/src/renderer/lib/meshcoreMqttPresets.test.ts index caa7dbd7a..14fc47b1a 100644 --- a/src/renderer/lib/meshcoreMqttPresets.test.ts +++ b/src/renderer/lib/meshcoreMqttPresets.test.ts @@ -1,7 +1,12 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { COLORADO_MESH_HOST, LETSMESH_HOST_EU, LETSMESH_HOST_US } from './letsMeshJwt'; +import { + COLORADO_MESH_HOST, + LETSMESH_HOST_EU, + LETSMESH_HOST_US, + MESHMAPPER_HOST, +} from './letsMeshJwt'; import { applyMeshcoreMqttPreset, readStoredMeshcoreMqttPreset } from './meshcoreMqttPresets'; import type { MQTTSettings } from './types'; @@ -56,6 +61,18 @@ describe('applyMeshcoreMqttPreset', () => { const next = applyMeshcoreMqttPreset('letsmesh', { ...base, server: COLORADO_MESH_HOST }); expect(next.server).toBe(LETSMESH_HOST_US); }); + + it('applies MeshMapper .net host with WSS on 443', () => { + const next = applyMeshcoreMqttPreset('meshmapper', base); + expect(next.server).toBe(MESHMAPPER_HOST); + expect(MESHMAPPER_HOST).toBe('mqtt.meshmapper.net'); + expect(next.port).toBe(443); + expect(next.useWebSocket).toBe(true); + expect(next.tlsEnabled).toBe(true); + expect(next.wsPath).toBe('/ws'); + expect(next.topicPrefix).toBe('meshcore/test'); + expect(next.password).toBe(''); + }); }); describe('readStoredMeshcoreMqttPreset', () => { diff --git a/src/renderer/lib/meshcoreMqttUserHint.test.ts b/src/renderer/lib/meshcoreMqttUserHint.test.ts index 0b28d8c22..f9ded909e 100644 --- a/src/renderer/lib/meshcoreMqttUserHint.test.ts +++ b/src/renderer/lib/meshcoreMqttUserHint.test.ts @@ -35,6 +35,13 @@ describe('meshcoreMqttUserFacingHint', () => { expect(meshcoreUserMessageKey(out)).toBe('meshcore.mqttHints.keepalive'); }); + it('returns tlsHandshake hint for EPROTO / TLSV1_ALERT_INTERNAL_ERROR', () => { + const out = meshcoreMqttUserFacingHint( + 'write EPROTO 30458176:error:10000438:SSL routines:OPENSSL_internal:TLSV1_ALERT_INTERNAL_ERROR:../../third_party/boringssl/src/ssl/tls_record.cc:486:SSL alert number 80', + ); + expect(meshcoreUserMessageKey(out)).toBe('meshcore.mqttHints.tlsHandshake'); + }); + it('passes through unrelated messages unchanged', () => { expect(meshcoreMqttUserFacingHint('Something else')).toBe('Something else'); }); diff --git a/src/renderer/lib/meshcoreMqttUserHint.ts b/src/renderer/lib/meshcoreMqttUserHint.ts index f27f77dfd..5a8fa694a 100644 --- a/src/renderer/lib/meshcoreMqttUserHint.ts +++ b/src/renderer/lib/meshcoreMqttUserHint.ts @@ -27,5 +27,12 @@ export function meshcoreMqttUserFacingHint(rawMessage: string): MeshcoreUserMess if (/keepalive/i.test(m)) { return mqttPrefixedHint(m, 'meshcore.mqttHints.keepalive'); } + if ( + /\bEPROTO\b/i.test(m) || + /TLSV1_ALERT_INTERNAL_ERROR/i.test(m) || + /OPENSSL_internal:TLSV1_ALERT/i.test(m) + ) { + return mqttPrefixedHint(m, 'meshcore.mqttHints.tlsHandshake'); + } return m; } diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts index 5551ebac6..28675437c 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts @@ -3,6 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; import { useNodeStore } from '../../stores/nodeStore'; +import { + resetConnectedMeshcoreBleMacForTests, + setConnectedMeshcoreBleMac, +} from '../connectedMeshcoreBleMac'; import { packetRouter } from '../drivers/PacketRouter'; import { syncNodesMapToIdentityStore } from '../hydrateIdentityStoresFromDb'; import { getIdentityNode } from '../identityStoreReads'; @@ -16,6 +20,8 @@ import { const IDENTITY = 'id-raw-se'; const MY_NODE = 1; const PEER = 99; +/** Nathan Blue — MeshCore BLE MAC cc:2e:e3:da:2e:2f */ +const BLUE_NODE = 0xe3da2e2f; function emptyNode(nodeId: number): MeshNode { return { @@ -51,6 +57,7 @@ function makeDeps(overrides: Partial = {}) { describe('attachMeshtasticRawPacketSideEffects', () => { beforeEach(() => { useNodeStore.setState({ nodes: {} }); + resetConnectedMeshcoreBleMacForTests(); localStorage.setItem(MESH_PROTOCOL_STORAGE_KEY, 'meshtastic'); window.electronAPI = { db: { saveNode: vi.fn().mockResolvedValue(undefined) }, @@ -59,6 +66,7 @@ describe('attachMeshtasticRawPacketSideEffects', () => { afterEach(() => { vi.restoreAllMocks(); + resetConnectedMeshcoreBleMacForTests(); localStorage.clear(); }); @@ -182,4 +190,104 @@ describe('attachMeshtasticRawPacketSideEffects', () => { expect(deps.touchLastData).not.toHaveBeenCalled(); detach(); }); + + it('Nathan Blue: does not bump last_heard for MeshCore BLE MAC-derived node while MeshCore connected', () => { + const staleHeard = Date.now() - 60_000; + const nodeMirror = new Map([ + [BLUE_NODE, { ...emptyNode(BLUE_NODE), last_heard: staleHeard, snr: 1 }], + ]); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + setConnectedMeshcoreBleMac('cc:2e:e3:da:2e:2f'); + const deps: MeshtasticRawPacketSideEffectsDeps = { + getMyNodeNum: () => MY_NODE, + getIsConfiguring: () => false, + setRawPackets: vi.fn(), + setSignalTelemetry: vi.fn(), + touchLastData: vi.fn(), + }; + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 12, + rssi: -70, + raw: new Uint8Array([0xcc]), + fromNodeId: BLUE_NODE, + portLabel: 'TEXT_MESSAGE_APP', + viaMqtt: false, + hopsAway: 0, + packetId: 42, + }, + }, + IDENTITY, + ); + const node = getIdentityNode(IDENTITY, BLUE_NODE); + expect(node?.last_heard).toBe(staleHeard); + expect(node?.snr).toBe(1); + expect(window.electronAPI.db.saveNode).not.toHaveBeenCalled(); + detach(); + }); + + it('still bumps last_heard for Blue MAC node when MeshCore BLE is disconnected', () => { + const staleHeard = Date.now() - 60_000; + const nodeMirror = new Map([ + [BLUE_NODE, { ...emptyNode(BLUE_NODE), last_heard: staleHeard, snr: 1 }], + ]); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const deps: MeshtasticRawPacketSideEffectsDeps = { + getMyNodeNum: () => MY_NODE, + getIsConfiguring: () => false, + setRawPackets: vi.fn(), + setSignalTelemetry: vi.fn(), + touchLastData: vi.fn(), + }; + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 12, + rssi: -70, + raw: new Uint8Array([0xcc]), + fromNodeId: BLUE_NODE, + portLabel: 'TEXT_MESSAGE_APP', + viaMqtt: false, + hopsAway: 0, + packetId: 43, + }, + }, + IDENTITY, + ); + const node = getIdentityNode(IDENTITY, BLUE_NODE); + expect(node?.last_heard).toBeGreaterThan(staleHeard); + expect(node?.snr).toBe(12); + detach(); + }); + + it('still bumps unrelated peers while MeshCore BLE is connected', () => { + setConnectedMeshcoreBleMac('cc:2e:e3:da:2e:2f'); + const { deps } = makeDeps(); + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 5, + rssi: -85, + raw: new Uint8Array([0x01]), + fromNodeId: PEER, + portLabel: 'TEXT_MESSAGE_APP', + viaMqtt: false, + hopsAway: 2, + }, + }, + IDENTITY, + ); + expect(getIdentityNode(IDENTITY, PEER)?.snr).toBe(5); + detach(); + }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts index 74196065f..24fd9c6fa 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts @@ -13,10 +13,12 @@ import type { Dispatch, SetStateAction } from 'react'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; import { upsertNodeRecord } from '../../stores/nodeStore'; +import { getConnectedMeshcoreBleMac } from '../connectedMeshcoreBleMac'; import { persistDbWrite } from '../dbPersistRetry'; import { attachTypedPacketListener } from '../drivers/attachTypedPacketListener'; import { errLikeToLogString } from '../errLikeToLogString'; import { getIdentityNode } from '../identityStoreReads'; +import { shouldSuppressMeshtasticNodeHear } from '../meshcoreBleMacMeshtasticNodeId'; import { mergeMeshtasticLivePacketLastHeard } from '../meshtasticLastHeard'; import type { RawPacketEntry } from '../protocols/Protocol'; import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; @@ -77,6 +79,9 @@ function applySignalAndHops( from: number, deps: MeshtasticRawPacketSideEffectsDeps, ): void { + if (shouldSuppressMeshtasticNodeHear(from, getConnectedMeshcoreBleMac())) { + return; + } const myNodeNum = deps.getMyNodeNum(); const hasSignal = Boolean(payload.snr || payload.rssi); const hasHopUpdate = payload.hopsAway !== undefined && from !== myNodeNum; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index ec0a1f02b..2cc3b8aac 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1235,7 +1235,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh potřebuje WebSocket na portu 443 a server mqtt-us-v1.letsmesh.net nebo mqtt-eu-v1.letsmesh.net. Použijte Region (US/EU), nebo přepněte na Custom pro ostatní brokery.", "coloradomesh": "Colorado Mesh potřebuje WebSocket na portu 443 s povoleným TLS, server mqtt.meshcore.coloradomesh.org, cestu /ws. Resetujte předvolbu nebo přepněte na Vlastní pro ostatní brokery.", - "meshmapper": "MeshMapper potřebuje WebSocket na portu 443 a server mqtt.meshmapper.cc. Resetujte předvolbu nebo přepněte na Vlastní pro ostatní brokery." + "meshmapper": "MeshMapper potřebuje WebSocket na portu 443 a server mqtt.meshmapper.net. Resetujte předvolbu nebo přepněte na Vlastní pro ostatní brokery." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth token (formát meshcore-decoder) bude vygenerován, když se připojíte. Uživatelské jméno je v1_ plus váš 64místný veřejný klíč (hex). Publikum JWT odpovídá názvu hostitele serveru.", @@ -2066,7 +2066,8 @@ "network": "Zkontrolujte síť, DNS, firewall a VPN.", "connackTimeout": "Pokud nevidíte žádný předchozí řádek „chyba klienta“, může se stát, že se navázání spojení TLS/WebSocket zasekává (zkuste jinou síť; aplikace preferuje IPv4 pro WSS).", "subscribeFailed": "Makléř může odmítnout odběr zástupných znaků na toto téma; zprávy mohou stále docházet, pokud to broker dovolí.", - "keepalive": "Server přestal reagovat na pingy MQTT (časový limit nečinnosti, výpadek sítě nebo zprostředkovatel ukončil relaci). Znovu se připojte nebo zkontrolujte svůj odkaz." + "keepalive": "Server přestal reagovat na pingy MQTT (časový limit nečinnosti, výpadek sítě nebo zprostředkovatel ukončil relaci). Znovu se připojte nebo zkontrolujte svůj odkaz.", + "tlsHandshake": "TLS handshake se nezdařil (certifikát makléře, sni nebo hostitele). Pro MeshMapper použijte mqtt.meshmapper.net (ne .cc). Zkuste jinou síť nebo resetujte předvolbu MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 97984a198..29e0e4916 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1232,7 +1232,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh benötigt WebSocket auf Port 443 und den Server mqtt-us-v1.letsmesh.net oder mqtt-eu-v1.letsmesh.net. Verwenden Sie die Region (USA/EU) oder wechseln Sie für andere Broker zu „Benutzerdefiniert“.", "coloradomesh": "Colorado Mesh benötigt WebSocket auf Port 443 mit aktiviertem TLS, Server mqtt.meshcore.coloradomesh.org, Pfad /ws. Setzen Sie die Voreinstellung zurück oder wechseln Sie für andere Broker zu „Benutzerdefiniert“.", - "meshmapper": "MeshMapper benötigt WebSocket auf Port 443 und den Server mqtt.meshmapper.cc. Setzen Sie die Voreinstellung zurück oder wechseln Sie für andere Broker zu „Benutzerdefiniert“." + "meshmapper": "MeshMapper benötigt WebSocket auf Port 443 und den Server mqtt.meshmapper.net. Setzen Sie die Voreinstellung zurück oder wechseln Sie für andere Broker zu „Benutzerdefiniert“." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth-Token (Meshcore-Decoder-Format) wird generiert, wenn Sie eine Verbindung herstellen. Der Benutzername ist v1_ plus Ihr 64-stelliger öffentlicher Schlüssel (hex). Die JWT-Zielgruppe entspricht dem Server-Hostnamen.", @@ -2064,7 +2064,8 @@ "network": "Überprüfen Sie Netzwerk, DNS, Firewall und VPN.", "connackTimeout": "Wenn Sie keine vorherige „Client-Fehler“-Zeile sehen, verzögert sich möglicherweise der TLS/WebSocket-Handshake (versuchen Sie es mit einem anderen Netzwerk; die App bevorzugt IPv4 für WSS).", "subscribeFailed": "Der Broker kann das Wildcard-Abonnement zu diesem Thema verweigern. Es können weiterhin Nachrichten eingehen, wenn der Broker dies zulässt.", - "keepalive": "Der Server reagiert nicht mehr auf MQTT-Pings (Leerlaufzeitüberschreitung, Netzwerkausfall oder Broker hat die Sitzung geschlossen). Stellen Sie die Verbindung erneut her oder überprüfen Sie Ihren Link." + "keepalive": "Der Server reagiert nicht mehr auf MQTT-Pings (Leerlaufzeitüberschreitung, Netzwerkausfall oder Broker hat die Sitzung geschlossen). Stellen Sie die Verbindung erneut her oder überprüfen Sie Ihren Link.", + "tlsHandshake": "TLS-Handshake fehlgeschlagen (Broker-Zertifikat, SNI oder Host). Verwenden Sie für MeshMapper mqtt.meshmapper.net (nicht .cc). Versuchen Sie es mit einem anderen Netzwerk oder setzen Sie die MeshMapper-Voreinstellung zurück." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index b34a2b41b..583860419 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1210,7 +1210,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh needs WebSocket on port 443 and server mqtt-us-v1.letsmesh.net or mqtt-eu-v1.letsmesh.net. Use Region (US/EU), or switch to Custom for other brokers.", "coloradomesh": "Colorado Mesh needs WebSocket on port 443 with TLS enabled, server mqtt.meshcore.coloradomesh.org, path /ws. Reset the preset or switch to Custom for other brokers.", - "meshmapper": "MeshMapper needs WebSocket on port 443 and server mqtt.meshmapper.cc. Reset the preset or switch to Custom for other brokers." + "meshmapper": "MeshMapper needs WebSocket on port 443 and server mqtt.meshmapper.net. Reset the preset or switch to Custom for other brokers." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth token (meshcore-decoder format) will be generated when you connect. Username is v1_ plus your 64-character public key (hex). JWT audience matches the Server hostname.", @@ -2070,7 +2070,8 @@ "network": "Check network, DNS, firewall, and VPN.", "connackTimeout": "If you see no prior “client error” line, the TLS/WebSocket handshake may be stalling (try another network; the app prefers IPv4 for WSS).", "subscribeFailed": "The broker may deny wildcard subscribe on this topic; messages may still arrive if the broker allows it.", - "keepalive": "The server stopped responding to MQTT pings (idle timeout, network drop, or broker closed the session). Reconnect or check your link." + "keepalive": "The server stopped responding to MQTT pings (idle timeout, network drop, or broker closed the session). Reconnect or check your link.", + "tlsHandshake": "TLS handshake failed (broker certificate, SNI, or host). For MeshMapper use mqtt.meshmapper.net (not .cc). Try another network or reset the MeshMapper preset." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 3f2885663..3fe12c1e8 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1234,7 +1234,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh necesita WebSocket en el puerto 443 y el servidor mqtt-us-v1.letsmesh.net o mqtt-eu-v1.letsmesh.net. Utilice Región (EE. UU./UE) o cambie a Personalizado para otros corredores.", "coloradomesh": "Colorado Mesh necesita WebSocket en el puerto 443 con TLS habilitado, servidor mqtt.meshcore.coloradomesh.org, ruta /ws. Restablezca el ajuste preestablecido o cambie a Personalizado para otros corredores.", - "meshmapper": "MeshMapper necesita WebSocket en el puerto 443 y el servidor mqtt.meshmapper.cc. Restablezca el ajuste preestablecido o cambie a Personalizado para otros corredores." + "meshmapper": "MeshMapper necesita WebSocket en el puerto 443 y el servidor mqtt.meshmapper.net. Restablezca el ajuste preestablecido o cambie a Personalizado para otros corredores." }, "meshcoreMqttIdentity": { "hasPrivateKey": "El token de autenticación (formato meshcore-decoder) se generará cuando se conecte. El nombre de usuario es v1_ más su clave pública de 64 caracteres (hexadecimal). La audiencia de JWT coincide con el nombre de host del servidor.", @@ -2064,7 +2064,8 @@ "network": "Verifique la red, DNS, firewall y VPN.", "connackTimeout": "Si no ve ninguna línea anterior de \"error del cliente\", es posible que el protocolo de enlace TLS/WebSocket se esté deteniendo (pruebe con otra red; la aplicación prefiere IPv4 para WSS).", "subscribeFailed": "El corredor puede denegar la suscripción comodín sobre este tema; Es posible que aún lleguen mensajes si el corredor lo permite.", - "keepalive": "El servidor dejó de responder a los pings de MQTT (tiempo de espera de inactividad, caída de la red o el agente cerró la sesión). Vuelva a conectarse o verifique su enlace." + "keepalive": "El servidor dejó de responder a los pings de MQTT (tiempo de espera de inactividad, caída de la red o el agente cerró la sesión). Vuelva a conectarse o verifique su enlace.", + "tlsHandshake": "Error en el apretón de manos TLS (certificado de bróker, SNI o host). Para MeshMapper use mqtt.meshmapper.net (no .cc). Pruebe con otra red o restablezca el ajuste preestablecido de MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 9b58b7c80..4ef1eb1de 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1233,7 +1233,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh a besoin de WebSocket sur le port 443 et du serveur mqtt-us-v1.letsmesh.net ou mqtt-eu-v1.letsmesh.net. Utilisez Région (États-Unis/UE) ou passez à Personnalisé pour les autres courtiers.", "coloradomesh": "Colorado Mesh a besoin de WebSocket sur le port 443 avec TLS activé, serveur mqtt.meshcore.coloradomesh.org, chemin /ws. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers.", - "meshmapper": "MeshMapper a besoin de WebSocket sur le port 443 et du serveur mqtt.meshmapper.cc. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers." + "meshmapper": "MeshMapper a besoin de WebSocket sur le port 443 et du serveur mqtt.meshmapper.net. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Le jeton d'authentification (format meshcore-decoder) sera généré lors de votre connexion. Le nom d'utilisateur est v1_ plus votre clé publique de 64 caractères (hexadécimal). L'audience JWT correspond au nom d'hôte du serveur.", @@ -2064,7 +2064,8 @@ "network": "Vérifiez le réseau, le DNS, le pare-feu et le VPN.", "connackTimeout": "Si vous ne voyez aucune ligne « erreur client » antérieure, la négociation TLS/WebSocket est peut-être en panne (essayez un autre réseau ; l'application préfère IPv4 pour WSS).", "subscribeFailed": "Le courtier peut refuser l'abonnement générique sur ce sujet ; les messages peuvent toujours arriver si le courtier le permet.", - "keepalive": "Le serveur a cessé de répondre aux pings MQTT (délai d'inactivité, interruption du réseau ou courtier a fermé la session). Reconnectez-vous ou vérifiez votre lien." + "keepalive": "Le serveur a cessé de répondre aux pings MQTT (délai d'inactivité, interruption du réseau ou courtier a fermé la session). Reconnectez-vous ou vérifiez votre lien.", + "tlsHandshake": "Échec de la poignée de main TLS (certificat de courtier, SNI ou hôte). Pour MeshMapper, utilisez mqtt.meshmapper.net (pas .cc). Essayez un autre réseau ou réinitialisez le préréglage MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 9f16ccfb9..69bbd28c8 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1234,7 +1234,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh memerlukan WebSocket pada port 443 dan server mqtt-us-v1.letsmesh.net atau mqtt-eu-v1.letsmesh.net. Gunakan Wilayah (AS/UE), atau beralih ke Kustom untuk broker lain.", "coloradomesh": "Colorado Mesh memerlukan WebSocket pada port 443 dengan TLS diaktifkan, server mqtt.meshcore.coloradomesh.org, jalur /ws. Reset preset atau alihkan ke Custom untuk broker lain.", - "meshmapper": "MeshMapper membutuhkan WebSocket pada port 443 dan server mqtt.meshmapper.cc. Reset preset atau alihkan ke Custom untuk broker lain." + "meshmapper": "MeshMapper membutuhkan WebSocket pada port 443 dan server mqtt.meshmapper.net. Reset preset atau alihkan ke Custom untuk broker lain." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Token autentikasi (format meshcore-decoder) akan dibuat saat Anda terhubung. Nama pengguna adalah v1_ ditambah kunci publik 64 karakter Anda (hex). Audiens JWT cocok dengan nama host Server.", @@ -2064,7 +2064,8 @@ "network": "Periksa jaringan, DNS, firewall, dan VPN.", "connackTimeout": "Jika Anda tidak melihat baris “kesalahan klien” sebelumnya, jabat tangan TLS/WebSocket mungkin terhenti (coba jaringan lain; aplikasi lebih memilih IPv4 untuk WSS).", "subscribeFailed": "Broker mungkin menolak berlangganan wildcard pada topik ini; pesan mungkin masih sampai jika broker mengizinkannya.", - "keepalive": "Server berhenti merespons ping MQTT (waktu tunggu habis, jaringan terputus, atau broker menutup sesi). Hubungkan kembali atau periksa tautan Anda." + "keepalive": "Server berhenti merespons ping MQTT (waktu tunggu habis, jaringan terputus, atau broker menutup sesi). Hubungkan kembali atau periksa tautan Anda.", + "tlsHandshake": "Jabat tangan TLS gagal (sertifikat broker, SNI, atau host). Untuk MeshMapper gunakan mqtt.meshmapper.net (bukan .cc). Coba jaringan lain atau atur ulang preset MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index fc2b9f7b2..b699fb016 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1232,7 +1232,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh necessita di WebSocket sulla porta 443 e del server mqtt-us-v1.letsmesh.net o mqtt-eu-v1.letsmesh.net. Utilizza la regione (USA/UE) o passa a Personalizzato per altri broker.", "coloradomesh": "Colorado Mesh necessita di WebSocket sulla porta 443 con TLS abilitato, server mqtt.meshcore.coloradomesh.org, percorso /ws. Ripristina la preimpostazione o passa a Personalizzato per altri broker.", - "meshmapper": "MeshMapper necessita di WebSocket sulla porta 443 e sul server mqtt.meshmapper.cc. Ripristina la preimpostazione o passa a Personalizzato per altri broker." + "meshmapper": "MeshMapper necessita di WebSocket sulla porta 443 e sul server mqtt.meshmapper.net. Ripristina la preimpostazione o passa a Personalizzato per altri broker." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Il token di autenticazione (formato decodificatore meshcore) verrà generato quando ti connetti. Il nome utente è v1_ più la chiave pubblica di 64 caratteri (esadecimale). Il pubblico JWT corrisponde al nome host del server.", @@ -2064,7 +2064,8 @@ "network": "Controlla rete, DNS, firewall e VPN.", "connackTimeout": "Se non vedi la riga \"errore client\" precedente, l'handshake TLS/WebSocket potrebbe essere in fase di stallo (prova un'altra rete; l'app preferisce IPv4 per WSS).", "subscribeFailed": "Il broker può negare l'iscrizione con caratteri jolly su questo argomento; i messaggi potrebbero comunque arrivare se il broker lo consente.", - "keepalive": "Il server ha smesso di rispondere ai ping MQTT (timeout di inattività, interruzione della rete o broker che ha chiuso la sessione). Riconnettiti o controlla il tuo collegamento." + "keepalive": "Il server ha smesso di rispondere ai ping MQTT (timeout di inattività, interruzione della rete o broker che ha chiuso la sessione). Riconnettiti o controlla il tuo collegamento.", + "tlsHandshake": "Handshake TLS non riuscito (certificato broker, SNI o host). Per MeshMapper utilizzare mqtt.meshmapper.net (non .cc). Provare un'altra rete o reimpostare il preset MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 369540545..a93233a96 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1234,7 +1234,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh には、ポート 443 とサーバー mqtt-us-v1.letsmesh.net または mqtt-eu-v1.letsmesh.net 上の WebSocket が必要です。地域 (US/EU) を使用するか、他のブローカーの場合はカスタムに切り替えてください。", "coloradomesh": "Colorado Mesh には、TLS が有効になっているポート 443、サーバー mqtt.meshcore.coloradomesh.org、パス /ws の WebSocket が必要です。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。", - "meshmapper": "MeshMapper には、ポート 443 とサーバー mqtt.meshmapper.cc 上の WebSocket が必要です。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。" + "meshmapper": "MeshMapper には、ポート 443 とサーバー mqtt.meshmapper.net 上の WebSocket が必要です。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。" }, "meshcoreMqttIdentity": { "hasPrivateKey": "接続時に認証トークン(meshcore-decoder形式)が生成されます。ユーザー名は v1_ に 64 文字の公開キー (16 進数) を加えたものです。 JWT オーディエンスはサーバーのホスト名と一致します。", @@ -2064,7 +2064,8 @@ "network": "ネットワーク、DNS、ファイアウォール、VPN を確認してください。", "connackTimeout": "前に「クライアント エラー」行が表示されない場合は、TLS/WebSocket ハンドシェイクが停止している可能性があります (別のネットワークを試してください。アプリは WSS に IPv4 を優先します)。", "subscribeFailed": "ブローカーは、このトピックに対するワイルドカード サブスクライブを拒否する場合があります。ブローカーが許可している場合、メッセージは引き続き到着する可能性があります。", - "keepalive": "サーバーが MQTT ping に応答しなくなりました (アイドル タイムアウト、ネットワーク ドロップ、またはブローカーがセッションを閉じた)。再接続するか、リンクを確認してください。" + "keepalive": "サーバーが MQTT ping に応答しなくなりました (アイドル タイムアウト、ネットワーク ドロップ、またはブローカーがセッションを閉じた)。再接続するか、リンクを確認してください。", + "tlsHandshake": "TLSハンドシェイクに失敗しました(ブローカー証明書、SNI、またはホスト)。MeshMapperの場合は、mqtt.meshmapper.net (.ccではありません)を使用します。別のネットワークを試すか、MeshMapperプリセットをリセットしてください。" } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 236558933..3a2c33ef6 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1234,7 +1234,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh에는 포트 443 및 서버 mqtt-us-v1.letsmesh.net 또는 mqtt-eu-v1.letsmesh.net에 WebSocket이 필요합니다. 지역(US/EU)을 사용하거나 다른 브로커의 경우 사용자 정의로 전환하세요.", "coloradomesh": "Colorado Mesh는 TLS가 활성화된 포트 443, 서버 mqtt.meshcore.coloradomesh.org, 경로 /ws에 WebSocket이 필요합니다. 다른 브로커의 경우 사전 설정을 재설정하거나 사용자 정의로 전환하세요.", - "meshmapper": "MeshMapper에는 포트 443 및 서버 mqtt.meshmapper.cc에 WebSocket이 필요합니다. 다른 브로커의 경우 사전 설정을 재설정하거나 사용자 정의로 전환하세요." + "meshmapper": "MeshMapper에는 포트 443 및 서버 mqtt.meshmapper.net에 WebSocket이 필요합니다. 다른 브로커의 경우 사전 설정을 재설정하거나 사용자 정의로 전환하세요." }, "meshcoreMqttIdentity": { "hasPrivateKey": "연결 시 인증 토큰(meshcore-decoder 형식)이 생성됩니다. 사용자 이름은 v1_에 64자 공개 키(16진수)를 더한 것입니다. JWT 대상은 서버 호스트 이름과 일치합니다.", @@ -2064,7 +2064,8 @@ "network": "네트워크, DNS, 방화벽, VPN을 확인하세요.", "connackTimeout": "이전의 \"클라이언트 오류\" 줄이 표시되지 않으면 TLS/WebSocket 핸드셰이크가 중단된 것일 수 있습니다(다른 네트워크를 사용해 보십시오. 앱은 WSS에 대해 IPv4를 선호합니다).", "subscribeFailed": "브로커는 이 주제에 대한 와일드카드 구독을 거부할 수 있습니다. 브로커가 허용하는 경우 메시지가 계속 도착할 수 있습니다.", - "keepalive": "서버가 MQTT 핑(유휴 시간 초과, 네트워크 중단 또는 브로커가 세션을 닫음)에 대한 응답을 중지했습니다. 링크를 다시 연결하거나 확인하세요." + "keepalive": "서버가 MQTT 핑(유휴 시간 초과, 네트워크 중단 또는 브로커가 세션을 닫음)에 대한 응답을 중지했습니다. 링크를 다시 연결하거나 확인하세요.", + "tlsHandshake": "TLS 핸드셰이크 실패 (브로커 인증서, SNI 또는 호스트). MeshMapper의 경우 mqtt.meshmapper.net (.cc 아님) 을 사용하십시오. 다른 네트워크를 시도하거나 MeshMapper 프리셋을 재설정하십시오." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 248d54561..0298f5d9b 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1233,7 +1233,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh heeft WebSocket op poort 443 en server mqtt-us-v1.letsmesh.net of mqtt-eu-v1.letsmesh.net nodig. Gebruik Regio (VS/EU) of schakel over naar Aangepast voor andere makelaars.", "coloradomesh": "Colorado Mesh heeft WebSocket nodig op poort 443 met TLS ingeschakeld, server mqtt.meshcore.coloradomesh.org, pad /ws. Reset de preset of schakel over naar Aangepast voor andere makelaars.", - "meshmapper": "MeshMapper heeft WebSocket op poort 443 en server mqtt.meshmapper.cc nodig. Reset de preset of schakel over naar Aangepast voor andere makelaars." + "meshmapper": "MeshMapper heeft WebSocket op poort 443 en server mqtt.meshmapper.net nodig. Reset de preset of schakel over naar Aangepast voor andere makelaars." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth-token (meshcore-decoder-indeling) wordt gegenereerd wanneer u verbinding maakt. De gebruikersnaam is v1_ plus uw openbare sleutel van 64 tekens (hexadecimaal). JWT-doelgroep komt overeen met de hostnaam van de server.", @@ -2064,7 +2064,8 @@ "network": "Controleer netwerk, DNS, firewall en VPN.", "connackTimeout": "Als u geen eerdere regel 'clientfout' ziet, loopt de TLS/WebSocket-handshake mogelijk vast (probeer een ander netwerk; de app geeft de voorkeur aan IPv4 voor WSS).", "subscribeFailed": "De makelaar kan een wildcard-abonnement op dit onderwerp weigeren; berichten kunnen nog steeds binnenkomen als de makelaar dit toestaat.", - "keepalive": "De server reageert niet meer op MQTT-pings (time-out bij inactiviteit, netwerkuitval of makelaar heeft de sessie gesloten). Maak opnieuw verbinding of controleer uw link." + "keepalive": "De server reageert niet meer op MQTT-pings (time-out bij inactiviteit, netwerkuitval of makelaar heeft de sessie gesloten). Maak opnieuw verbinding of controleer uw link.", + "tlsHandshake": "TLS-handshake mislukt (makelaarscertificaat, SNI of host). Gebruik voor MeshMapper mqtt.meshmapper.net (niet .cc). Probeer een ander netwerk of reset de MeshMapper-voorinstelling." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index c7367c49d..c10fcb876 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1236,7 +1236,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh potrzebuje WebSocket na porcie 443 i serwerze mqtt-us-v1.letsmesh.net lub mqtt-eu-v1.letsmesh.net. Użyj regionu (USA/UE) lub przejdź na opcję Niestandardowy dla innych brokerów.", "coloradomesh": "Colorado Mesh potrzebuje protokołu WebSocket na porcie 443 z włączoną obsługą TLS, serwer mqtt.meshcore.coloradomesh.org, ścieżka /ws. Zresetuj ustawienie wstępne lub przejdź na opcję Niestandardową dla innych brokerów.", - "meshmapper": "MeshMapper potrzebuje protokołu WebSocket na porcie 443 i serwerze mqtt.meshmapper.cc. Zresetuj ustawienie wstępne lub przejdź na opcję Niestandardową dla innych brokerów." + "meshmapper": "MeshMapper potrzebuje protokołu WebSocket na porcie 443 i serwerze mqtt.meshmapper.net. Zresetuj ustawienie wstępne lub przejdź na opcję Niestandardową dla innych brokerów." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Token autoryzacji (format dekodera meshcore) zostanie wygenerowany po połączeniu. Nazwa użytkownika to v1_ plus Twój 64-znakowy klucz publiczny (szesnastkowo). Lista odbiorców JWT odpowiada nazwie hosta serwera.", @@ -2068,7 +2068,8 @@ "network": "Sprawdź sieć, DNS, zaporę sieciową i VPN.", "connackTimeout": "Jeśli nie widzisz wcześniej wiersza „błąd klienta”, uzgadnianie TLS/WebSocket może się zawieszać (spróbuj innej sieci; aplikacja preferuje protokół IPv4 dla WSS).", "subscribeFailed": "Broker może odmówić subskrypcji symboli wieloznacznych w tym temacie; wiadomości mogą nadal przychodzić, jeśli broker na to pozwala.", - "keepalive": "Serwer przestał odpowiadać na pingi MQTT (przekroczenie limitu czasu bezczynności, przerwa w działaniu sieci lub zamknięcie sesji przez brokera). Połącz się ponownie lub sprawdź łącze." + "keepalive": "Serwer przestał odpowiadać na pingi MQTT (przekroczenie limitu czasu bezczynności, przerwa w działaniu sieci lub zamknięcie sesji przez brokera). Połącz się ponownie lub sprawdź łącze.", + "tlsHandshake": "Uzgadnianie TLS nie powiodło się (certyfikat brokera, sni lub host). W przypadku aplikacji MeshMapper użyj pliku mqtt.meshmapper.net (nie .cc). Wypróbuj inną sieć lub zresetuj ustawienie wstępne MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index eeefc6bee..af8c2ca70 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1234,7 +1234,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh precisa de WebSocket na porta 443 e servidor mqtt-us-v1.letsmesh.net ou mqtt-eu-v1.letsmesh.net. Use Região (EUA/UE) ou mude para Personalizado para outros corretores.", "coloradomesh": "Colorado Mesh precisa de WebSocket na porta 443 com TLS habilitado, servidor mqtt.meshcore.coloradomesh.org, caminho /ws. Redefina a predefinição ou mude para Personalizado para outros corretores.", - "meshmapper": "MeshMapper precisa de WebSocket na porta 443 e servidor mqtt.meshmapper.cc. Redefina a predefinição ou mude para Personalizado para outros corretores." + "meshmapper": "MeshMapper precisa de WebSocket na porta 443 e servidor mqtt.meshmapper.net. Redefina a predefinição ou mude para Personalizado para outros corretores." }, "meshcoreMqttIdentity": { "hasPrivateKey": "O token de autenticação (formato de decodificador meshcore) será gerado quando você se conectar. O nome de usuário é v1_ mais sua chave pública de 64 caracteres (hex). O público JWT corresponde ao nome do host do servidor.", @@ -2064,7 +2064,8 @@ "network": "Verifique rede, DNS, firewall e VPN.", "connackTimeout": "Se você não vir nenhuma linha anterior de “erro do cliente”, o handshake TLS/WebSocket pode estar paralisado (tente outra rede; o aplicativo prefere IPv4 para WSS).", "subscribeFailed": "O corretor pode negar assinatura curinga neste tópico; as mensagens ainda podem chegar se o corretor permitir.", - "keepalive": "O servidor parou de responder aos pings MQTT (tempo limite de inatividade, queda de rede ou corretor fechou a sessão). Reconecte ou verifique seu link." + "keepalive": "O servidor parou de responder aos pings MQTT (tempo limite de inatividade, queda de rede ou corretor fechou a sessão). Reconecte ou verifique seu link.", + "tlsHandshake": "O handshake TLS falhou (certificado de corretor, SNI ou host). Para MeshMapper use mqtt.meshmapper.net (não .cc). Tente outra rede ou redefina a predefinição do MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 3dce68b82..a05af55c1 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1236,7 +1236,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh нужен WebSocket на порту 443 и сервер mqtt-us-v1.letsmesh.net или mqtt-eu-v1.letsmesh.net. Используйте регион (США/ЕС) или переключитесь на Custom для других брокеров.", "coloradomesh": "Colorado Mesh требуется WebSocket на порту 443 с включенным TLS, сервер mqtt.meshcore.coloradomesh.org, путь /ws. Сбросьте предустановку или переключитесь на Custom для других брокеров.", - "meshmapper": "MeshMapper нуждается в WebSocket на порту 443 и сервере mqtt.meshmapper.cc. Сбросьте предустановку или переключитесь на Custom для других брокеров." + "meshmapper": "MeshMapper нуждается в WebSocket на порту 443 и сервере mqtt.meshmapper.net. Сбросьте предустановку или переключитесь на Custom для других брокеров." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Токен аутентификации (формат Meshcore-декодера) будет сгенерирован при подключении. Имя пользователя — v1_ плюс ваш 64-значный открытый ключ (шестнадцатеричный). Аудитория JWT соответствует имени хоста Сервера.", @@ -2066,7 +2066,8 @@ "network": "Проверьте сеть, DNS, брандмауэр и VPN.", "connackTimeout": "Если вы не видите предыдущую строку «ошибка клиента», возможно, рукопожатие TLS/WebSocket зависает (попробуйте другую сеть; приложение предпочитает IPv4 для WSS).", "subscribeFailed": "Брокер может отказать в подписке по подстановочным знакам по этой теме; сообщения все равно могут приходить, если брокер это разрешает.", - "keepalive": "Сервер перестал отвечать на запросы MQTT (тайм-аут простоя, обрыв сети или брокер закрыл сеанс). Переподключитесь или проверьте ссылку." + "keepalive": "Сервер перестал отвечать на запросы MQTT (тайм-аут простоя, обрыв сети или брокер закрыл сеанс). Переподключитесь или проверьте ссылку.", + "tlsHandshake": "Ошибка подтверждения TLS (сертификат брокера, SNI или хост). Для MeshMapper используйте mqtt.meshmapper.net (не .cc). Попробуйте другую сеть или сбросьте предустановку MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index b3ac19a1a..d55b286c7 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1234,7 +1234,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh'in 443 numaralı bağlantı noktasında WebSocket'e ve mqtt-us-v1.letsmesh.net veya mqtt-eu-v1.letsmesh.net sunucusuna ihtiyacı vardır. Bölge'yi (ABD/AB) kullanın veya diğer aracılar için Özel'e geçin.", "coloradomesh": "Colorado Mesh'in TLS etkinleştirilmiş 443 numaralı bağlantı noktasında WebSocket'e ihtiyacı var, sunucu mqtt.meshcore.coloradomesh.org, yol /ws. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin.", - "meshmapper": "MeshMapper'ın 443 numaralı bağlantı noktasında WebSocket'e ve mqtt.meshmapper.cc sunucusuna ihtiyacı vardır. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin." + "meshmapper": "MeshMapper'ın 443 numaralı bağlantı noktasında WebSocket'e ve mqtt.meshmapper.net sunucusuna ihtiyacı vardır. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Bağlandığınızda kimlik doğrulama jetonu (meshcore-kod çözücü formatı) oluşturulacaktır. Kullanıcı adı v1_ artı 64 karakterlik genel anahtarınızdan (onaltılık) oluşur. JWT izleyicisi Sunucu ana bilgisayar adıyla eşleşir.", @@ -2064,7 +2064,8 @@ "network": "Ağı, DNS'yi, güvenlik duvarını ve VPN'yi kontrol edin.", "connackTimeout": "Önceden bir \"istemci hatası\" satırı görmüyorsanız TLS/WebSocket anlaşması duruyor olabilir (başka bir ağ deneyin; uygulama, WSS için IPv4'ü tercih ediyor).", "subscribeFailed": "Broker bu konuda joker karakter aboneliğini reddedebilir; aracı izin verirse mesajlar yine de gelebilir.", - "keepalive": "Sunucu, MQTT ping'lerine yanıt vermeyi durdurdu (boşta kalma zaman aşımı, ağ kesintisi veya aracı oturumu kapattı). Bağlantınızı yeniden bağlayın veya kontrol edin." + "keepalive": "Sunucu, MQTT ping'lerine yanıt vermeyi durdurdu (boşta kalma zaman aşımı, ağ kesintisi veya aracı oturumu kapattı). Bağlantınızı yeniden bağlayın veya kontrol edin.", + "tlsHandshake": "TLS el sıkışması başarısız oldu (aracı sertifikası, SNI veya ana bilgisayar). MeshMapper için mqtt.meshmapper.net (.cc değil) kullanın. Başka bir ağ deneyin veya MeshMapper ön ayarını sıfırlayın." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 3edc8ed07..71e445717 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1236,7 +1236,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh потребує WebSocket на порту 443 і сервері mqtt-us-v1.letsmesh.net або mqtt-eu-v1.letsmesh.net. Використовуйте регіон (США/ЄС) або перейдіть на спеціальний для інших брокерів.", "coloradomesh": "Colorado Mesh потребує WebSocket на порту 443 із увімкненим TLS, сервер mqtt.meshcore.coloradomesh.org, шлях /ws. Скиньте попередні налаштування або перейдіть на Custom для інших брокерів.", - "meshmapper": "MeshMapper потребує WebSocket на порту 443 і сервері mqtt.meshmapper.cc. Скиньте попередні налаштування або перейдіть на Custom для інших брокерів." + "meshmapper": "MeshMapper потребує WebSocket на порту 443 і сервері mqtt.meshmapper.net. Скиньте попередні налаштування або перейдіть на Custom для інших брокерів." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Маркер автентифікації (формат meshcore-decoder) буде згенеровано під час підключення. Ім’я користувача v1_ плюс ваш відкритий ключ із 64 символів (шістнадцятковий). Аудиторія JWT відповідає імені хоста сервера.", @@ -2066,7 +2066,8 @@ "network": "Перевірте мережу, DNS, брандмауер і VPN.", "connackTimeout": "Якщо ви не бачите попереднього рядка «помилка клієнта», можливо, рукостискання TLS/WebSocket зупиняється (спробуйте іншу мережу; програма віддає перевагу IPv4 для WSS).", "subscribeFailed": "Брокер може відмовити в підписці на цю тему; повідомлення все ще можуть надходити, якщо це дозволяє брокер.", - "keepalive": "Сервер перестав відповідати на запити MQTT (тайм-аут простою, розрив мережі або брокер закрив сеанс). Повторно підключіться або перевірте своє посилання." + "keepalive": "Сервер перестав відповідати на запити MQTT (тайм-аут простою, розрив мережі або брокер закрив сеанс). Повторно підключіться або перевірте своє посилання.", + "tlsHandshake": "Не вдалося підтвердити TLS (сертифікат брокера, SNI або хост). Для MeshMapper використовуйте mqtt.meshmapper.net (не .cc). Спробуйте іншу мережу або скиньте попереднє налаштування MeshMapper." } }, "meshcoreContactSettings": { diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index a916cc948..b1de738fc 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1234,7 +1234,7 @@ "meshcorePresetDeviation": { "letsmesh": "LetsMesh 需要端口 443 上的 WebSocket 和服务器 mqtt-us-v1.letsmesh.net 或 mqtt-eu-v1.letsmesh.net。使用区域(美国/欧盟),或切换到其他经纪商的自定义。", "coloradomesh": "Colorado Mesh 需要端口 443 上的 WebSocket 且启用了 TLS,服务器 mqtt.meshcore.coloradomesh.org,路径 /ws。重置预设或切换到其他经纪商的自定义。", - "meshmapper": "MeshMapper 需要端口 443 上的 WebSocket 和服务器 mqtt.meshmapper.cc。重置预设或切换到其他经纪商的自定义。" + "meshmapper": "MeshMapper 需要端口 443 上的 WebSocket 和服务器 mqtt.meshmapper.net。重置预设或切换到其他经纪商的自定义。" }, "meshcoreMqttIdentity": { "hasPrivateKey": "连接时将生成身份验证令牌(meshcore-decoder 格式)。用户名是 v1_ 加上您的 64 字符公钥(十六进制)。 JWT 受众与服务器主机名匹配。", @@ -2064,7 +2064,8 @@ "network": "检查网络、DNS、防火墙和 VPN。", "connackTimeout": "如果您之前没有看到“客户端错误”行,则 TLS/WebSocket 握手可能会停止(尝试另一个网络;应用程序更喜欢使用 IPv4 作为 WSS)。", "subscribeFailed": "经纪商可能会拒绝通配符订阅该主题;如果代理允许,消息仍然可能到达。", - "keepalive": "服务器停止响应 MQTT ping(空闲超时、网络中断或代理关闭会话)。重新连接或检查您的链接。" + "keepalive": "服务器停止响应 MQTT ping(空闲超时、网络中断或代理关闭会话)。重新连接或检查您的链接。", + "tlsHandshake": "TLS握手失败( Broker证书、SNI或主机)。对于MeshMapper ,使用mqtt.meshmapper.net (而不是.cc )。尝试其他网络或重置MeshMapper预设。" } }, "meshcoreContactSettings": { diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 170383c70..bbbdd0e71 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -6,6 +6,7 @@ import { flushSync } from 'react-dom'; import { dedupeChannelPillsByIndex } from '@/renderer/lib/channelListDedupe'; import { requestChatOutboxDrain } from '@/renderer/lib/chatOutboxDrain'; /* eslint-disable @typescript-eslint/no-confusing-void-expression */ +import { setConnectedMeshcoreBleMac } from '@/renderer/lib/connectedMeshcoreBleMac'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { isMeshcoreOffloadAbortError, @@ -2597,6 +2598,7 @@ export function useMeshcoreRuntime() { meshcoreEverConfiguredRef.current = false; meshcoreDeviceConfiguredRef.current = false; meshcoreConnectionParamsRef.current = null; + setConnectedMeshcoreBleMac(null); meshcoreIsReconnectingRef.current = false; meshcoreReconnectAttemptRef.current = 0; meshcoreReconnectGenerationRef.current += 1; @@ -3064,6 +3066,7 @@ export function useMeshcoreRuntime() { serialPortId: type === 'serial' ? localStorage.getItem(LAST_SERIAL_PORT_KEY) : undefined, serialPort: null, }; + setConnectedMeshcoreBleMac(type === 'ble' ? (blePeripheralId ?? null) : null); meshcoreExplicitDisconnectRef.current = false; meshcoreReconnectAttemptRef.current = 0; meshcoreIsReconnectingRef.current = false; @@ -3185,6 +3188,7 @@ export function useMeshcoreRuntime() { serialPortId: lastSerialPortId ?? localStorage.getItem(LAST_SERIAL_PORT_KEY), serialPort: null, }; + setConnectedMeshcoreBleMac(null); meshcoreExplicitDisconnectRef.current = false; meshcoreReconnectAttemptRef.current = 0; meshcoreIsReconnectingRef.current = false; diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 1085c25e2..70a6506d3 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -13,7 +13,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getStoreForwardHistoryProfile } from '@/renderer/lib/appSettingsStorage'; import { requestChatOutboxDrain } from '@/renderer/lib/chatOutboxDrain'; +import { getConnectedMeshcoreBleMac } from '@/renderer/lib/connectedMeshcoreBleMac'; +import { setDebugSnapshotMeshtasticContext } from '@/renderer/lib/debugSnapshotMeshtasticContext'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { shouldSuppressMeshtasticNodeHear } from '@/renderer/lib/meshcoreBleMacMeshtasticNodeId'; import { buildStoreForwardHistoryToRadioBytes, getLastSfHistoryFetchMs, @@ -653,6 +656,9 @@ export function useMeshtasticRuntime() { if (!identityId || nodeNum === 0 || getIdentityNode(identityId, nodeNum) != null) { return; } + if (shouldSuppressMeshtasticNodeHear(nodeNum, getConnectedMeshcoreBleMac())) { + return; + } const created = createChatStubNode(nodeNum, source); upsertNodeRecord(identityId, meshNodeToNodeRecord(created)); persistMeshtasticNode(created); @@ -665,9 +671,17 @@ export function useMeshtasticRuntime() { entries = meshtasticMqttChannelKeyEntriesFromManual(); } if (entries.length === 0) return; - void window.electronAPI.mqtt.updateChannelKeys({ entries }).catch((e: unknown) => { - console.warn('[useMeshtasticRuntime] mqtt.updateChannelKeys failed ' + errLikeToLogString(e)); - }); + void window.electronAPI.mqtt + .updateChannelKeys({ entries }) + .then(() => window.electronAPI.mqtt.getChannelNameToIndex()) + .then((map) => { + setDebugSnapshotMeshtasticContext({ mqttChannelNameToIndex: map }); + }) + .catch((e: unknown) => { + console.warn( + '[useMeshtasticRuntime] mqtt.updateChannelKeys failed ' + errLikeToLogString(e), + ); + }); }, []); useEffect(() => { @@ -1292,6 +1306,10 @@ export function useMeshtasticRuntime() { }; if (!nodeUpdate.node_id) return; + if (shouldSuppressMeshtasticNodeHear(nodeUpdate.node_id, getConnectedMeshcoreBleMac())) { + return; + } + // Record noisy portnums from MQTT for diagnostics if (nodeUpdate.portnum != null) { useDiagnosticsStore.getState().recordNoisePort(nodeUpdate.node_id, nodeUpdate.portnum); diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index dcfba4edf..38d628fe4 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -161,6 +161,7 @@ export function createElectronAPIMock(): ElectronAPI { onClientId: vi.fn().mockReturnValue(() => {}), getClientId: vi.fn().mockResolvedValue(''), getCachedNodes: vi.fn().mockResolvedValue([]), + getChannelNameToIndex: vi.fn().mockResolvedValue({}), updateChannelKeys: vi.fn().mockResolvedValue(undefined), updateTopicPrefix: vi.fn().mockResolvedValue(undefined), publish: vi.fn().mockResolvedValue(1), diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index fc625a1b9..0406b7378 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -708,6 +708,7 @@ export interface ElectronAPI { onClientId: (cb: (payload: { clientId: string; protocol: MeshProtocol }) => void) => () => void; getClientId: (protocol?: MeshProtocol) => Promise; getCachedNodes: () => Promise; + getChannelNameToIndex: () => Promise>; updateChannelKeys: (args: { entries: { name: string; pskBase64: string; index?: number }[]; }) => Promise; From 4240a10055d047418636006ed86f2a58cb77f5ea Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 10:01:03 -0600 Subject: [PATCH 2/2] fix: address PR review for Blue ghost suppress and BLE MAC lifecycle Move MeshCore MAC suppress earlier in raw-packet handling, clear/restore the connected BLE identity across loss/reconnect/Linux Web BT, and tighten related tests (typed MQTT private access, empty channel map, per-matcher TLS hints). --- src/main/mqtt-manager.test.ts | 54 ++++++++++------ .../lib/connectedMeshcoreBleMac.test.ts | 63 +++++++++++++++++++ src/renderer/lib/connectedMeshcoreBleMac.ts | 34 ++++++++++ .../debugSnapshotMeshtasticContext.test.ts | 8 +++ src/renderer/lib/meshcoreMqttUserHint.test.ts | 14 ++++- .../lib/meshcoreWebBluetoothConnection.ts | 7 +++ .../meshtasticRawPacketSideEffects.test.ts | 12 ++++ .../meshtasticRawPacketSideEffects.ts | 8 ++- .../useMeshcoreRuntime.reconnect.test.ts | 28 +++++++++ src/renderer/runtime/useMeshcoreRuntime.ts | 39 +++++++++++- 10 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 src/renderer/lib/connectedMeshcoreBleMac.test.ts diff --git a/src/main/mqtt-manager.test.ts b/src/main/mqtt-manager.test.ts index b4b5559ca..04a767a3a 100644 --- a/src/main/mqtt-manager.test.ts +++ b/src/main/mqtt-manager.test.ts @@ -1263,6 +1263,22 @@ describe('connect — channelPsks parsing', () => { }); describe('updateChannelKeys', () => { + /** Test-only surface for private MQTTManager members used by channel-map regressions. */ + interface MqttManagerChannelTestAccess { + _doConnect: () => void; + channelNameToIndex: Map; + channelKeysByName: Map; + onMessage: (topic: string, payload: Buffer) => void; + } + + function mqttChannelTestAccess(manager: MQTTManager): MqttManagerChannelTestAccess { + return manager as unknown as MqttManagerChannelTestAccess; + } + + function stubMqttConnect(manager: MQTTManager): void { + mqttChannelTestAccess(manager)._doConnect = () => {}; + } + it('registers radio channel keys for decrypt and publish', () => { const manager = new MQTTManager(); (manager as any)._doConnect = () => {}; @@ -1347,7 +1363,7 @@ describe('updateChannelKeys', () => { it('stores LongFast index mapping from default public radio sync', () => { const manager = new MQTTManager(); - (manager as any)._doConnect = () => {}; + stubMqttConnect(manager); manager.connect({ server: 'localhost', port: 1883, @@ -1359,14 +1375,14 @@ describe('updateChannelKeys', () => { manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); - const nameToIndex: Map = (manager as any).channelNameToIndex; - expect(nameToIndex.get('LongFast')).toBe(1); + expect(mqttChannelTestAccess(manager).channelNameToIndex.get('LongFast')).toBe(1); expect(manager.getChannelNameToIndex()).toEqual({ LongFast: 1 }); }); it('Nathan/Colorado: radio LongFast@1 overrides manual LongFast@0 for topic attribution', () => { const manager = new MQTTManager(); - (manager as any)._doConnect = () => {}; + const access = mqttChannelTestAccess(manager); + stubMqttConnect(manager); manager.connect({ server: 'localhost', port: 1883, @@ -1377,19 +1393,19 @@ describe('updateChannelKeys', () => { channelPsks: ['LongFast@0=AQ=='], }); - expect((manager as any).channelNameToIndex.get('LongFast')).toBe(0); + expect(access.channelNameToIndex.get('LongFast')).toBe(0); manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]); expect(manager.getChannelNameToIndex().LongFast).toBe(1); // Manual default-public PSK preserved when radio also pushes AQ== - const byName: Map = (manager as any).channelKeysByName; - expect(byName.get('LongFast')?.equals(DEFAULT_PSK)).toBe(true); + expect(access.channelKeysByName.get('LongFast')?.equals(DEFAULT_PSK)).toBe(true); }); it('Nathan/Colorado: inbound LongFast topic + packet channel 0 attributes to slot 1 after radio sync overrides @0', () => { const manager = new MQTTManager(); - (manager as any)._doConnect = () => {}; + const access = mqttChannelTestAccess(manager); + stubMqttConnect(manager); manager.connect({ server: 'localhost', port: 1883, @@ -1421,7 +1437,7 @@ describe('updateChannelKeys', () => { const messages: unknown[] = []; manager.on('message', (m) => messages.push(m)); - (manager as any).onMessage('msh/US/CO/2/e/LongFast/!095cf12b', payload); + access.onMessage('msh/US/CO/2/e/LongFast/!095cf12b', payload); expect(messages).toHaveLength(1); expect((messages[0] as { channel: number }).channel).toBe(1); @@ -1429,7 +1445,8 @@ describe('updateChannelKeys', () => { it('Nathan/Colorado: JSON LongFast with manual @0 then radio @1 attributes to slot 1', () => { const manager = new MQTTManager(); - (manager as any)._doConnect = () => {}; + const access = mqttChannelTestAccess(manager); + stubMqttConnect(manager); manager.connect({ server: 'localhost', port: 1883, @@ -1451,10 +1468,7 @@ describe('updateChannelKeys', () => { const messages: unknown[] = []; manager.on('message', (m) => messages.push(m)); - (manager as any).onMessage( - 'msh/US/CO/2/json/LongFast/!aabbccdd', - Buffer.from(JSON.stringify(json)), - ); + access.onMessage('msh/US/CO/2/json/LongFast/!aabbccdd', Buffer.from(JSON.stringify(json))); expect(messages).toHaveLength(1); expect((messages[0] as { channel: number }).channel).toBe(1); @@ -1462,7 +1476,7 @@ describe('updateChannelKeys', () => { it('keeps LongFast@1 when manual and radio both say slot 1', () => { const manager = new MQTTManager(); - (manager as any)._doConnect = () => {}; + stubMqttConnect(manager); manager.connect({ server: 'localhost', port: 1883, @@ -1478,7 +1492,8 @@ describe('updateChannelKeys', () => { it('preserves manual Garber PSK when radio sync pushes a different key', () => { const manager = new MQTTManager(); - (manager as any)._doConnect = () => {}; + const access = mqttChannelTestAccess(manager); + stubMqttConnect(manager); const customGarber = Buffer.alloc(32, 0x11); const radioGarber = Buffer.alloc(32, 0x22); @@ -1496,9 +1511,8 @@ describe('updateChannelKeys', () => { { name: 'Garber', pskBase64: radioGarber.toString('base64'), index: 2 }, ]); - const byName: Map = (manager as any).channelKeysByName; - expect(byName.get('Garber')?.equals(customGarber)).toBe(true); - expect(byName.get('Garber')?.equals(radioGarber)).toBe(false); + expect(access.channelKeysByName.get('Garber')?.equals(customGarber)).toBe(true); + expect(access.channelKeysByName.get('Garber')?.equals(radioGarber)).toBe(false); const nodeId = 0x11223344; const packetId = 0x00000041; @@ -1519,7 +1533,7 @@ describe('updateChannelKeys', () => { const messages: unknown[] = []; manager.on('message', (m) => messages.push(m)); - (manager as any).onMessage('msh/US/2/e/Garber/!11223344', payload); + access.onMessage('msh/US/2/e/Garber/!11223344', payload); expect(messages).toHaveLength(1); expect((messages[0] as { payload: string }).payload).toBe('manual garber key'); diff --git a/src/renderer/lib/connectedMeshcoreBleMac.test.ts b/src/renderer/lib/connectedMeshcoreBleMac.test.ts new file mode 100644 index 000000000..e6ee7d40c --- /dev/null +++ b/src/renderer/lib/connectedMeshcoreBleMac.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + readMeshcoreWebBluetoothDeviceId, + resolveConnectedMeshcoreBleIdentity, +} from './connectedMeshcoreBleMac'; + +describe('resolveConnectedMeshcoreBleIdentity', () => { + it('prefers explicit blePeripheralId over Web Bluetooth and last-id fallbacks', () => { + expect( + resolveConnectedMeshcoreBleIdentity({ + blePeripheralId: 'aa:bb:cc:dd:ee:ff', + webBluetoothDeviceId: 'web-bt-uuid', + fallbackLastBlePeripheralId: 'stored-id', + }), + ).toBe('aa:bb:cc:dd:ee:ff'); + }); + + it('uses Web Bluetooth device id when peripheral id is missing (Linux)', () => { + expect( + resolveConnectedMeshcoreBleIdentity({ + blePeripheralId: undefined, + webBluetoothDeviceId: ' web-bt-uuid ', + fallbackLastBlePeripheralId: 'stored-id', + }), + ).toBe('web-bt-uuid'); + }); + + it('falls back to last remembered BLE id', () => { + expect( + resolveConnectedMeshcoreBleIdentity({ + blePeripheralId: '', + webBluetoothDeviceId: null, + fallbackLastBlePeripheralId: 'stored-mac', + }), + ).toBe('stored-mac'); + }); + + it('returns null when no candidates are usable', () => { + expect( + resolveConnectedMeshcoreBleIdentity({ + blePeripheralId: ' ', + webBluetoothDeviceId: undefined, + fallbackLastBlePeripheralId: null, + }), + ).toBeNull(); + }); +}); + +describe('readMeshcoreWebBluetoothDeviceId', () => { + it('reads getWebBluetoothDeviceId from duck-typed connections', () => { + expect( + readMeshcoreWebBluetoothDeviceId({ + getWebBluetoothDeviceId: () => 'linux-device-id', + }), + ).toBe('linux-device-id'); + }); + + it('returns null for non-Web-Bluetooth handles', () => { + expect(readMeshcoreWebBluetoothDeviceId({})).toBeNull(); + expect(readMeshcoreWebBluetoothDeviceId(null)).toBeNull(); + }); +}); diff --git a/src/renderer/lib/connectedMeshcoreBleMac.ts b/src/renderer/lib/connectedMeshcoreBleMac.ts index 0fe4deafb..dec4baef2 100644 --- a/src/renderer/lib/connectedMeshcoreBleMac.ts +++ b/src/renderer/lib/connectedMeshcoreBleMac.ts @@ -11,6 +11,40 @@ export function getConnectedMeshcoreBleMac(): string | null { return connectedMeshcoreBleMac; } +/** + * Prefer an explicit Noble peripheral id, then a live Web Bluetooth device id, + * then a remembered last-BLE id (Linux chooser may omit blePeripheralId on connect). + */ +export function resolveConnectedMeshcoreBleIdentity(opts: { + blePeripheralId?: string | null; + webBluetoothDeviceId?: string | null; + fallbackLastBlePeripheralId?: string | null; +}): string | null { + for (const candidate of [ + opts.blePeripheralId, + opts.webBluetoothDeviceId, + opts.fallbackLastBlePeripheralId, + ]) { + const trimmed = candidate?.trim() ?? ''; + if (trimmed.length > 0) return trimmed; + } + return null; +} + +/** Duck-typed read of MeshcoreWebBluetoothConnection.getWebBluetoothDeviceId(). */ +export function readMeshcoreWebBluetoothDeviceId(conn: unknown): string | null { + if (!conn || typeof conn !== 'object') return null; + const getter = (conn as { getWebBluetoothDeviceId?: unknown }).getWebBluetoothDeviceId; + if (typeof getter !== 'function') return null; + try { + const id = (getter as (this: unknown) => unknown).call(conn); + return typeof id === 'string' && id.trim().length > 0 ? id.trim() : null; + } catch { + // catch-no-log-ok optional Web Bluetooth accessor on non-Web-BT connections + return null; + } +} + /** Test helper — reset module state between cases. */ export function resetConnectedMeshcoreBleMacForTests(): void { connectedMeshcoreBleMac = null; diff --git a/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts b/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts index 3193e6284..7d4dce49e 100644 --- a/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts +++ b/src/renderer/lib/debugSnapshotMeshtasticContext.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { beforeEach, describe, expect, it } from 'vitest'; import { MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES } from '@/shared/meshtasticDefaultPublicPsk'; @@ -28,6 +29,13 @@ describe('buildDebugSnapshotMeshtasticContextFromRuntime', () => { expect(ctx.mqttChannelNameToIndex).toEqual({ LongFast: 1 }); }); + it('preserves an empty mqttChannelNameToIndex map instead of falling back to null', () => { + setDebugSnapshotMeshtasticContext({ mqttChannelNameToIndex: {} }); + const ctx = buildDebugSnapshotMeshtasticContextFromRuntime([], []); + expect(ctx.mqttChannelNameToIndex).toEqual({}); + expect(ctx.mqttChannelNameToIndex).not.toBeNull(); + }); + it('maps channel pills and config summary including default-public PSK detection', () => { const ctx = buildDebugSnapshotMeshtasticContextFromRuntime( [ diff --git a/src/renderer/lib/meshcoreMqttUserHint.test.ts b/src/renderer/lib/meshcoreMqttUserHint.test.ts index f9ded909e..4833c24a2 100644 --- a/src/renderer/lib/meshcoreMqttUserHint.test.ts +++ b/src/renderer/lib/meshcoreMqttUserHint.test.ts @@ -35,9 +35,19 @@ describe('meshcoreMqttUserFacingHint', () => { expect(meshcoreUserMessageKey(out)).toBe('meshcore.mqttHints.keepalive'); }); - it('returns tlsHandshake hint for EPROTO / TLSV1_ALERT_INTERNAL_ERROR', () => { + it('returns tlsHandshake hint for EPROTO alone', () => { + const out = meshcoreMqttUserFacingHint('write EPROTO'); + expect(meshcoreUserMessageKey(out)).toBe('meshcore.mqttHints.tlsHandshake'); + }); + + it('returns tlsHandshake hint for TLSV1_ALERT_INTERNAL_ERROR alone', () => { + const out = meshcoreMqttUserFacingHint('SSL alert: TLSV1_ALERT_INTERNAL_ERROR'); + expect(meshcoreUserMessageKey(out)).toBe('meshcore.mqttHints.tlsHandshake'); + }); + + it('returns tlsHandshake hint for OPENSSL_internal:TLSV1_ALERT alone', () => { const out = meshcoreMqttUserFacingHint( - 'write EPROTO 30458176:error:10000438:SSL routines:OPENSSL_internal:TLSV1_ALERT_INTERNAL_ERROR:../../third_party/boringssl/src/ssl/tls_record.cc:486:SSL alert number 80', + 'error:10000438:SSL routines:OPENSSL_internal:TLSV1_ALERT_PROTOCOL_VERSION', ); expect(meshcoreUserMessageKey(out)).toBe('meshcore.mqttHints.tlsHandshake'); }); diff --git a/src/renderer/lib/meshcoreWebBluetoothConnection.ts b/src/renderer/lib/meshcoreWebBluetoothConnection.ts index fa77c2456..45f6fd57f 100644 --- a/src/renderer/lib/meshcoreWebBluetoothConnection.ts +++ b/src/renderer/lib/meshcoreWebBluetoothConnection.ts @@ -48,6 +48,13 @@ export class MeshcoreWebBluetoothConnection extends Connection { await this.transport.disconnect(); } + /** Chromium Web Bluetooth device id (opaque UUID on Linux; may be MAC-shaped elsewhere). */ + getWebBluetoothDeviceId(): string | null { + return ( + this.transport.getDeviceInfo()?.deviceId ?? this.transport.getLastGrantedDeviceId() ?? null + ); + } + async connect(reuseDeviceId?: string): Promise { // Wrap all connection steps in timeouts to prevent hanging on unresponsive devices if (reuseDeviceId) { diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts index 28675437c..229d06fe3 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts @@ -205,6 +205,8 @@ describe('attachMeshtasticRawPacketSideEffects', () => { setSignalTelemetry: vi.fn(), touchLastData: vi.fn(), }; + const recordNoisePort = vi.spyOn(useDiagnosticsStore.getState(), 'recordNoisePort'); + const recordPacketPath = vi.spyOn(useDiagnosticsStore.getState(), 'recordPacketPath'); const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); packetRouter.dispatch( { @@ -219,14 +221,24 @@ describe('attachMeshtasticRawPacketSideEffects', () => { viaMqtt: false, hopsAway: 0, packetId: 42, + portnum: 1, }, }, IDENTITY, ); const node = getIdentityNode(IDENTITY, BLUE_NODE); + expect(deps.touchLastData).toHaveBeenCalled(); expect(node?.last_heard).toBe(staleHeard); expect(node?.snr).toBe(1); expect(window.electronAPI.db.saveNode).not.toHaveBeenCalled(); + expect(deps.setRawPackets).not.toHaveBeenCalled(); + expect(deps.setSignalTelemetry).not.toHaveBeenCalled(); + expect(recordNoisePort).not.toHaveBeenCalledWith(BLUE_NODE, expect.anything()); + expect(recordPacketPath).not.toHaveBeenCalledWith( + expect.anything(), + BLUE_NODE, + expect.anything(), + ); detach(); }); diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts index 24fd9c6fa..2441f5ac2 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts @@ -79,9 +79,6 @@ function applySignalAndHops( from: number, deps: MeshtasticRawPacketSideEffectsDeps, ): void { - if (shouldSuppressMeshtasticNodeHear(from, getConnectedMeshcoreBleMac())) { - return; - } const myNodeNum = deps.getMyNodeNum(); const hasSignal = Boolean(payload.snr || payload.rssi); const hasHopUpdate = payload.hopsAway !== undefined && from !== myNodeNum; @@ -125,6 +122,11 @@ function handleRawPacket( const from = payload.fromNodeId; if (!from) return; + // Connected MeshCore BLE MAC → skip all packet-wide side effects (diagnostics, SNR, hops). + if (shouldSuppressMeshtasticNodeHear(from, getConnectedMeshcoreBleMac())) { + return; + } + if (getStoredMeshProtocol() === 'meshtastic') { appendRawPacketLog(payload, deps); if (typeof payload.portnum === 'number') { diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 2ec209ad6..3608073a1 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -145,6 +145,34 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(lostBody).toContain('defer reconnect until in-flight open settles'); }); + it('clears connected MeshCore BLE MAC on connection loss and restores it after BLE reconnect attach', () => { + const lostBody = extractUseCallbackBody(RUNTIME_SOURCE, 'handleMeshcoreConnectionLost'); + expect(lostBody).toContain('setConnectedMeshcoreBleMac(null)'); + const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); + expect(reconnectBody).toContain('resolveConnectedMeshcoreBleIdentity'); + expect(reconnectBody).toContain('readMeshcoreWebBluetoothDeviceId'); + expect(reconnectBody).toMatch(/Reconnect succeeded[\s\S]*?setConnectedMeshcoreBleMac\(bleId\)/); + }); + + it('clears MeshCore BLE MAC on prepareRfConnect replacement and BLE connect failure', () => { + const prepareBody = extractUseCallbackBody(RUNTIME_SOURCE, 'prepareRfConnect'); + expect(prepareBody).toContain('setConnectedMeshcoreBleMac(null)'); + const failureBody = extractUseCallbackBody(RUNTIME_SOURCE, 'handleRfConnectFailure'); + expect(failureBody).toMatch( + /if \(type === 'ble'\) \{[\s\S]*?setConnectedMeshcoreBleMac\(null\)/, + ); + }); + + it('sets MeshCore BLE identity after connect attach including Linux Web Bluetooth without blePeripheralId', () => { + const connectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'connect'); + expect(connectBody).toContain('resolveConnectedMeshcoreBleIdentity'); + expect(connectBody).toContain('readMeshcoreWebBluetoothDeviceId(opened.conn)'); + expect(connectBody).toContain( + "fallbackLastBlePeripheralId: resolveLastBlePeripheralId('meshcore')", + ); + expect(connectBody).toContain("setConnectedMeshcoreBleMac(type === 'ble' ? bleId : null)"); + }); + it('flushes deferred reconnects after reconnect attempts settle', () => { const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); const finallyBody = reconnectBody.slice(reconnectBody.indexOf('finally {')); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index bbbdd0e71..f0a46cfb0 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -6,7 +6,11 @@ import { flushSync } from 'react-dom'; import { dedupeChannelPillsByIndex } from '@/renderer/lib/channelListDedupe'; import { requestChatOutboxDrain } from '@/renderer/lib/chatOutboxDrain'; /* eslint-disable @typescript-eslint/no-confusing-void-expression */ -import { setConnectedMeshcoreBleMac } from '@/renderer/lib/connectedMeshcoreBleMac'; +import { + readMeshcoreWebBluetoothDeviceId, + resolveConnectedMeshcoreBleIdentity, + setConnectedMeshcoreBleMac, +} from '@/renderer/lib/connectedMeshcoreBleMac'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { isMeshcoreOffloadAbortError, @@ -2470,6 +2474,9 @@ export function useMeshcoreRuntime() { bleConnectInProgressRef.current = false; meshcoreDeferredReconnectRef.current = false; } + // Drop sticky MeshCore BLE identity before replacement so a failed open cannot keep + // suppressing Meshtastic MAC-derived node hears from a previous peripheral. + setConnectedMeshcoreBleMac(null); const driverIdentity = meshcoreDriverConnectedRef.current ? (meshcoreIdentityIdRef.current ?? meshcorePendingDriverIdentityRef.current) : null; @@ -2578,6 +2585,9 @@ export function useMeshcoreRuntime() { (type: 'ble' | 'serial' | 'tcp', driverIdentityId?: string): Promise => { setState({ status: 'disconnected', myNodeNum: 0, connectionType: null }); meshcoreDeviceConfiguredRef.current = false; + if (type === 'ble') { + setConnectedMeshcoreBleMac(null); + } teardownMeshcoreConnEventListeners({ driverDisconnect: true, driverIdentityId, @@ -2837,6 +2847,19 @@ export function useMeshcoreRuntime() { console.debug( `[useMeshcoreRuntime] Reconnect succeeded on attempt ${meshcoreReconnectAttemptRef.current}`, ); + if (params.rfType === 'ble') { + const bleId = resolveConnectedMeshcoreBleIdentity({ + blePeripheralId: params.blePeripheralId, + webBluetoothDeviceId: readMeshcoreWebBluetoothDeviceId(opened.conn), + fallbackLastBlePeripheralId: resolveLastBlePeripheralId('meshcore') ?? null, + }); + if (bleId) { + params.blePeripheralId = bleId; + } + setConnectedMeshcoreBleMac(bleId); + } else { + setConnectedMeshcoreBleMac(null); + } meshcoreReconnectAttemptRef.current = 0; meshcoreIsReconnectingRef.current = false; meshcoreDeferredReconnectRef.current = false; @@ -2937,6 +2960,8 @@ export function useMeshcoreRuntime() { } meshcoreReconnectGenerationRef.current += 1; meshcoreDeviceConfiguredRef.current = false; + // Stop suppressing Meshtastic hears while MeshCore link is down / reconnecting. + setConnectedMeshcoreBleMac(null); if (!meshcoreIsReconnectingRef.current) { console.warn('[useMeshcoreRuntime] Connection lost — initiating reconnect'); meshcoreIsReconnectingRef.current = true; @@ -3059,14 +3084,22 @@ export function useMeshcoreRuntime() { ? await withNobleBleConnectMutex('meshcore', openTransport) : await openTransport(); await attachRfSession(opened.driverIdentityId, type); + const bleId = + type === 'ble' + ? resolveConnectedMeshcoreBleIdentity({ + blePeripheralId, + webBluetoothDeviceId: readMeshcoreWebBluetoothDeviceId(opened.conn), + fallbackLastBlePeripheralId: resolveLastBlePeripheralId('meshcore') ?? null, + }) + : null; meshcoreConnectionParamsRef.current = { rfType: type, httpAddress: type === 'tcp' ? tcpHost : undefined, - blePeripheralId: type === 'ble' ? blePeripheralId : undefined, + blePeripheralId: type === 'ble' ? (bleId ?? undefined) : undefined, serialPortId: type === 'serial' ? localStorage.getItem(LAST_SERIAL_PORT_KEY) : undefined, serialPort: null, }; - setConnectedMeshcoreBleMac(type === 'ble' ? (blePeripheralId ?? null) : null); + setConnectedMeshcoreBleMac(type === 'ble' ? bleId : null); meshcoreExplicitDisconnectRef.current = false; meshcoreReconnectAttemptRef.current = 0; meshcoreIsReconnectingRef.current = false;