From 8382f7d5820aa7100b27551dec382dd2592b9171 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 06:25:17 -0600 Subject: [PATCH 1/7] fix(meshcore): improve BLE missing-services detection and discovery fallback Reduce false unsupported-firmware errors by centralizing MeshCore BLE missing-services classification and mapping both Noble and main-process discovery failures consistently. Add a one-time non-Windows fallback from targeted to full GATT discovery to recover transient service discovery misses. --- src/main/noble-ble-manager.test.ts | 9 +++++ src/main/noble-ble-manager.ts | 45 +++++++++++++++++----- src/renderer/lib/bleConnectErrors.test.ts | 36 +++++++++++++++++ src/renderer/lib/bleConnectErrors.ts | 7 ++++ src/renderer/runtime/useMeshcoreRuntime.ts | 3 +- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/main/noble-ble-manager.test.ts b/src/main/noble-ble-manager.test.ts index eaf986c32..7c62c9286 100644 --- a/src/main/noble-ble-manager.test.ts +++ b/src/main/noble-ble-manager.test.ts @@ -143,6 +143,15 @@ describe('NobleBleManager.connect — per-session UUID selection (regression)', const meshcoreBranch = meshcoreBranchMatch![1]; expect(meshcoreBranch).not.toContain('fromNumChar'); }); + + it('retries MeshCore non-Windows discovery once with full discovery after missing-services failure', () => { + expect(SOURCE).toContain('isMeshcoreMissingServicesDiscoveryError'); + expect(SOURCE).toContain('retrying once with full discovery'); + expect(SOURCE).toContain('BLE full GATT discovery (meshcore fallback)'); + expect(SOURCE).toMatch( + /isMeshcore &&\s*!IS_WIN32 &&\s*isMeshcoreMissingServicesDiscoveryError/, + ); + }); }); describe('NobleBleManager.connect — macOS wake zombie peripheral (regression)', () => { diff --git a/src/main/noble-ble-manager.ts b/src/main/noble-ble-manager.ts index c2065f29a..d9fba8359 100644 --- a/src/main/noble-ble-manager.ts +++ b/src/main/noble-ble-manager.ts @@ -169,6 +169,13 @@ function meshcorePickBestChar( return candidates.reduce((best, c) => (score(c) > score(best) ? c : best), candidates[0]); } +function isMeshcoreMissingServicesDiscoveryError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /could not find all requested services|failed to find required ble characteristics/i.test( + message, + ); +} + function formatBleDisconnectReason(reason: unknown): string { if (reason instanceof Error) return reason.message; if (reason == null) return 'none'; @@ -1435,15 +1442,35 @@ export class NobleBleManager extends EventEmitter { ); characteristics = all.characteristics; } else { - const discovered = await withTimeout( - peripheral.discoverSomeServicesAndCharacteristicsAsync( - discoverServiceUuids, - discoverCharUuids, - ), - BLE_DISCOVERY_TIMEOUT_MS, - 'BLE characteristic discovery', - ); - characteristics = discovered.characteristics; + try { + const discovered = await withTimeout( + peripheral.discoverSomeServicesAndCharacteristicsAsync( + discoverServiceUuids, + discoverCharUuids, + ), + BLE_DISCOVERY_TIMEOUT_MS, + 'BLE characteristic discovery', + ); + characteristics = discovered.characteristics; + } catch (err) { + const shouldRetryFullDiscovery = + isMeshcore && !IS_WIN32 && isMeshcoreMissingServicesDiscoveryError(err); + if (!shouldRetryFullDiscovery) { + throw err; + } + console.debug( + `[BLE:${sessionId}] targeted characteristic discovery failed for MeshCore; retrying once with full discovery`, + ); + const discoveredAll = await withTimeout( + peripheral.discoverAllServicesAndCharacteristicsAsync(), + BLE_DISCOVERY_TIMEOUT_MS, + 'BLE full GATT discovery (meshcore fallback)', + ); + characteristics = discoveredAll.characteristics; + console.debug( + `[BLE:${sessionId}] fallback full discovery succeeded for MeshCore after targeted discovery failure`, + ); + } } if (isMeshcore) { const rxCandidates: NobleCharacteristic[] = []; diff --git a/src/renderer/lib/bleConnectErrors.test.ts b/src/renderer/lib/bleConnectErrors.test.ts index f9a3f4301..971d6887d 100644 --- a/src/renderer/lib/bleConnectErrors.test.ts +++ b/src/renderer/lib/bleConnectErrors.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + classifyMeshcoreBleTimeoutStage, + isMeshcoreMissingServicesErrorMessage, isMeshcoreRetryableBleErrorMessage, isMeshcoreSetupAbortError, isMeshcoreTcpTransportDeadError, @@ -8,6 +10,40 @@ import { rethrowMeshcoreSetupAbortFromTcpDead, } from './bleConnectErrors'; +describe('isMeshcoreMissingServicesErrorMessage', () => { + it('matches noble missing requested services message', () => { + expect(isMeshcoreMissingServicesErrorMessage('Could not find all requested services')).toBe( + true, + ); + }); + + it('matches main-process missing required BLE characteristics message', () => { + expect( + isMeshcoreMissingServicesErrorMessage('Failed to find required BLE characteristics'), + ).toBe(true); + }); + + it('does not match unrelated BLE errors', () => { + expect(isMeshcoreMissingServicesErrorMessage('Bluetooth adapter is not powered on')).toBe( + false, + ); + }); +}); + +describe('classifyMeshcoreBleTimeoutStage', () => { + it('keeps existing timeout classification for known timeout messages', () => { + expect(classifyMeshcoreBleTimeoutStage('BLE characteristic discovery timed out')).toBe( + 'ipc-open', + ); + }); + + it('does not classify missing services as a timeout', () => { + expect(classifyMeshcoreBleTimeoutStage('Could not find all requested services')).toBe( + 'unknown', + ); + }); +}); + describe('isMeshcoreSetupAbortError', () => { it('matches MeshCore setup cancel AbortError', () => { expect( diff --git a/src/renderer/lib/bleConnectErrors.ts b/src/renderer/lib/bleConnectErrors.ts index 154fc3250..567552cc4 100644 --- a/src/renderer/lib/bleConnectErrors.ts +++ b/src/renderer/lib/bleConnectErrors.ts @@ -53,6 +53,13 @@ export function classifyMeshcoreBleTimeoutStage(message: string): MeshcoreBleTim return 'unknown'; } +const MESHCORE_MISSING_SERVICES_RE = + /could not find all requested services|failed to find required ble characteristics/i; + +export function isMeshcoreMissingServicesErrorMessage(message: string): boolean { + return MESHCORE_MISSING_SERVICES_RE.test(message); +} + /** WinRT / BlueZ sometimes drop the link during GATT service or characteristic discovery. */ const MESHCORE_RETRYABLE_GATT_DISCOVERY_FLAKES_RE = /unreachable while discovering services|unreachable while discovering characteristics|gatt.*unreachable/i; diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 17a49b92f..53ea4b40b 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -87,6 +87,7 @@ import { } from '../lib/appSettingsStorage'; import { classifyMeshcoreBleTimeoutStage, + isMeshcoreMissingServicesErrorMessage, isMeshcoreSetupAbortError, isMeshcoreTcpTransportDeadError, MESHCORE_SETUP_ABORT_MESSAGE, @@ -4023,7 +4024,7 @@ export function useMeshcoreRuntime() { const isAlreadyInProgress = /already in progress|Connection already in progress/i.test( safeMessage, ); - const isMissingServices = /could not find all requested services/i.test(safeMessage); + const isMissingServices = isMeshcoreMissingServicesErrorMessage(safeMessage); const isPeripheralInUse = /already in use by the/i.test(safeMessage); const bleTimeoutStage = type === 'ble' ? classifyMeshcoreBleTimeoutStage(safeMessage) : 'unknown'; From 6807c8c7006060b318306bf4f84ae0cc828af707 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 06:27:32 -0600 Subject: [PATCH 2/7] fix(meshcore): show full long names in Contacts Stop forcing single-line truncation for MeshCore contact names so room and chat long names stay readable in the Contacts table. Add a regression test covering long room names with the key indicator present. --- .../components/NodeListPanel.test.tsx | 24 +++++++++++++++++++ src/renderer/components/NodeListPanel.tsx | 6 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index e994412fd..df0ed7a2f 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -665,6 +665,30 @@ describe('NodeListPanel import contacts', () => { }, ); + it('shows full MeshCore room long names without forcing truncate', () => { + const nodeId = 0xfaceb00c; + const longName = 'Richmond upon Thames Chat'; + const nodes = new Map([ + [nodeId, makeNode({ node_id: nodeId, long_name: longName, hw_model: 'Room' })], + ]); + render( + , + ); + + const longNameNode = screen.getByText(longName); + expect(longNameNode).toBeInTheDocument(); + expect(screen.getByLabelText('Has public key')).toBeInTheDocument(); + expect(longNameNode.className).not.toContain('truncate'); + }); + it('hides the key icon when the MeshCore contact has no known public key', () => { const nodeId = 0xdeadbeef; const nodes = new Map([ diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index 3595f8ccb..cbde9fa34 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -1424,7 +1424,11 @@ export default function NodeListPanel({ >
- + {mode === 'meshcore' ? meshcoreContactDisplayName(node.node_id, node.long_name) : node.long_name || '-'} From e6d8fa09c855efac5926a380251b76c3a3ed4f56 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 06:31:09 -0600 Subject: [PATCH 3/7] fix(meshcore): stop delayed advert-name regressions from offload and MQTT placeholders MeshCore names could still roll back later when offload persisted stale radio advName or when MQTT placeholder upserts wrote transient sender labels into adv_name. Reuse advert-name merge logic in offload and guard MQTT placeholder writes so existing real names are preserved. --- ...eMeshcoreRuntime.offload-contacts.test.tsx | 77 +++++++++++++++++++ .../useMeshcoreRuntime.persistence.test.tsx | 50 ++++++++++++ src/renderer/runtime/useMeshcoreRuntime.ts | 35 ++++++++- 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/src/renderer/hooks/useMeshcoreRuntime.offload-contacts.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.offload-contacts.test.tsx index c80752047..12e081605 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.offload-contacts.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.offload-contacts.test.tsx @@ -255,6 +255,83 @@ describe('useMeshcoreRuntime offloadContactsFromRadio', () => { void MY_NODE_ID; }); + it('offload keeps a newer live advert name when radio contact name is stale', async () => { + const staleName = 'OldRoom'; + const freshName = 'NewRoom'; + getContactsMock.mockResolvedValue([ + { + publicKey: PEER_PUBKEY, + type: 1, + advName: staleName, + lastAdvert: 1_700_000_000, + advLat: 0, + advLon: 0, + flags: 0, + outPathLen: 0, + outPath: new Uint8Array(0), + }, + { + publicKey: SELF_PUBKEY, + type: 1, + advName: 'SelfRadio', + lastAdvert: 1_700_000_000, + advLat: 0, + advLon: 0, + flags: 0, + outPathLen: 0, + outPath: new Uint8Array(0), + }, + ]); + vi.mocked(window.electronAPI.db.getMeshcoreContacts).mockResolvedValue([ + { + node_id: PEER_NODE_ID, + public_key: PEER_PUBKEY_HEX, + adv_name: freshName, + contact_type: 1, + last_advert: 1_700_000_100, + adv_lat: null, + adv_lon: null, + last_snr: 0, + last_rssi: 0, + favorited: 0, + nickname: null, + contact_flags: 0, + hops_away: 1, + on_radio: 0, + last_synced_from_radio: null, + }, + ]); + + const port = makeMockSerialPort(); + Object.defineProperty(navigator, 'serial', { + configurable: true, + value: { requestPort: vi.fn().mockResolvedValue(port) }, + }); + const { result } = renderHook(() => useMeshcoreRuntime()); + + await act(async () => { + await result.current.connect('serial'); + }); + await waitFor(() => { + expect(result.current.state.status).toBe('configured'); + expect(result.current.nodes.get(PEER_NODE_ID)?.long_name).toBe(freshName); + }); + + vi.mocked(window.electronAPI.db.saveMeshcoreContactsBatch).mockClear(); + await act(async () => { + await result.current.offloadContactsFromRadio(); + }); + + expect(vi.mocked(window.electronAPI.db.saveMeshcoreContactsBatch)).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + node_id: PEER_NODE_ID, + adv_name: freshName, + }), + ]), + ); + }); + it('stops removeContact loop when aborted mid-offload', async () => { getContactsMock.mockResolvedValue([ { diff --git a/src/renderer/hooks/useMeshcoreRuntime.persistence.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.persistence.test.tsx index 4851624e2..a4019f64a 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.persistence.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.persistence.test.tsx @@ -357,6 +357,56 @@ describe('useMeshcoreRuntime mount hydration', () => { ); }); + it('mqtt placeholder persistence does not overwrite an existing real contact name', async () => { + const senderNodeId = 0xabcd1234; + vi.mocked(window.electronAPI.db.getMeshcoreMessages).mockResolvedValue([ + { + ...sampleMeshcoreDbRow(), + id: 74, + sender_id: senderNodeId, + sender_name: 'ExistingRealName', + payload: 'Prior persisted message', + }, + ]); + let meshcoreChatHandler: ((raw: unknown) => void) | undefined; + vi.mocked(window.electronAPI.mqtt.onMeshcoreChat).mockImplementation((cb) => { + meshcoreChatHandler = cb; + return () => {}; + }); + + renderHook(() => useMeshcoreRuntime()); + await waitFor(() => { + expect(meshcoreChatHandler).toBeDefined(); + }); + + vi.mocked(window.electronAPI.db.saveMeshcoreContact).mockClear(); + act(() => { + meshcoreChatHandler!({ + text: 'StaleAlias: mqtt line from unresolved sender', + channelIdx: 0, + senderName: 'StaleAlias', + senderNodeId, + timestamp: Date.now(), + }); + }); + + await waitFor(() => { + expect(window.electronAPI.db.saveMeshcoreMessage).toHaveBeenCalled(); + }); + const contactUpserts = vi.mocked(window.electronAPI.db.saveMeshcoreContact).mock.calls; + if (contactUpserts.length > 0) { + expect(contactUpserts[0]?.[0]).toEqual( + expect.objectContaining({ + node_id: senderNodeId, + adv_name: null, + }), + ); + } + expect(window.electronAPI.db.saveMeshcoreContact).not.toHaveBeenCalledWith( + expect.objectContaining({ adv_name: 'StaleAlias' }), + ); + }); + it('merges RF history + MQTT duplicate into one message with receivedVia both', async () => { const senderName = 'SynthUser'; const baseTs = 1_700_000_010_000; diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 53ea4b40b..1296421f6 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -392,6 +392,7 @@ import { meshcoreConnectionImpliesUsbPower, meshcoreContactToMeshNode, meshcoreIsChatStubNodeId, + meshcoreIsPlaceholderNodeLongName, meshcoreIsSyntheticPlaceholderPubKeyHex, meshcoreManufacturerModelFromDeviceQuery, meshcoreMergeChannelDisplayNameOntoNode, @@ -1501,11 +1502,16 @@ export function useMeshcoreRuntime() { !mqttPlaceholderSavedRef.current.has(resolvedId) ) { mqttPlaceholderSavedRef.current.add(resolvedId); + const existingLongName = readMeshcoreNodes().get(resolvedId)?.long_name; + const shouldProtectExistingName = + typeof existingLongName === 'string' && + existingLongName.trim().length > 0 && + !meshcoreIsPlaceholderNodeLongName(existingLongName, resolvedId); void window.electronAPI.db .saveMeshcoreContact({ node_id: resolvedId, public_key: meshcoreSyntheticPlaceholderPubKeyHex(resolvedId), - adv_name: m.senderName ?? displayName, + adv_name: shouldProtectExistingName ? null : (m.senderName ?? displayName), contact_type: 1, last_advert: tsSec, nickname: null, @@ -4742,11 +4748,34 @@ export function useMeshcoreRuntime() { for (const contact of contacts) { const id = pubkeyToNodeId(contact.publicKey); if (id === myId) continue; + const prevNode = readMeshcoreNodes().get(id); + const nickname = nicknameMapRef.current.get(id); const prevHops = getIdentityNode(meshcoreIdentityIdRef.current, id)?.hops_away; const base = meshcoreContactToMeshNode(contact); const mergedHops = meshcoreMergeContactHopsAwayFromPrevious(base.hops_away, prevHops, 0); + const mergedAdvName = meshcoreMergeContactAdvNameFromPrevious( + base.long_name, + meshcorePreviousAdvertNameForRebuild( + prevNode?.long_name, + nickname, + rememberedMeshcoreLiveAdvertName(id), + id, + ), + id, + { + prevLastHeard: prevNode?.last_heard, + radioLastAdvert: contact.lastAdvert, + }, + ); + rememberMeshcoreLiveAdvertName(id, mergedAdvName); pendingDbRows.push( - contactToDbRow(contact, nicknameMapRef.current.get(id) ?? null, 1, now, mergedHops), + contactToDbRow( + { ...contact, advName: mergedAdvName }, + nickname ?? null, + 1, + now, + mergedHops, + ), ); } const toRemove = pendingDbRows.length; @@ -4794,7 +4823,7 @@ export function useMeshcoreRuntime() { } return removed; }, - [], + [readMeshcoreNodes], ); const setOwner = useCallback( From 4fd4354e93d0ddba3aa42324c753cdfe7308a238 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 06:45:52 -0600 Subject: [PATCH 4/7] fix(meshcore): clear stale BLE target on missing-services failures Clear remembered MeshCore BLE device IDs when connect fails with missing characteristic errors so auto-connect and reconnect do not loop on an incompatible peripheral. --- src/renderer/components/ConnectionPanel.tsx | 37 ++++++++++++++++++- .../hooks/useProtocolRfAutoConnect.ts | 14 +++++++ src/renderer/lib/lastConnectionStorage.ts | 16 ++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 8dd986f4a..e31095d49 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -38,6 +38,7 @@ import { loadProtocolMqttSettings, persistMqttSettingsIfChanged, } from '../hooks/useProtocolMqttSettings'; +import { isMeshcoreMissingServicesErrorMessage } from '../lib/bleConnectErrors'; import { cacheBleDeviceMac, getBleDeviceMac, @@ -308,6 +309,14 @@ function saveLastBleDevice(protocol: MeshProtocol, id: string) { } } +function clearLastBleDevice(protocol: MeshProtocol) { + try { + localStorage.removeItem(lastBleDeviceKey(protocol)); + } catch (e) { + console.debug('[ConnectionPanel] clearLastBleDevice ' + errLikeToLogString(e)); + } +} + function loadLastSerialPort(): string | null { try { return localStorage.getItem(LAST_SERIAL_PORT_KEY); @@ -834,6 +843,21 @@ export default function ConnectionPanel({ deviceStateRef.current = state; const lastConnectionRef = useRef(lastConnection); lastConnectionRef.current = lastConnection; + + const clearMeshcoreBleSelectionOnMissingServices = useCallback( + (err: unknown) => { + if (protocol !== 'meshcore') return; + const message = err instanceof Error ? err.message : String(err); + const isMissingServices = + isMeshcoreMissingServicesErrorMessage(message) || + message === 'meshcore.errors.bleMissingServices'; + if (!isMissingServices) return; + clearLastConnection('meshcore'); + clearLastBleDevice('meshcore'); + setLastConnection(null); + }, + [protocol, setLastConnection], + ); const connectionTypeRef = useRef(connectionType); connectionTypeRef.current = connectionType; const onAutoConnectRef = useRef(onAutoConnect); @@ -1535,6 +1559,7 @@ export default function ConnectionPanel({ onConnect('ble', undefined, deviceId).catch((err: unknown) => { const errMsg = err instanceof Error ? err.message : String(err); console.warn(`[ConnectionPanel] BLE connect after selection failed ${errMsg}`); + clearMeshcoreBleSelectionOnMissingServices(err); const bleErrMsg = humanizeBleError(err, t); if (bleErrMsg) setError(bleErrMsg); setConnecting(false); @@ -1542,7 +1567,15 @@ export default function ConnectionPanel({ }); } }, - [bleDevices, isLinux, onConnect, protocol, t, capabilities.hasNobleBleScanning], + [ + bleDevices, + isLinux, + onConnect, + protocol, + t, + capabilities.hasNobleBleScanning, + clearMeshcoreBleSelectionOnMissingServices, + ], ); const handleSelectSerialPort = useCallback((portId: string) => { @@ -1657,6 +1690,7 @@ export default function ConnectionPanel({ // catch-no-log-ok reconnect errors surfaced via setError/humanizeBleError isAutoConnectingRef.current = false; setIsAutoConnecting(false); + clearMeshcoreBleSelectionOnMissingServices(err); const bleErrMsg = humanizeBleError(err, t); if (bleErrMsg) setError(bleErrMsg); setConnecting(false); @@ -1730,6 +1764,7 @@ export default function ConnectionPanel({ protocol, tcpHost, isLinux, + clearMeshcoreBleSelectionOnMissingServices, t, ]); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.ts b/src/renderer/hooks/useProtocolRfAutoConnect.ts index b584593ac..88f10276c 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.ts +++ b/src/renderer/hooks/useProtocolRfAutoConnect.ts @@ -3,6 +3,8 @@ import { useEffect, useRef } from 'react'; import { reconnectBleWithScan } from '@/renderer/lib/bleReconnectHelper'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { + clearLastBleDeviceId, + clearLastConnection, type LastConnection, loadLastBleDeviceId, loadLastConnection, @@ -29,6 +31,8 @@ import { tryGetMeshtasticSession } from '@/renderer/lib/sessions/meshtasticSessi import { POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS } from '@/renderer/lib/timeConstants'; import type { DeviceState, MeshProtocol } from '@/renderer/lib/types'; +import { isMeshcoreMissingServicesErrorMessage } from '../lib/bleConnectErrors'; + export interface UseProtocolRfAutoConnectOptions { protocol: MeshProtocol; state: DeviceState; @@ -143,6 +147,16 @@ export function useProtocolRfAutoConnect({ error: unknown, transport: 'serial' | 'ble' | 'tcp' | 'http' = 'ble', ) => { + if (protocol === 'meshcore' && transport === 'ble') { + const message = error instanceof Error ? error.message : String(error); + const isMissingServices = + isMeshcoreMissingServicesErrorMessage(message) || + message === 'meshcore.errors.bleMissingServices'; + if (isMissingServices) { + clearLastConnection('meshcore'); + clearLastBleDeviceId('meshcore'); + } + } clearAutoConnectTimeout(); console.warn( `[useProtocolRfAutoConnect] ${protocol} ${transport} auto-connect failed: ${errLikeToLogString(error)}`, diff --git a/src/renderer/lib/lastConnectionStorage.ts b/src/renderer/lib/lastConnectionStorage.ts index 626f6d573..2d2bacbfe 100644 --- a/src/renderer/lib/lastConnectionStorage.ts +++ b/src/renderer/lib/lastConnectionStorage.ts @@ -35,6 +35,14 @@ export function saveLastConnection(protocol: MeshProtocol, connection: LastConne } } +export function clearLastConnection(protocol: MeshProtocol): void { + try { + localStorage.removeItem(lastConnectionKey(protocol)); + } catch { + // catch-no-log-ok localStorage unavailable in tests or private mode + } +} + export function loadLastBleDeviceId(protocol: MeshProtocol): string | null { try { return localStorage.getItem(lastBleDeviceKey(protocol)); @@ -44,6 +52,14 @@ export function loadLastBleDeviceId(protocol: MeshProtocol): string | null { } } +export function clearLastBleDeviceId(protocol: MeshProtocol): void { + try { + localStorage.removeItem(lastBleDeviceKey(protocol)); + } catch { + // catch-no-log-ok localStorage unavailable in tests or private mode + } +} + export function resolveLastBlePeripheralId(protocol: MeshProtocol): string | undefined { const last = loadLastConnection(protocol); return last?.bleDeviceId ?? loadLastBleDeviceId(protocol) ?? undefined; From 9a2ef65f8d3acdba3aa8c0ef35ce0a0d97a62ec9 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 06:51:32 -0600 Subject: [PATCH 5/7] fix(meshcore): share BLE missing-services reset handling Keep MeshCore BLE missing-services recovery on one shared error path so manual and auto-connect clear stale remembered targets consistently, with regression coverage for both flows. --- .../components/ConnectionPanel.test.tsx | 80 +++++++++++++++++++ src/renderer/components/ConnectionPanel.tsx | 21 +---- .../hooks/useProtocolRfAutoConnect.test.tsx | 28 +++++++ .../hooks/useProtocolRfAutoConnect.ts | 20 ++--- src/renderer/lib/bleConnectErrors.test.ts | 23 ++++++ src/renderer/lib/bleConnectErrors.ts | 8 ++ src/renderer/lib/lastConnectionStorage.ts | 5 ++ 7 files changed, 156 insertions(+), 29 deletions(-) diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index b28353277..9d8f100e7 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -2342,6 +2342,86 @@ describe('ConnectionPanel BLE MAC identity', () => { } }); + it('clears remembered MeshCore BLE selection after missing-services connect failure', async () => { + const user = userEvent.setup(); + const { restore } = mockMacNoblePlatform(); + const lastConnKey = 'mesh-client:lastConnection:meshcore'; + const lastBleKey = 'mesh-client:lastBleDevice:meshcore'; + localStorage.setItem( + lastConnKey, + JSON.stringify({ + type: 'ble', + bleDeviceId: darwinUuid, + bleDeviceName: 'MeshCore-NV0N', + bleMac: 'ac:a7:04:00:d6:f1', + }), + ); + localStorage.setItem(lastBleKey, darwinUuid); + let capturedCb: + | ((device: { + deviceId: string; + deviceName: string; + rssi?: number | null; + address?: string | null; + }) => void) + | undefined; + vi.mocked(window.electronAPI.onNobleBleDeviceDiscovered).mockImplementation((cb) => { + capturedCb = cb; + return () => {}; + }); + const onConnect = vi + .fn() + .mockRejectedValue(new Error('Failed to find required BLE characteristics')); + + try { + await withMockedConsoleWarn(async () => { + render( + , + ); + + expect(screen.getByRole('button', { name: /^Reconnect$/i })).toBeInTheDocument(); + const radioCard = screen.getByText('Radio Connection').closest('.bg-deep-black'); + expect(radioCard).toBeTruthy(); + await user.click(within(radioCard as HTMLElement).getByRole('button', { name: 'Connect' })); + + await waitFor(() => { + expect(capturedCb).toBeTruthy(); + }); + act(() => { + capturedCb?.({ + deviceId: darwinUuid, + deviceName: 'MeshCore-NV0N', + address: 'ac:a7:04:00:d6:f1', + rssi: -62, + }); + }); + + await user.click( + await screen.findByRole('button', { name: /MeshCore-NV0N ac:a7:04:00:d6:f1/i }), + ); + + await waitFor(() => { + expect(localStorage.getItem(lastConnKey)).toBeNull(); + expect(localStorage.getItem(lastBleKey)).toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByRole('button', { name: /^Reconnect$/i })).not.toBeInTheDocument(); + }); + }); + } finally { + localStorage.removeItem(lastConnKey); + localStorage.removeItem(lastBleKey); + restore(); + } + }); + it('shows Bluetooth MAC on Last Connection and the connected Radio Connection card', () => { localStorage.setItem( lastConnKey, diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index e31095d49..d2f26f29a 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -38,7 +38,7 @@ import { loadProtocolMqttSettings, persistMqttSettingsIfChanged, } from '../hooks/useProtocolMqttSettings'; -import { isMeshcoreMissingServicesErrorMessage } from '../lib/bleConnectErrors'; +import { shouldClearMeshcoreBleSelectionForError } from '../lib/bleConnectErrors'; import { cacheBleDeviceMac, getBleDeviceMac, @@ -57,6 +57,7 @@ import { runConnectionPanelStorageMigrations, } from '../lib/connectionPanelStorageMigrations'; import type { FirmwareCheckResult } from '../lib/firmwareCheck'; +import { clearStoredBleSelection as clearStoredBleSelectionForProtocol } from '../lib/lastConnectionStorage'; import { letsMeshPresetConfigurationDeviation, validateLetsMeshManualCredentials, @@ -309,14 +310,6 @@ function saveLastBleDevice(protocol: MeshProtocol, id: string) { } } -function clearLastBleDevice(protocol: MeshProtocol) { - try { - localStorage.removeItem(lastBleDeviceKey(protocol)); - } catch (e) { - console.debug('[ConnectionPanel] clearLastBleDevice ' + errLikeToLogString(e)); - } -} - function loadLastSerialPort(): string | null { try { return localStorage.getItem(LAST_SERIAL_PORT_KEY); @@ -846,14 +839,8 @@ export default function ConnectionPanel({ const clearMeshcoreBleSelectionOnMissingServices = useCallback( (err: unknown) => { - if (protocol !== 'meshcore') return; - const message = err instanceof Error ? err.message : String(err); - const isMissingServices = - isMeshcoreMissingServicesErrorMessage(message) || - message === 'meshcore.errors.bleMissingServices'; - if (!isMissingServices) return; - clearLastConnection('meshcore'); - clearLastBleDevice('meshcore'); + if (protocol !== 'meshcore' || !shouldClearMeshcoreBleSelectionForError(err)) return; + clearStoredBleSelectionForProtocol('meshcore'); setLastConnection(null); }, [protocol, setLastConnection], diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx index 2c0261554..fdbc226e8 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx +++ b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ loadLastConnection: vi.fn(), loadLastBleDeviceId: vi.fn(), saveLastConnection: vi.fn(), + clearStoredBleSelection: vi.fn(), reconnectBleWithScan: vi.fn(), awaitReticulumBleCoexistenceClear: vi.fn(), dualNobleBleBothRadiosConfigured: vi.fn(), @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@/renderer/lib/lastConnectionStorage', () => ({ + clearStoredBleSelection: mocks.clearStoredBleSelection, loadLastConnection: mocks.loadLastConnection, loadLastBleDeviceId: mocks.loadLastBleDeviceId, saveLastConnection: mocks.saveLastConnection, @@ -68,6 +70,7 @@ describe('useProtocolRfAutoConnect cold-start skip paths', () => { vi.clearAllMocks(); mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); + mocks.clearStoredBleSelection.mockReset(); mocks.reconnectBleWithScan.mockImplementation(async (_p, _id, attempt) => { await attempt(); }); @@ -224,6 +227,7 @@ describe('useProtocolRfAutoConnect cold-start TCP/HTTP', () => { vi.clearAllMocks(); mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); + mocks.clearStoredBleSelection.mockReset(); mocks.awaitReticulumBleCoexistenceClear.mockResolvedValue(undefined); mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(false); mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue(null); @@ -397,6 +401,7 @@ describe('useProtocolRfAutoConnect cold-start serial + BLE', () => { vi.clearAllMocks(); mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); + mocks.clearStoredBleSelection.mockReset(); mocks.reconnectBleWithScan.mockImplementation(async (_p, _id, attempt) => { await attempt(); }); @@ -595,4 +600,27 @@ describe('useProtocolRfAutoConnect cold-start serial + BLE', () => { expect(protocolOrder).toBeLessThan(connectOrder); }, ); + + it('clears remembered MeshCore BLE selection after missing-services auto-connect failure', async () => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue('darwin'); + mocks.loadLastConnection.mockReturnValue({ type: 'ble', bleDeviceId: 'meshcore-ble' }); + const connectAutomatic = vi + .fn() + .mockRejectedValue(new Error('Failed to find required BLE characteristics')); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('ble', undefined, undefined, 'meshcore-ble'); + }); + await waitFor(() => { + expect(mocks.clearStoredBleSelection).toHaveBeenCalledWith('meshcore'); + }); + }); }); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.ts b/src/renderer/hooks/useProtocolRfAutoConnect.ts index 88f10276c..dbce8c5e9 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.ts +++ b/src/renderer/hooks/useProtocolRfAutoConnect.ts @@ -3,8 +3,7 @@ import { useEffect, useRef } from 'react'; import { reconnectBleWithScan } from '@/renderer/lib/bleReconnectHelper'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { - clearLastBleDeviceId, - clearLastConnection, + clearStoredBleSelection, type LastConnection, loadLastBleDeviceId, loadLastConnection, @@ -31,7 +30,7 @@ import { tryGetMeshtasticSession } from '@/renderer/lib/sessions/meshtasticSessi import { POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS } from '@/renderer/lib/timeConstants'; import type { DeviceState, MeshProtocol } from '@/renderer/lib/types'; -import { isMeshcoreMissingServicesErrorMessage } from '../lib/bleConnectErrors'; +import { shouldClearMeshcoreBleSelectionForError } from '../lib/bleConnectErrors'; export interface UseProtocolRfAutoConnectOptions { protocol: MeshProtocol; @@ -147,15 +146,12 @@ export function useProtocolRfAutoConnect({ error: unknown, transport: 'serial' | 'ble' | 'tcp' | 'http' = 'ble', ) => { - if (protocol === 'meshcore' && transport === 'ble') { - const message = error instanceof Error ? error.message : String(error); - const isMissingServices = - isMeshcoreMissingServicesErrorMessage(message) || - message === 'meshcore.errors.bleMissingServices'; - if (isMissingServices) { - clearLastConnection('meshcore'); - clearLastBleDeviceId('meshcore'); - } + if ( + protocol === 'meshcore' && + transport === 'ble' && + shouldClearMeshcoreBleSelectionForError(error) + ) { + clearStoredBleSelection('meshcore'); } clearAutoConnectTimeout(); console.warn( diff --git a/src/renderer/lib/bleConnectErrors.test.ts b/src/renderer/lib/bleConnectErrors.test.ts index 971d6887d..f60ee5e50 100644 --- a/src/renderer/lib/bleConnectErrors.test.ts +++ b/src/renderer/lib/bleConnectErrors.test.ts @@ -8,6 +8,7 @@ import { isMeshcoreTcpTransportDeadError, MESHCORE_SETUP_ABORT_MESSAGE, rethrowMeshcoreSetupAbortFromTcpDead, + shouldClearMeshcoreBleSelectionForError, } from './bleConnectErrors'; describe('isMeshcoreMissingServicesErrorMessage', () => { @@ -44,6 +45,28 @@ describe('classifyMeshcoreBleTimeoutStage', () => { }); }); +describe('shouldClearMeshcoreBleSelectionForError', () => { + it('matches main-process missing characteristics errors', () => { + expect( + shouldClearMeshcoreBleSelectionForError( + new Error('Failed to find required BLE characteristics'), + ), + ).toBe(true); + }); + + it('matches translated MeshCore missing-services keys', () => { + expect(shouldClearMeshcoreBleSelectionForError('meshcore.errors.bleMissingServices')).toBe( + true, + ); + }); + + it('does not match unrelated BLE failures', () => { + expect(shouldClearMeshcoreBleSelectionForError('Bluetooth adapter is not powered on')).toBe( + false, + ); + }); +}); + describe('isMeshcoreSetupAbortError', () => { it('matches MeshCore setup cancel AbortError', () => { expect( diff --git a/src/renderer/lib/bleConnectErrors.ts b/src/renderer/lib/bleConnectErrors.ts index 567552cc4..08893f87d 100644 --- a/src/renderer/lib/bleConnectErrors.ts +++ b/src/renderer/lib/bleConnectErrors.ts @@ -60,6 +60,14 @@ export function isMeshcoreMissingServicesErrorMessage(message: string): boolean return MESHCORE_MISSING_SERVICES_RE.test(message); } +export function shouldClearMeshcoreBleSelectionForError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + isMeshcoreMissingServicesErrorMessage(message) || + message === 'meshcore.errors.bleMissingServices' + ); +} + /** WinRT / BlueZ sometimes drop the link during GATT service or characteristic discovery. */ const MESHCORE_RETRYABLE_GATT_DISCOVERY_FLAKES_RE = /unreachable while discovering services|unreachable while discovering characteristics|gatt.*unreachable/i; diff --git a/src/renderer/lib/lastConnectionStorage.ts b/src/renderer/lib/lastConnectionStorage.ts index 2d2bacbfe..3b5b06625 100644 --- a/src/renderer/lib/lastConnectionStorage.ts +++ b/src/renderer/lib/lastConnectionStorage.ts @@ -60,6 +60,11 @@ export function clearLastBleDeviceId(protocol: MeshProtocol): void { } } +export function clearStoredBleSelection(protocol: MeshProtocol): void { + clearLastConnection(protocol); + clearLastBleDeviceId(protocol); +} + export function resolveLastBlePeripheralId(protocol: MeshProtocol): string | undefined { const last = loadLastConnection(protocol); return last?.bleDeviceId ?? loadLastBleDeviceId(protocol) ?? undefined; From 5fdefb0b9cf4816eb85401e2dcc508a4ed844062 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 06:56:01 -0600 Subject: [PATCH 6/7] fix(meshcore): clear stale BLE targets on Linux and auto-connect Apply missing-services BLE selection clearing on Linux Web Bluetooth failure paths and broadcast storage clears so mounted connection panels immediately drop stale reconnect targets. --- .../components/ConnectionPanel.test.tsx | 70 +++++++++++++++++++ src/renderer/components/ConnectionPanel.tsx | 29 +++++++- .../hooks/useProtocolRfAutoConnect.test.tsx | 6 ++ .../hooks/useProtocolRfAutoConnect.ts | 2 + src/renderer/lib/lastConnectionStorage.ts | 11 +++ 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index 9d8f100e7..007c358ee 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -1056,6 +1056,48 @@ describe('ConnectionPanel Linux BLE path', () => { expect(screen.getByText(/paired with your computer using a PIN/i)).toBeInTheDocument(); userAgentSpy.mockRestore(); }); + + it('clears remembered MeshCore BLE selection after Linux missing-services failure', async () => { + const user = userEvent.setup(); + const userAgentSpy = vi.spyOn(window.navigator, 'userAgent', 'get'); + userAgentSpy.mockReturnValue( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36', + ); + const lastConnKey = 'mesh-client:lastConnection:meshcore'; + const lastBleKey = 'mesh-client:lastBleDevice:meshcore'; + localStorage.setItem(lastConnKey, JSON.stringify({ type: 'ble', bleDeviceId: 'bad-device' })); + localStorage.setItem(lastBleKey, 'bad-device'); + const onConnect = vi.fn().mockRejectedValue(new Error('Could not find all requested services')); + + try { + await withMockedConsoleWarn(async () => { + render( + , + ); + + const radioCard = screen.getByText('Radio Connection').closest('.bg-deep-black'); + expect(radioCard).toBeTruthy(); + await user.click(within(radioCard as HTMLElement).getByRole('button', { name: 'Connect' })); + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('ble', undefined); + expect(localStorage.getItem(lastConnKey)).toBeNull(); + expect(localStorage.getItem(lastBleKey)).toBeNull(); + }); + }); + } finally { + localStorage.removeItem(lastConnKey); + localStorage.removeItem(lastBleKey); + userAgentSpy.mockRestore(); + } + }); }); // ─── Firmware status indicator ──────────────────────────────────── @@ -2494,6 +2536,34 @@ describe('ConnectionPanel BLE MAC identity', () => { } }); + it('clears reconnect card when BLE selection-cleared event is emitted', async () => { + const lastConnKey = 'mesh-client:lastConnection:meshcore'; + localStorage.setItem(lastConnKey, JSON.stringify({ type: 'ble', bleDeviceId: darwinUuid })); + + try { + render( + , + ); + + expect(screen.getByRole('button', { name: /^Reconnect$/i })).toBeInTheDocument(); + window.dispatchEvent( + new CustomEvent('mesh-client:ble-selection-cleared', { detail: { protocol: 'meshcore' } }), + ); + await waitFor(() => { + expect(screen.queryByRole('button', { name: /^Reconnect$/i })).not.toBeInTheDocument(); + }); + } finally { + localStorage.removeItem(lastConnKey); + } + }); + it('formats a compact 12-hex picker deviceId as a colon MAC', async () => { const user = userEvent.setup(); const userAgentSpy = vi.spyOn(window.navigator, 'userAgent', 'get'); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index d2f26f29a..2053a5bc1 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -57,7 +57,10 @@ import { runConnectionPanelStorageMigrations, } from '../lib/connectionPanelStorageMigrations'; import type { FirmwareCheckResult } from '../lib/firmwareCheck'; -import { clearStoredBleSelection as clearStoredBleSelectionForProtocol } from '../lib/lastConnectionStorage'; +import { + BLE_SELECTION_CLEARED_EVENT, + clearStoredBleSelection as clearStoredBleSelectionForProtocol, +} from '../lib/lastConnectionStorage'; import { letsMeshPresetConfigurationDeviation, validateLetsMeshManualCredentials, @@ -855,6 +858,18 @@ export default function ConnectionPanel({ setLastConnection(loadLastConnection(protocol)); }, [protocol]); + useEffect(() => { + const handleBleSelectionCleared = (event: Event) => { + const detail = (event as CustomEvent<{ protocol: MeshProtocol }>).detail; + if (detail?.protocol !== protocol) return; + setLastConnection(null); + }; + window.addEventListener(BLE_SELECTION_CLEARED_EVENT, handleBleSelectionCleared); + return () => { + window.removeEventListener(BLE_SELECTION_CLEARED_EVENT, handleBleSelectionCleared); + }; + }, [protocol]); + useEffect(() => { pendingMeshcoreLinuxWbMacRef.current = null; }, [protocol]); @@ -1357,6 +1372,7 @@ export default function ConnectionPanel({ return; } catch (err) { // catch-no-log-ok -- error is humanized and surfaced via setError + clearMeshcoreBleSelectionOnMissingServices(err); const bleErrMsg = humanizeBleError(err, t); const mac = lastSelectedBleMacRef.current; if (mac) { @@ -1424,7 +1440,15 @@ export default function ConnectionPanel({ setConnecting(false); setConnectionStage(''); } - }, [connectionType, activeHostAddress, onConnect, protocol, isLinux, t]); + }, [ + connectionType, + activeHostAddress, + onConnect, + protocol, + isLinux, + t, + clearMeshcoreBleSelectionOnMissingServices, + ]); const handleCancelConnection = useCallback(() => { cancelProtocolRfAutoConnect(protocol); @@ -1644,6 +1668,7 @@ export default function ConnectionPanel({ // catch-no-log-ok reconnect errors surfaced via setError/humanizeBleError isAutoConnectingRef.current = false; setIsAutoConnecting(false); + clearMeshcoreBleSelectionOnMissingServices(err); const bleErrMsg = humanizeBleError(err, t); if (bleErrMsg) setError(bleErrMsg); const isPairingRelatedError = shouldShowLinuxRePairFromBleError(err, bleErrMsg); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx index fdbc226e8..2122ff86f 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx +++ b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ loadLastBleDeviceId: vi.fn(), saveLastConnection: vi.fn(), clearStoredBleSelection: vi.fn(), + notifyBleSelectionCleared: vi.fn(), reconnectBleWithScan: vi.fn(), awaitReticulumBleCoexistenceClear: vi.fn(), dualNobleBleBothRadiosConfigured: vi.fn(), @@ -25,6 +26,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/renderer/lib/lastConnectionStorage', () => ({ clearStoredBleSelection: mocks.clearStoredBleSelection, + notifyBleSelectionCleared: mocks.notifyBleSelectionCleared, loadLastConnection: mocks.loadLastConnection, loadLastBleDeviceId: mocks.loadLastBleDeviceId, saveLastConnection: mocks.saveLastConnection, @@ -71,6 +73,7 @@ describe('useProtocolRfAutoConnect cold-start skip paths', () => { mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); mocks.clearStoredBleSelection.mockReset(); + mocks.notifyBleSelectionCleared.mockReset(); mocks.reconnectBleWithScan.mockImplementation(async (_p, _id, attempt) => { await attempt(); }); @@ -228,6 +231,7 @@ describe('useProtocolRfAutoConnect cold-start TCP/HTTP', () => { mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); mocks.clearStoredBleSelection.mockReset(); + mocks.notifyBleSelectionCleared.mockReset(); mocks.awaitReticulumBleCoexistenceClear.mockResolvedValue(undefined); mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(false); mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue(null); @@ -402,6 +406,7 @@ describe('useProtocolRfAutoConnect cold-start serial + BLE', () => { mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); mocks.clearStoredBleSelection.mockReset(); + mocks.notifyBleSelectionCleared.mockReset(); mocks.reconnectBleWithScan.mockImplementation(async (_p, _id, attempt) => { await attempt(); }); @@ -622,5 +627,6 @@ describe('useProtocolRfAutoConnect cold-start serial + BLE', () => { await waitFor(() => { expect(mocks.clearStoredBleSelection).toHaveBeenCalledWith('meshcore'); }); + expect(mocks.notifyBleSelectionCleared).toHaveBeenCalledWith('meshcore'); }); }); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.ts b/src/renderer/hooks/useProtocolRfAutoConnect.ts index dbce8c5e9..e43fd6939 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.ts +++ b/src/renderer/hooks/useProtocolRfAutoConnect.ts @@ -7,6 +7,7 @@ import { type LastConnection, loadLastBleDeviceId, loadLastConnection, + notifyBleSelectionCleared, saveLastConnection, } from '@/renderer/lib/lastConnectionStorage'; import { @@ -152,6 +153,7 @@ export function useProtocolRfAutoConnect({ shouldClearMeshcoreBleSelectionForError(error) ) { clearStoredBleSelection('meshcore'); + notifyBleSelectionCleared('meshcore'); } clearAutoConnectTimeout(); console.warn( diff --git a/src/renderer/lib/lastConnectionStorage.ts b/src/renderer/lib/lastConnectionStorage.ts index 3b5b06625..5d9f8c5e2 100644 --- a/src/renderer/lib/lastConnectionStorage.ts +++ b/src/renderer/lib/lastConnectionStorage.ts @@ -2,6 +2,8 @@ import { parseStoredJson } from './parseStoredJson'; import { LAST_SERIAL_PORT_KEY } from './serialPortSignature'; import type { ConnectionType, MeshProtocol } from './types'; +export const BLE_SELECTION_CLEARED_EVENT = 'mesh-client:ble-selection-cleared'; + export interface LastConnection { type: ConnectionType; httpAddress?: string; @@ -65,6 +67,15 @@ export function clearStoredBleSelection(protocol: MeshProtocol): void { clearLastBleDeviceId(protocol); } +export function notifyBleSelectionCleared(protocol: MeshProtocol): void { + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return; + window.dispatchEvent( + new CustomEvent<{ protocol: MeshProtocol }>(BLE_SELECTION_CLEARED_EVENT, { + detail: { protocol }, + }), + ); +} + export function resolveLastBlePeripheralId(protocol: MeshProtocol): string | undefined { const last = loadLastConnection(protocol); return last?.bleDeviceId ?? loadLastBleDeviceId(protocol) ?? undefined; From 95f8f959437f9c7968c9b5a61929a6f42fa9a830 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 07:22:40 -0600 Subject: [PATCH 7/7] fix(meshcore): add behavioral BLE fallback + Linux/UI test coverage Replace MeshCore missing-services discovery retry test with a true mocked peripheral behavioral check, add Linux reconnect coverage for stale BLE target clearing, and strengthen MeshCore room long-name UI checks (class + axe). --- src/main/noble-ble-manager.test.ts | 280 +++++++++++++++++- .../components/ConnectionPanel.test.tsx | 44 +++ .../components/NodeListPanel.test.tsx | 8 +- 3 files changed, 323 insertions(+), 9 deletions(-) diff --git a/src/main/noble-ble-manager.test.ts b/src/main/noble-ble-manager.test.ts index 7c62c9286..6554e1720 100644 --- a/src/main/noble-ble-manager.test.ts +++ b/src/main/noble-ble-manager.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { readFileSync } from 'fs'; import { join } from 'path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; /** * NobleBleManager depends on @stoprocent/noble (native). We cannot unit-test @@ -12,6 +12,31 @@ import { describe, expect, it } from 'vitest'; */ const SOURCE = readFileSync(join(__dirname, 'noble-ble-manager.ts'), 'utf-8'); +vi.mock('@stoprocent/noble', () => { + const api = { + state: 'poweredOn', + on: vi.fn(() => api), + removeListener: vi.fn(), + removeAllListeners: vi.fn(), + startScanning: vi.fn(), + stopScanning: vi.fn(), + stop: vi.fn(), + }; + return api; +}); + +vi.mock('./ble-coexistence-coordinator', () => ({ + bleCoexistenceCoordinator: { + assertCanConnect: vi.fn(), + register: vi.fn(), + unregister: vi.fn(), + // Not used by connect(), but kept to avoid accidental runtime import failures + // if other code paths are reached during the test. + acquireScan: vi.fn(), + releaseScan: vi.fn(), + }, +})); + describe('NobleBleManager.startScanning (regression)', () => { it('preserves connected peripherals and re-emits deviceDiscovered when clearing knownPeripherals', () => { expect(SOURCE).toMatch(/stillConnected/); @@ -144,13 +169,254 @@ describe('NobleBleManager.connect — per-session UUID selection (regression)', expect(meshcoreBranch).not.toContain('fromNumChar'); }); - it('retries MeshCore non-Windows discovery once with full discovery after missing-services failure', () => { - expect(SOURCE).toContain('isMeshcoreMissingServicesDiscoveryError'); - expect(SOURCE).toContain('retrying once with full discovery'); - expect(SOURCE).toContain('BLE full GATT discovery (meshcore fallback)'); - expect(SOURCE).toMatch( - /isMeshcore &&\s*!IS_WIN32 &&\s*isMeshcoreMissingServicesDiscoveryError/, + it('retries MeshCore non-Windows discovery once with full discovery after missing-services failure', async () => { + // IS_WIN32 is compile-time derived from process.platform at module load. + if (process.platform === 'win32') return; + + const { NobleBleManager } = await import('./noble-ble-manager'); + + const MESHCORE_RX_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; + const MESHCORE_TX_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; + const MESHCORE_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; + + const makeChar = (uuid: string, properties: string[]) => { + return { + uuid, + properties, + on: vi.fn().mockReturnThis(), + off: vi.fn().mockReturnThis(), + removeListener: vi.fn().mockReturnThis(), + removeAllListeners: vi.fn().mockReturnThis(), + readAsync: vi.fn().mockResolvedValue(Buffer.alloc(0)), + writeAsync: vi.fn().mockResolvedValue(undefined), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + unsubscribeAsync: vi.fn().mockResolvedValue(undefined), + }; + }; + + const rxChar = makeChar(MESHCORE_RX_UUID, ['write']); + const txChar = makeChar(MESHCORE_TX_UUID, ['notify']); + + const discoverSomeServicesAndCharacteristicsAsync = vi + .fn() + .mockRejectedValue(new Error('Could not find all requested services')); + const discoverAllServicesAndCharacteristicsAsync = vi.fn().mockResolvedValue({ + characteristics: [rxChar, txChar], + }); + + const peripheralId = 'peripheral-1'; + const peripheral = { + id: peripheralId, + address: 'aa:bb:cc:dd:ee:ff', + advertisement: { localName: 'MeshCore-NUS' }, + rssi: -55, + mtu: 23, + state: 'disconnected', + connectAsync: vi.fn().mockImplementation(() => { + peripheral.state = 'connected'; + return Promise.resolve(); + }), + disconnectAsync: vi.fn().mockImplementation(() => { + peripheral.state = 'disconnected'; + return Promise.resolve(); + }), + updateRssiAsync: vi.fn().mockResolvedValue(-55), + once: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + removeListener: vi.fn().mockReturnThis(), + removeAllListeners: vi.fn().mockReturnThis(), + discoverSomeServicesAndCharacteristicsAsync, + discoverAllServicesAndCharacteristicsAsync, + }; + + const manager = new NobleBleManager() as unknown as { + knownPeripherals: Map; + sessions: Map; + startLinkRssiPolling: ( + sessionId: string, + session: unknown, + peripheral: unknown, + seedRssi: unknown, + ) => void; + connect: (sessionId: 'meshcore' | 'meshtastic', peripheralId: string) => Promise; + isConnected: (sessionId: 'meshcore' | 'meshtastic') => boolean; + }; + + // Avoid leaving active timers in the test process. + manager.startLinkRssiPolling = vi.fn(); + (manager as any).adapterReady = true; + manager.knownPeripherals.set(peripheralId, peripheral); + + await manager.connect('meshcore', peripheralId); + + expect(discoverSomeServicesAndCharacteristicsAsync).toHaveBeenCalledTimes(1); + expect(discoverSomeServicesAndCharacteristicsAsync).toHaveBeenCalledWith( + [MESHCORE_SERVICE_UUID], + [MESHCORE_RX_UUID, MESHCORE_TX_UUID], + ); + expect(discoverAllServicesAndCharacteristicsAsync).toHaveBeenCalledTimes(1); + + expect(manager.isConnected('meshcore')).toBe(true); + const session = manager.sessions.get('meshcore') as any; + expect(session.toRadioChar?.uuid).toBe(MESHCORE_RX_UUID); + expect(session.fromRadioChar?.uuid).toBe(MESHCORE_TX_UUID); + }); + + it('does not full-discovery retry for unrelated discovery errors (non-Windows)', async () => { + if (process.platform === 'win32') return; + + const { NobleBleManager } = await import('./noble-ble-manager'); + + const makeChar = (uuid: string, properties: string[]) => { + return { + uuid, + properties, + on: vi.fn().mockReturnThis(), + off: vi.fn().mockReturnThis(), + removeListener: vi.fn().mockReturnThis(), + removeAllListeners: vi.fn().mockReturnThis(), + readAsync: vi.fn().mockResolvedValue(Buffer.alloc(0)), + writeAsync: vi.fn().mockResolvedValue(undefined), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + unsubscribeAsync: vi.fn().mockResolvedValue(undefined), + }; + }; + + const peripheralId = 'peripheral-2'; + const discoverSomeServicesAndCharacteristicsAsync = vi + .fn() + .mockRejectedValue(new Error('Bluetooth adapter is not powered on')); + const discoverAllServicesAndCharacteristicsAsync = vi.fn().mockResolvedValue({ + characteristics: [makeChar('irrelevant', ['notify'])], + }); + + const peripheral = { + id: peripheralId, + address: 'aa:bb:cc:dd:ee:ff', + advertisement: { localName: 'NotMeshCore' }, + rssi: -55, + mtu: 23, + state: 'disconnected', + connectAsync: vi.fn().mockImplementation(() => { + peripheral.state = 'connected'; + return Promise.resolve(); + }), + disconnectAsync: vi.fn().mockImplementation(() => { + peripheral.state = 'disconnected'; + return Promise.resolve(); + }), + updateRssiAsync: vi.fn().mockResolvedValue(-55), + once: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + removeListener: vi.fn().mockReturnThis(), + removeAllListeners: vi.fn().mockReturnThis(), + discoverSomeServicesAndCharacteristicsAsync, + discoverAllServicesAndCharacteristicsAsync, + }; + + const manager = new NobleBleManager() as unknown as { + knownPeripherals: Map; + sessions: Map; + startLinkRssiPolling: () => void; + connect: (sessionId: 'meshcore' | 'meshtastic', peripheralId: string) => Promise; + }; + + manager.startLinkRssiPolling = vi.fn(); + (manager as any).adapterReady = true; + manager.knownPeripherals.set(peripheralId, peripheral); + + await expect(manager.connect('meshcore', peripheralId)).rejects.toThrow( + 'Bluetooth adapter is not powered on', ); + + expect(discoverSomeServicesAndCharacteristicsAsync).toHaveBeenCalledTimes(1); + expect(discoverAllServicesAndCharacteristicsAsync).toHaveBeenCalledTimes(0); + }); + + it('does not use targeted→full discovery fallback on Windows (full discovery is primary)', async () => { + if (process.platform !== 'win32') return; + + const { NobleBleManager } = await import('./noble-ble-manager'); + + vi.useFakeTimers(); + try { + const MESHCORE_RX_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; + const MESHCORE_TX_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; + const makeChar = (uuid: string, properties: string[]) => { + return { + uuid, + properties, + on: vi.fn().mockReturnThis(), + off: vi.fn().mockReturnThis(), + removeListener: vi.fn().mockReturnThis(), + removeAllListeners: vi.fn().mockReturnThis(), + readAsync: vi.fn().mockResolvedValue(Buffer.alloc(0)), + writeAsync: vi.fn().mockResolvedValue(undefined), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + unsubscribeAsync: vi.fn().mockResolvedValue(undefined), + }; + }; + + const rxChar = makeChar(MESHCORE_RX_UUID, ['write']); + const txChar = makeChar(MESHCORE_TX_UUID, ['notify']); + + const peripheralId = 'peripheral-3'; + const discoverSomeServicesAndCharacteristicsAsync = vi + .fn() + .mockRejectedValue(new Error('Should not be called')); + const discoverAllServicesAndCharacteristicsAsync = vi.fn().mockResolvedValue({ + characteristics: [rxChar, txChar], + }); + + const peripheral = { + id: peripheralId, + address: 'aa:bb:cc:dd:ee:ff', + advertisement: { localName: 'MeshCore-NUS' }, + rssi: -55, + mtu: 23, + state: 'disconnected', + connectAsync: vi.fn().mockImplementation(() => { + peripheral.state = 'connected'; + return Promise.resolve(); + }), + disconnectAsync: vi.fn().mockImplementation(() => { + peripheral.state = 'disconnected'; + return Promise.resolve(); + }), + updateRssiAsync: vi.fn().mockResolvedValue(-55), + once: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + removeListener: vi.fn().mockReturnThis(), + removeAllListeners: vi.fn().mockReturnThis(), + discoverSomeServicesAndCharacteristicsAsync, + discoverAllServicesAndCharacteristicsAsync, + }; + + const manager = new NobleBleManager() as unknown as { + knownPeripherals: Map; + sessions: Map; + startLinkRssiPolling: () => void; + connect: (sessionId: 'meshcore' | 'meshtastic', peripheralId: string) => Promise; + isConnected: (sessionId: 'meshcore' | 'meshtastic') => boolean; + }; + + manager.startLinkRssiPolling = vi.fn(); + (manager as any).adapterReady = true; + manager.knownPeripherals.set(peripheralId, peripheral); + + await manager.connect('meshcore', peripheralId); + + expect(discoverSomeServicesAndCharacteristicsAsync).toHaveBeenCalledTimes(0); + expect(discoverAllServicesAndCharacteristicsAsync).toHaveBeenCalledTimes(1); + expect(manager.isConnected('meshcore')).toBe(true); + + const session = manager.sessions.get('meshcore') as any; + expect(session.toRadioChar?.uuid).toBe(MESHCORE_RX_UUID); + expect(session.fromRadioChar?.uuid).toBe(MESHCORE_TX_UUID); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } }); }); diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index 007c358ee..af5e58cd2 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -1098,6 +1098,50 @@ describe('ConnectionPanel Linux BLE path', () => { userAgentSpy.mockRestore(); } }); + + it('clears remembered MeshCore BLE selection after Linux missing-services reconnect failure', async () => { + const user = userEvent.setup(); + const userAgentSpy = vi.spyOn(window.navigator, 'userAgent', 'get'); + userAgentSpy.mockReturnValue( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36', + ); + const lastConnKey = 'mesh-client:lastConnection:meshcore'; + const lastBleKey = 'mesh-client:lastBleDevice:meshcore'; + localStorage.setItem(lastConnKey, JSON.stringify({ type: 'ble', bleDeviceId: 'bad-device' })); + localStorage.setItem(lastBleKey, 'bad-device'); + const onConnect = vi.fn().mockRejectedValue(new Error('Could not find all requested services')); + + try { + await withMockedConsoleWarn(async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^Reconnect$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^Reconnect$/i })); + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('ble', undefined); + expect(localStorage.getItem(lastConnKey)).toBeNull(); + expect(localStorage.getItem(lastBleKey)).toBeNull(); + }); + }); + } finally { + localStorage.removeItem(lastConnKey); + localStorage.removeItem(lastBleKey); + userAgentSpy.mockRestore(); + } + }); }); // ─── Firmware status indicator ──────────────────────────────────── diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index df0ed7a2f..c3277675b 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -665,13 +665,13 @@ describe('NodeListPanel import contacts', () => { }, ); - it('shows full MeshCore room long names without forcing truncate', () => { + it('shows full MeshCore room long names without forcing truncate', async () => { const nodeId = 0xfaceb00c; const longName = 'Richmond upon Thames Chat'; const nodes = new Map([ [nodeId, makeNode({ node_id: nodeId, long_name: longName, hw_model: 'Room' })], ]); - render( + const { container } = render( { expect(longNameNode).toBeInTheDocument(); expect(screen.getByLabelText('Has public key')).toBeInTheDocument(); expect(longNameNode.className).not.toContain('truncate'); + expect(longNameNode.className).toContain('whitespace-normal'); + + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); }); it('hides the key icon when the MeshCore contact has no known public key', () => {