diff --git a/src/main/noble-ble-manager.test.ts b/src/main/noble-ble-manager.test.ts index eaf986c32..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/); @@ -143,6 +168,256 @@ 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', 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(); + } + }); }); 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/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index b28353277..af5e58cd2 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -1056,6 +1056,92 @@ 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(); + } + }); + + 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 ──────────────────────────────────── @@ -2342,6 +2428,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, @@ -2414,6 +2580,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 8dd986f4a..2053a5bc1 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -38,6 +38,7 @@ import { loadProtocolMqttSettings, persistMqttSettingsIfChanged, } from '../hooks/useProtocolMqttSettings'; +import { shouldClearMeshcoreBleSelectionForError } from '../lib/bleConnectErrors'; import { cacheBleDeviceMac, getBleDeviceMac, @@ -56,6 +57,10 @@ import { runConnectionPanelStorageMigrations, } from '../lib/connectionPanelStorageMigrations'; import type { FirmwareCheckResult } from '../lib/firmwareCheck'; +import { + BLE_SELECTION_CLEARED_EVENT, + clearStoredBleSelection as clearStoredBleSelectionForProtocol, +} from '../lib/lastConnectionStorage'; import { letsMeshPresetConfigurationDeviation, validateLetsMeshManualCredentials, @@ -834,6 +839,15 @@ export default function ConnectionPanel({ deviceStateRef.current = state; const lastConnectionRef = useRef(lastConnection); lastConnectionRef.current = lastConnection; + + const clearMeshcoreBleSelectionOnMissingServices = useCallback( + (err: unknown) => { + if (protocol !== 'meshcore' || !shouldClearMeshcoreBleSelectionForError(err)) return; + clearStoredBleSelectionForProtocol('meshcore'); + setLastConnection(null); + }, + [protocol, setLastConnection], + ); const connectionTypeRef = useRef(connectionType); connectionTypeRef.current = connectionType; const onAutoConnectRef = useRef(onAutoConnect); @@ -844,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]); @@ -1346,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) { @@ -1413,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); @@ -1535,6 +1570,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 +1578,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) => { @@ -1624,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); @@ -1657,6 +1702,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 +1776,7 @@ export default function ConnectionPanel({ protocol, tcpHost, isLinux, + clearMeshcoreBleSelectionOnMissingServices, t, ]); diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index e994412fd..c3277675b 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -665,6 +665,34 @@ describe('NodeListPanel import contacts', () => { }, ); + 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' })], + ]); + const { container } = render( + , + ); + + const longNameNode = screen.getByText(longName); + 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', () => { 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 || '-'} 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/hooks/useProtocolRfAutoConnect.test.tsx b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx index 2c0261554..2122ff86f 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx +++ b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ loadLastConnection: vi.fn(), loadLastBleDeviceId: vi.fn(), saveLastConnection: vi.fn(), + clearStoredBleSelection: vi.fn(), + notifyBleSelectionCleared: vi.fn(), reconnectBleWithScan: vi.fn(), awaitReticulumBleCoexistenceClear: vi.fn(), dualNobleBleBothRadiosConfigured: vi.fn(), @@ -23,6 +25,8 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@/renderer/lib/lastConnectionStorage', () => ({ + clearStoredBleSelection: mocks.clearStoredBleSelection, + notifyBleSelectionCleared: mocks.notifyBleSelectionCleared, loadLastConnection: mocks.loadLastConnection, loadLastBleDeviceId: mocks.loadLastBleDeviceId, saveLastConnection: mocks.saveLastConnection, @@ -68,6 +72,8 @@ describe('useProtocolRfAutoConnect cold-start skip paths', () => { vi.clearAllMocks(); mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); + mocks.clearStoredBleSelection.mockReset(); + mocks.notifyBleSelectionCleared.mockReset(); mocks.reconnectBleWithScan.mockImplementation(async (_p, _id, attempt) => { await attempt(); }); @@ -224,6 +230,8 @@ describe('useProtocolRfAutoConnect cold-start TCP/HTTP', () => { vi.clearAllMocks(); 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); @@ -397,6 +405,8 @@ describe('useProtocolRfAutoConnect cold-start serial + BLE', () => { vi.clearAllMocks(); mocks.loadLastConnection.mockReturnValue(null); mocks.loadLastBleDeviceId.mockReturnValue(null); + mocks.clearStoredBleSelection.mockReset(); + mocks.notifyBleSelectionCleared.mockReset(); mocks.reconnectBleWithScan.mockImplementation(async (_p, _id, attempt) => { await attempt(); }); @@ -595,4 +605,28 @@ 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'); + }); + expect(mocks.notifyBleSelectionCleared).toHaveBeenCalledWith('meshcore'); + }); }); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.ts b/src/renderer/hooks/useProtocolRfAutoConnect.ts index b584593ac..e43fd6939 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.ts +++ b/src/renderer/hooks/useProtocolRfAutoConnect.ts @@ -3,9 +3,11 @@ import { useEffect, useRef } from 'react'; import { reconnectBleWithScan } from '@/renderer/lib/bleReconnectHelper'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { + clearStoredBleSelection, type LastConnection, loadLastBleDeviceId, loadLastConnection, + notifyBleSelectionCleared, saveLastConnection, } from '@/renderer/lib/lastConnectionStorage'; import { @@ -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 { shouldClearMeshcoreBleSelectionForError } from '../lib/bleConnectErrors'; + export interface UseProtocolRfAutoConnectOptions { protocol: MeshProtocol; state: DeviceState; @@ -143,6 +147,14 @@ export function useProtocolRfAutoConnect({ error: unknown, transport: 'serial' | 'ble' | 'tcp' | 'http' = 'ble', ) => { + if ( + protocol === 'meshcore' && + transport === 'ble' && + shouldClearMeshcoreBleSelectionForError(error) + ) { + clearStoredBleSelection('meshcore'); + notifyBleSelectionCleared('meshcore'); + } clearAutoConnectTimeout(); console.warn( `[useProtocolRfAutoConnect] ${protocol} ${transport} auto-connect failed: ${errLikeToLogString(error)}`, diff --git a/src/renderer/lib/bleConnectErrors.test.ts b/src/renderer/lib/bleConnectErrors.test.ts index f9a3f4301..f60ee5e50 100644 --- a/src/renderer/lib/bleConnectErrors.test.ts +++ b/src/renderer/lib/bleConnectErrors.test.ts @@ -1,13 +1,72 @@ import { describe, expect, it } from 'vitest'; import { + classifyMeshcoreBleTimeoutStage, + isMeshcoreMissingServicesErrorMessage, isMeshcoreRetryableBleErrorMessage, isMeshcoreSetupAbortError, isMeshcoreTcpTransportDeadError, MESHCORE_SETUP_ABORT_MESSAGE, rethrowMeshcoreSetupAbortFromTcpDead, + shouldClearMeshcoreBleSelectionForError, } 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('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 154fc3250..08893f87d 100644 --- a/src/renderer/lib/bleConnectErrors.ts +++ b/src/renderer/lib/bleConnectErrors.ts @@ -53,6 +53,21 @@ 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); +} + +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 626f6d573..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; @@ -35,6 +37,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 +54,28 @@ 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 clearStoredBleSelection(protocol: MeshProtocol): void { + clearLastConnection(protocol); + 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; diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 17a49b92f..1296421f6 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, @@ -391,6 +392,7 @@ import { meshcoreConnectionImpliesUsbPower, meshcoreContactToMeshNode, meshcoreIsChatStubNodeId, + meshcoreIsPlaceholderNodeLongName, meshcoreIsSyntheticPlaceholderPubKeyHex, meshcoreManufacturerModelFromDeviceQuery, meshcoreMergeChannelDisplayNameOntoNode, @@ -1500,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, @@ -4023,7 +4030,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'; @@ -4741,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; @@ -4793,7 +4823,7 @@ export function useMeshcoreRuntime() { } return removed; }, - [], + [readMeshcoreNodes], ); const setOwner = useCallback(