Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 276 additions & 1 deletion src/main/noble-ble-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/);
Expand Down Expand Up @@ -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<string, unknown>;
sessions: Map<string, unknown>;
startLinkRssiPolling: (
sessionId: string,
session: unknown,
peripheral: unknown,
seedRssi: unknown,
) => void;
connect: (sessionId: 'meshcore' | 'meshtastic', peripheralId: string) => Promise<void>;
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<string, unknown>;
sessions: Map<string, unknown>;
startLinkRssiPolling: () => void;
connect: (sessionId: 'meshcore' | 'meshtastic', peripheralId: string) => Promise<void>;
};

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<string, unknown>;
sessions: Map<string, unknown>;
startLinkRssiPolling: () => void;
connect: (sessionId: 'meshcore' | 'meshtastic', peripheralId: string) => Promise<void>;
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)', () => {
Expand Down
45 changes: 36 additions & 9 deletions src/main/noble-ble-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1435,15 +1442,35 @@ export class NobleBleManager extends EventEmitter {
);
characteristics = all.characteristics;
} else {
const discovered = await withTimeout<NobleDiscoveryResult>(
peripheral.discoverSomeServicesAndCharacteristicsAsync(
discoverServiceUuids,
discoverCharUuids,
),
BLE_DISCOVERY_TIMEOUT_MS,
'BLE characteristic discovery',
);
characteristics = discovered.characteristics;
try {
const discovered = await withTimeout<NobleDiscoveryResult>(
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<NobleDiscoveryResult>(
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[] = [];
Expand Down
Loading
Loading