@onekeyfe/hd-transport-electron 1.2.2-alpha.100 → 1.2.2-alpha.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ble-packet-capacity.d.ts +2 -0
- package/dist/ble-packet-capacity.d.ts.map +1 -1
- package/dist/{index-b37d926c.js → index-d51c7da6.js} +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{noble-ble-handler-e8b87173.js → noble-ble-handler-0b455c4f.js} +176 -82
- package/dist/noble-ble-handler.d.ts +8 -1
- package/dist/noble-ble-handler.d.ts.map +1 -1
- package/dist/noble-ble-timeouts.d.ts +1 -1
- package/dist/noble-ble-timeouts.d.ts.map +1 -1
- package/dist/types/desktop-api.d.ts +11 -0
- package/dist/types/desktop-api.d.ts.map +1 -1
- package/dist/types/noble-extended.d.ts +1 -0
- package/dist/types/noble-extended.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/ble-packet-capacity.test.ts +8 -1
- package/src/__tests__/noble-ble-handler.test.ts +120 -70
- package/src/ble-packet-capacity.ts +14 -0
- package/src/index.ts +7 -1
- package/src/noble-ble-handler.ts +307 -105
- package/src/noble-ble-timeouts.ts +1 -1
- package/src/types/desktop-api.ts +13 -0
- package/src/types/noble-extended.ts +1 -0
package/src/noble-ble-handler.ts
CHANGED
|
@@ -10,34 +10,94 @@ import {
|
|
|
10
10
|
EOneKeyBleMessageKeys,
|
|
11
11
|
ERRORS,
|
|
12
12
|
HardwareErrorCode,
|
|
13
|
+
HardwareErrorCodeMessage,
|
|
13
14
|
ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
|
|
14
15
|
ONEKEY_SERVICE_UUID,
|
|
15
16
|
ONEKEY_WRITE_CHARACTERISTIC_UUID,
|
|
16
17
|
createKnownBleUuidAliases,
|
|
17
18
|
hasOnekeyCommunicationService,
|
|
19
|
+
isBleStaleBondHardwareError,
|
|
18
20
|
isOnekeyBluetoothDevice,
|
|
21
|
+
isPro2FamilyBleName,
|
|
19
22
|
matchesKnownBleUuid,
|
|
20
23
|
wait,
|
|
21
24
|
} from '@onekeyfe/hd-shared';
|
|
22
25
|
import pRetry from 'p-retry';
|
|
23
26
|
|
|
24
|
-
import { resolveBlePacketCapacity } from './ble-packet-capacity';
|
|
27
|
+
import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capacity';
|
|
25
28
|
import { safeLog } from './types/noble-extended';
|
|
26
29
|
import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
|
|
27
30
|
import {
|
|
28
|
-
|
|
31
|
+
NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
29
32
|
NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
|
|
30
33
|
} from './noble-ble-timeouts';
|
|
31
34
|
|
|
32
35
|
import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
|
|
33
36
|
import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
|
|
34
|
-
import type { NobleBleWriteOptions } from './types/desktop-api';
|
|
37
|
+
import type { NobleBleIpcErrorResponse, NobleBleWriteOptions } from './types/desktop-api';
|
|
35
38
|
import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
|
|
36
39
|
|
|
37
40
|
// Noble will be dynamically imported to avoid bundling issues
|
|
38
41
|
let noble: NobleModule | null = null;
|
|
39
42
|
let logger: Logger | null = null;
|
|
40
43
|
|
|
44
|
+
type NobleBleNativeError = Error & {
|
|
45
|
+
nativeErrorCode?: number;
|
|
46
|
+
nativeErrorDomain?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
|
|
50
|
+
const errorMessage = error.message;
|
|
51
|
+
const isInvalidMacOsBond =
|
|
52
|
+
error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain';
|
|
53
|
+
if (isInvalidMacOsBond) {
|
|
54
|
+
const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
|
|
55
|
+
return ERRORS.TypedError(
|
|
56
|
+
HardwareErrorCode.BleBondInvalid,
|
|
57
|
+
`${HardwareErrorCodeMessage[HardwareErrorCode.BleBondInvalid]} (${nativeErrorMessage})`,
|
|
58
|
+
{
|
|
59
|
+
nativeErrorMessage,
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return ERRORS.TypedError(HardwareErrorCode.BleConnectedError, `${messagePrefix}${errorMessage}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createNobleBleIpcErrorResponse(error: unknown): NobleBleIpcErrorResponse {
|
|
68
|
+
const candidate = error as {
|
|
69
|
+
errorCode?: unknown;
|
|
70
|
+
message?: unknown;
|
|
71
|
+
name?: unknown;
|
|
72
|
+
params?: unknown;
|
|
73
|
+
};
|
|
74
|
+
const errorCode =
|
|
75
|
+
typeof candidate?.errorCode === 'number' ? candidate.errorCode : HardwareErrorCode.UnknownError;
|
|
76
|
+
const message =
|
|
77
|
+
typeof candidate?.message === 'string' ? candidate.message : String(error ?? 'Unknown error');
|
|
78
|
+
const name = typeof candidate?.name === 'string' ? candidate.name : 'Error';
|
|
79
|
+
let params: unknown;
|
|
80
|
+
if (candidate?.params !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
const serializedParams = JSON.stringify(candidate.params);
|
|
83
|
+
params = serializedParams === undefined ? undefined : JSON.parse(serializedParams);
|
|
84
|
+
} catch {
|
|
85
|
+
params = undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
type: 'NobleBleIpcError',
|
|
91
|
+
success: false,
|
|
92
|
+
error: {
|
|
93
|
+
name,
|
|
94
|
+
message,
|
|
95
|
+
errorCode,
|
|
96
|
+
...(params !== undefined ? { params } : {}),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
41
101
|
// Bluetooth state management
|
|
42
102
|
const bluetoothState: {
|
|
43
103
|
available: boolean;
|
|
@@ -90,8 +150,18 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
|
|
|
90
150
|
const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
|
|
91
151
|
const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
|
|
92
152
|
const BLE_CLEANUP_TIMEOUT = 250;
|
|
153
|
+
// A physical teardown must actually reach the OS before the device can return
|
|
154
|
+
// to a clean state; 250ms routinely declared success while CoreBluetooth was
|
|
155
|
+
// still disconnecting, leaving no trace when the teardown never completed.
|
|
156
|
+
const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
|
|
93
157
|
// Renderer release is logical only; this timer physically frees the device.
|
|
94
|
-
|
|
158
|
+
// Keep it SHORT: the Classic 1S goes protocol-deaf when a link is dropped
|
|
159
|
+
// after sitting idle for minutes (field logs 2026-08-19: every deaf window
|
|
160
|
+
// followed a 3-minute-idle disconnect, while disconnects right after traffic
|
|
161
|
+
// have never produced one across 6.5.0's per-call teardown history). A hot
|
|
162
|
+
// disconnect ~20s after the last operation stays inside the proven-safe
|
|
163
|
+
// pattern and also shrinks the window in which phones cannot see the device.
|
|
164
|
+
const BLE_IDLE_DISCONNECT_MS = 20_000;
|
|
95
165
|
// Ceiling while a call is in flight: no outstanding write, but not forever.
|
|
96
166
|
const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
|
|
97
167
|
|
|
@@ -257,6 +327,11 @@ async function initializeNoble(): Promise<void> {
|
|
|
257
327
|
noble = require('@stoprocent/noble') as NobleModule;
|
|
258
328
|
logger?.info('[NobleBLE] Noble library loaded');
|
|
259
329
|
|
|
330
|
+
// Register the process-lifetime state listener before any early return:
|
|
331
|
+
// the poweredOn fast path below would otherwise skip it for the whole
|
|
332
|
+
// session, leaving poweredOff cache/state reconciliation dead.
|
|
333
|
+
setupPersistentStateListener();
|
|
334
|
+
|
|
260
335
|
// Wait for Bluetooth to be ready
|
|
261
336
|
await new Promise<void>((resolve, reject) => {
|
|
262
337
|
if (!noble) {
|
|
@@ -269,9 +344,6 @@ async function initializeNoble(): Promise<void> {
|
|
|
269
344
|
return;
|
|
270
345
|
}
|
|
271
346
|
|
|
272
|
-
// Setup persistent state listener before initialization
|
|
273
|
-
setupPersistentStateListener();
|
|
274
|
-
|
|
275
347
|
const timeout = setTimeout(() => {
|
|
276
348
|
reject(
|
|
277
349
|
ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
|
|
@@ -398,7 +470,21 @@ function armIdleDisconnect(
|
|
|
398
470
|
logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
|
|
399
471
|
const peripheral = connectedDevices.get(deviceId);
|
|
400
472
|
const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
|
|
401
|
-
|
|
473
|
+
// Unsubscribe CCCD before dropping the link, matching every other
|
|
474
|
+
// teardown path: the 1S leaves its notify session half-open when the
|
|
475
|
+
// link drops without an unsubscribe and then ignores application
|
|
476
|
+
// protocol traffic on NEW links for tens of minutes (field log
|
|
477
|
+
// 2026-08-19: the lone unsubscribe-skipping idle teardown caused a
|
|
478
|
+
// 6-attempt reconnect loop; unsubscribe-first teardowns reconnected
|
|
479
|
+
// instantly).
|
|
480
|
+
const pending = unsubscribeNotifications(deviceId)
|
|
481
|
+
.catch(error => {
|
|
482
|
+
logger?.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
|
|
483
|
+
deviceId,
|
|
484
|
+
error: error instanceof Error ? error.message : String(error),
|
|
485
|
+
});
|
|
486
|
+
})
|
|
487
|
+
.then(() => disconnectDevice(deviceId))
|
|
402
488
|
.then(() => {
|
|
403
489
|
// A call is still in flight here; it must reject, not hang.
|
|
404
490
|
// Both 'idle' and 'busy-backstop' report IdleKeepAlive on purpose:
|
|
@@ -508,6 +594,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
|
|
|
508
594
|
|
|
509
595
|
cleanupDevice(deviceId, webContents, {
|
|
510
596
|
cleanupConnection: true,
|
|
597
|
+
// Same stale-object hazard as manual disconnect: an externally dropped
|
|
598
|
+
// link invalidates the cached peripheral for the next reconnect.
|
|
599
|
+
cleanupDiscoveredCache: true,
|
|
511
600
|
sendDisconnectEvent: true,
|
|
512
601
|
cancelOperations: true,
|
|
513
602
|
reason: 'auto-disconnect',
|
|
@@ -544,12 +633,13 @@ function setupMtuListener(
|
|
|
544
633
|
}
|
|
545
634
|
|
|
546
635
|
const listener = (mtu: number) => {
|
|
547
|
-
|
|
636
|
+
const normalizedMtu = resolveNobleAttMtu(mtu);
|
|
637
|
+
if (normalizedMtu === undefined) return;
|
|
548
638
|
// A kept-alive link can outlive the renderer this closure captured.
|
|
549
639
|
if (webContents.isDestroyed()) return;
|
|
550
640
|
webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
|
|
551
641
|
id: deviceId,
|
|
552
|
-
mtu,
|
|
642
|
+
mtu: normalizedMtu,
|
|
553
643
|
});
|
|
554
644
|
};
|
|
555
645
|
deviceMtuListeners.set(deviceId, { peripheral, listener });
|
|
@@ -697,7 +787,7 @@ async function transmitHexDataToDevice(
|
|
|
697
787
|
const toBuffer = Buffer.from(hexData, 'hex');
|
|
698
788
|
const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
|
|
699
789
|
const packetCapacity = resolveBlePacketCapacity(
|
|
700
|
-
peripheral.mtu,
|
|
790
|
+
resolveNobleAttMtu(peripheral.mtu),
|
|
701
791
|
BLE_PACKET_SIZE_MAXIMUM,
|
|
702
792
|
BLE_PACKET_SIZE_FALLBACK
|
|
703
793
|
);
|
|
@@ -761,6 +851,44 @@ async function transmitHexDataToDevice(
|
|
|
761
851
|
}
|
|
762
852
|
|
|
763
853
|
// Handle discovered device (for general enumeration only)
|
|
854
|
+
// A scan that finds nothing costs this much before the direct-connect
|
|
855
|
+
// fallback runs. Every advertisement in the 6.5.0 control logs arrived within
|
|
856
|
+
// 631ms, so this leaves ~2x headroom while keeping a miss cheap.
|
|
857
|
+
const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
|
|
858
|
+
|
|
859
|
+
// Attempts before a link is written off as unable to serve the OneKey service.
|
|
860
|
+
const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
|
|
861
|
+
|
|
862
|
+
// Advertised names, kept for the life of the process: device caches are purged
|
|
863
|
+
// on every disconnect, but the family a device belongs to never changes and
|
|
864
|
+
// decides which connection strategy is safe for it.
|
|
865
|
+
const bleNamesById = new Map<string, string>();
|
|
866
|
+
|
|
867
|
+
function rememberBleName(deviceId: string, name?: string | null): void {
|
|
868
|
+
const trimmed = name?.trim();
|
|
869
|
+
if (trimmed) {
|
|
870
|
+
bleNamesById.set(deviceId, trimmed);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function resolveBleName(deviceId: string): string | undefined {
|
|
875
|
+
const live =
|
|
876
|
+
connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
|
|
877
|
+
discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
|
|
878
|
+
return live || bleNamesById.get(deviceId);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Pro2/Neo connect by id first: they are unaffected by the stale-session
|
|
883
|
+
* problem and advertise under Find My names a OneKey-filtered scan cannot
|
|
884
|
+
* match. Everything else — Classic family, and any device whose name is not
|
|
885
|
+
* known yet — scans first, because connecting by id can resolve a session the
|
|
886
|
+
* device no longer serves (see setupConnectionAndDiscoverServices).
|
|
887
|
+
*/
|
|
888
|
+
function shouldConnectByIdFirst(deviceId: string): boolean {
|
|
889
|
+
return isPro2FamilyBleName(resolveBleName(deviceId));
|
|
890
|
+
}
|
|
891
|
+
|
|
764
892
|
function handleDeviceDiscovered(peripheral: Peripheral): void {
|
|
765
893
|
// Only process OneKey candidates for general discovery. Avoid logging every
|
|
766
894
|
// ambient BLE peripheral; it makes Pro2 debugging hard to read.
|
|
@@ -770,6 +898,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
|
|
|
770
898
|
|
|
771
899
|
const isNewDevice = !discoveredDevices.has(peripheral.id);
|
|
772
900
|
discoveredDevices.set(peripheral.id, peripheral);
|
|
901
|
+
rememberBleName(peripheral.id, peripheral.advertisement?.localName);
|
|
773
902
|
if (isNewDevice) {
|
|
774
903
|
logger?.debug('[NobleBLE] OneKey BLE device discovered', {
|
|
775
904
|
deviceId: peripheral.id,
|
|
@@ -804,7 +933,10 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
|
|
|
804
933
|
|
|
805
934
|
// Perform targeted scan for a specific device ID
|
|
806
935
|
// Uses self-contained local listener pattern - no global state needed
|
|
807
|
-
async function performTargetedScan(
|
|
936
|
+
async function performTargetedScan(
|
|
937
|
+
targetDeviceId: string,
|
|
938
|
+
timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
|
|
939
|
+
): Promise<Peripheral | null> {
|
|
808
940
|
if (!noble) {
|
|
809
941
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
810
942
|
}
|
|
@@ -849,7 +981,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
|
|
|
849
981
|
const timeoutId = setTimeout(() => {
|
|
850
982
|
logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
|
|
851
983
|
finish(null).catch(reject);
|
|
852
|
-
},
|
|
984
|
+
}, timeoutMs);
|
|
853
985
|
|
|
854
986
|
// Add local listener for this scan
|
|
855
987
|
nobleInstance.on('discover', onDiscover);
|
|
@@ -975,12 +1107,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
975
1107
|
const peripheral = discoveredDevices.get(deviceId);
|
|
976
1108
|
if (peripheral) {
|
|
977
1109
|
const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
|
|
1110
|
+
const mtu = resolveNobleAttMtu(peripheral.mtu);
|
|
978
1111
|
return {
|
|
979
1112
|
commType: 'electron-ble',
|
|
980
1113
|
id: peripheral.id,
|
|
981
1114
|
name: deviceName,
|
|
982
1115
|
state: peripheral.state || 'disconnected',
|
|
983
|
-
...(
|
|
1116
|
+
...(mtu === undefined ? {} : { mtu }),
|
|
984
1117
|
};
|
|
985
1118
|
}
|
|
986
1119
|
|
|
@@ -988,12 +1121,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
988
1121
|
const connectedPeripheral = connectedDevices.get(deviceId);
|
|
989
1122
|
if (connectedPeripheral) {
|
|
990
1123
|
const deviceName = connectedPeripheral.advertisement?.localName || 'Unknown Device';
|
|
1124
|
+
const mtu = resolveNobleAttMtu(connectedPeripheral.mtu);
|
|
991
1125
|
return {
|
|
992
1126
|
commType: 'electron-ble',
|
|
993
1127
|
id: connectedPeripheral.id,
|
|
994
1128
|
name: deviceName,
|
|
995
1129
|
state: connectedPeripheral.state || 'connected',
|
|
996
|
-
...(
|
|
1130
|
+
...(mtu === undefined ? {} : { mtu }),
|
|
997
1131
|
};
|
|
998
1132
|
}
|
|
999
1133
|
|
|
@@ -1051,9 +1185,17 @@ async function discoverServicesAndCharacteristics(
|
|
|
1051
1185
|
|
|
1052
1186
|
// Main discovery logic as async function
|
|
1053
1187
|
const discoveryPromise = (async (): Promise<CharacteristicPair> => {
|
|
1054
|
-
// Step 1: Discover
|
|
1188
|
+
// Step 1: Discover the OneKey service by UUID. Filtering is not just a
|
|
1189
|
+
// narrowing: an unfiltered discovery is answered from the OS GATT cache, so
|
|
1190
|
+
// a device whose stack still advertises and accepts links but no longer
|
|
1191
|
+
// serves its application layer still looks healthy — the link comes up, the
|
|
1192
|
+
// cached services resolve, the protocol frame goes out and nothing ever
|
|
1193
|
+
// answers. A targeted query returns nothing in that state, which is what
|
|
1194
|
+
// 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
|
|
1195
|
+
// scan) — the sequence that brings such a device back. Pro2/Neo expose the
|
|
1196
|
+
// same service UUID, and the selection below only ever accepts that one.
|
|
1055
1197
|
const services = await new Promise<Service[]>((resolve, reject) => {
|
|
1056
|
-
peripheral.discoverServices(
|
|
1198
|
+
peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
|
|
1057
1199
|
if (error) {
|
|
1058
1200
|
logger?.error('[NobleBLE] Service discovery failed:', error);
|
|
1059
1201
|
reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
|
|
@@ -1064,7 +1206,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1064
1206
|
});
|
|
1065
1207
|
|
|
1066
1208
|
if (!services || services.length === 0) {
|
|
1067
|
-
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
|
|
1209
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
|
|
1068
1210
|
}
|
|
1069
1211
|
|
|
1070
1212
|
logger?.debug('[NobleBLE] services discovered', {
|
|
@@ -1169,9 +1311,14 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1169
1311
|
}
|
|
1170
1312
|
|
|
1171
1313
|
// Step 3: Re-establish connection
|
|
1172
|
-
await
|
|
1173
|
-
|
|
1174
|
-
|
|
1314
|
+
await new Promise<void>((resolve, reject) => {
|
|
1315
|
+
peripheral.connect(error => {
|
|
1316
|
+
if (error) {
|
|
1317
|
+
reject(createNobleBleConnectionError(error));
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
resolve();
|
|
1321
|
+
});
|
|
1175
1322
|
});
|
|
1176
1323
|
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1177
1324
|
connectedDevices.set(deviceId, peripheral);
|
|
@@ -1213,12 +1360,7 @@ async function freshScanAndDiscover(
|
|
|
1213
1360
|
await new Promise<void>((resolve, reject) => {
|
|
1214
1361
|
freshPeripheral.connect((error: Error | undefined) => {
|
|
1215
1362
|
if (error) {
|
|
1216
|
-
reject(
|
|
1217
|
-
ERRORS.TypedError(
|
|
1218
|
-
HardwareErrorCode.BleConnectedError,
|
|
1219
|
-
`Fresh peripheral connection failed: ${error.message}`
|
|
1220
|
-
)
|
|
1221
|
-
);
|
|
1363
|
+
reject(createNobleBleConnectionError(error, 'Fresh peripheral connection failed: '));
|
|
1222
1364
|
} else {
|
|
1223
1365
|
connectedDevices.set(deviceId, freshPeripheral);
|
|
1224
1366
|
resolve();
|
|
@@ -1248,12 +1390,14 @@ async function discoverServicesAndCharacteristicsWithRetry(
|
|
|
1248
1390
|
deviceId,
|
|
1249
1391
|
peripheralState: peripheral.state,
|
|
1250
1392
|
attempt: attemptNumber,
|
|
1251
|
-
maxRetries:
|
|
1393
|
+
maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
|
|
1252
1394
|
targetUUIDs: ONEKEY_SERVICE_UUIDS,
|
|
1253
1395
|
});
|
|
1254
1396
|
|
|
1255
1397
|
if (attemptNumber > 1) {
|
|
1256
|
-
logger?.info(
|
|
1398
|
+
logger?.info(
|
|
1399
|
+
`[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`
|
|
1400
|
+
);
|
|
1257
1401
|
}
|
|
1258
1402
|
|
|
1259
1403
|
// Verify connection state before attempting service discovery
|
|
@@ -1267,20 +1411,32 @@ async function discoverServicesAndCharacteristicsWithRetry(
|
|
|
1267
1411
|
try {
|
|
1268
1412
|
return await discoverServicesAndCharacteristics(peripheral);
|
|
1269
1413
|
} catch (error) {
|
|
1270
|
-
logger?.error(
|
|
1414
|
+
logger?.error(
|
|
1415
|
+
`[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
|
|
1416
|
+
);
|
|
1271
1417
|
|
|
1272
|
-
if (attemptNumber <
|
|
1273
|
-
logger?.error(
|
|
1418
|
+
if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
|
|
1419
|
+
logger?.error(
|
|
1420
|
+
`[NobleBLE] Will retry service discovery (attempt ${
|
|
1421
|
+
attemptNumber + 1
|
|
1422
|
+
}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
|
|
1423
|
+
);
|
|
1274
1424
|
}
|
|
1275
1425
|
|
|
1276
1426
|
throw error; // p-retry will handle the retry logic
|
|
1277
1427
|
}
|
|
1278
1428
|
},
|
|
1279
1429
|
{
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1430
|
+
// One retry, not four. An empty result here is the signature of a device
|
|
1431
|
+
// whose stack no longer serves its application layer, and retrying the
|
|
1432
|
+
// same link has never recovered it — every observed run failed all the
|
|
1433
|
+
// way through. The recovery is the teardown and cold reconnect the caller
|
|
1434
|
+
// performs afterwards, so reaching it sooner is what shortens the wait.
|
|
1435
|
+
// The single retry stays for a genuinely transient miss.
|
|
1436
|
+
retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
|
|
1437
|
+
factor: 1.5,
|
|
1438
|
+
minTimeout: 500,
|
|
1439
|
+
maxTimeout: 3000,
|
|
1284
1440
|
onFailedAttempt: error => {
|
|
1285
1441
|
// This runs after each failed attempt
|
|
1286
1442
|
logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
|
|
@@ -1305,26 +1461,48 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1305
1461
|
deviceId: string,
|
|
1306
1462
|
webContents: WebContents
|
|
1307
1463
|
): Promise<CharacteristicPair> {
|
|
1308
|
-
|
|
1309
|
-
|
|
1464
|
+
// Reset the link before any protocol traffic reaches the device. 6.5.0 did
|
|
1465
|
+
// this on every cold setup and never produced a protocol-deaf Classic 1S
|
|
1466
|
+
// (control log 2026-08-20: 16/16 links answered within ~1s, including after
|
|
1467
|
+
// a 15-minute idle). Demoting it to a discovery-failure fallback removed the
|
|
1468
|
+
// reset entirely for this device, because discovery always succeeds: the link
|
|
1469
|
+
// comes up, GATT resolves from cache, and the device then answers nothing on
|
|
1470
|
+
// a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
|
|
1471
|
+
// link with an intact subscription returns before reaching here, so reuse
|
|
1472
|
+
// inside a workflow is unaffected.
|
|
1310
1473
|
try {
|
|
1311
|
-
|
|
1312
|
-
} catch (
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1474
|
+
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1475
|
+
} catch (resetError) {
|
|
1476
|
+
if (isBleStaleBondHardwareError(resetError)) {
|
|
1477
|
+
throw resetError;
|
|
1478
|
+
}
|
|
1479
|
+
// A failed reset must not abort the attempt: discovery on the existing
|
|
1480
|
+
// link, then the fresh-scan fallback, still have a chance to recover.
|
|
1481
|
+
logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
|
|
1317
1482
|
}
|
|
1318
|
-
|
|
1319
|
-
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1320
1483
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1484
|
+
|
|
1321
1485
|
try {
|
|
1322
1486
|
return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
|
|
1323
|
-
} catch (
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1487
|
+
} catch (discoveryError) {
|
|
1488
|
+
// A connected peripheral does not advertise, so the fresh scan below would
|
|
1489
|
+
// only run out its timeout. Drop the link first, then rescan: the cold
|
|
1490
|
+
// reconnect is what recovers a device whose stack stopped serving its
|
|
1491
|
+
// application layer. Recover here rather than throwing to the caller —
|
|
1492
|
+
// one acquire pays ~2s for the rescan, while a thrown error costs the
|
|
1493
|
+
// renderer a full retry round trip.
|
|
1494
|
+
if (peripheral.state === 'connected') {
|
|
1495
|
+
logger?.error(
|
|
1496
|
+
'[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan',
|
|
1497
|
+
discoveryError
|
|
1498
|
+
);
|
|
1499
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1500
|
+
} else {
|
|
1501
|
+
logger?.error(
|
|
1502
|
+
'[NobleBLE] Service discovery failed, attempting fresh scan...',
|
|
1503
|
+
discoveryError
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1328
1506
|
return freshScanAndDiscover(deviceId, webContents);
|
|
1329
1507
|
}
|
|
1330
1508
|
}
|
|
@@ -1413,10 +1591,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1413
1591
|
// enumerate clears the discovery map; a kept-alive link outlives it.
|
|
1414
1592
|
let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
|
|
1415
1593
|
|
|
1416
|
-
// If device not discovered, try a targeted scan for this specific device
|
|
1417
1594
|
if (!peripheral) {
|
|
1418
|
-
logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
|
|
1419
|
-
|
|
1420
1595
|
// Initialize Noble if not already done
|
|
1421
1596
|
if (!noble) {
|
|
1422
1597
|
await initializeNoble();
|
|
@@ -1426,27 +1601,46 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1426
1601
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
1427
1602
|
}
|
|
1428
1603
|
|
|
1429
|
-
//
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1604
|
+
// A live advertisement proves the device holds no link and will open a
|
|
1605
|
+
// fresh session; connecting by id resolves through the OS cache instead
|
|
1606
|
+
// and can hand back a session the device no longer serves (see
|
|
1607
|
+
// setupConnectionAndDiscoverServices). Scanning is also the faster of the
|
|
1608
|
+
// two when the device does advertise: ~96ms median against ~784ms for
|
|
1609
|
+
// connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
|
|
1610
|
+
// because they are unaffected and may advertise under an unmatchable Find
|
|
1611
|
+
// My name.
|
|
1612
|
+
const byIdFirst = shouldConnectByIdFirst(deviceId);
|
|
1613
|
+
logger?.info('[NobleBLE] Resolving device for cold connect', {
|
|
1614
|
+
deviceId,
|
|
1615
|
+
name: resolveBleName(deviceId) ?? 'unknown',
|
|
1616
|
+
strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
|
|
1617
|
+
});
|
|
1435
1618
|
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1619
|
+
const scanForPeripheral = async () => {
|
|
1620
|
+
try {
|
|
1621
|
+
return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
|
|
1622
|
+
} catch (scanError) {
|
|
1623
|
+
logger?.info('[NobleBLE] Targeted scan failed', {
|
|
1624
|
+
deviceId,
|
|
1625
|
+
error: scanError instanceof Error ? scanError.message : String(scanError),
|
|
1626
|
+
});
|
|
1627
|
+
return undefined;
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
const connectById = async () => {
|
|
1632
|
+
const found = await tryDirectConnectById(deviceId);
|
|
1633
|
+
if (found) {
|
|
1634
|
+
discoveredDevices.set(deviceId, found);
|
|
1445
1635
|
}
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1636
|
+
return found;
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
|
|
1640
|
+
if (!peripheral) {
|
|
1641
|
+
// The preferred route came up empty: not advertising (held elsewhere, or
|
|
1642
|
+
// silent), or not reachable by id. Try the other one before giving up.
|
|
1643
|
+
peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
|
|
1450
1644
|
}
|
|
1451
1645
|
}
|
|
1452
1646
|
|
|
@@ -1455,6 +1649,10 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1455
1649
|
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
|
|
1456
1650
|
}
|
|
1457
1651
|
|
|
1652
|
+
// Also covers the connect-by-id route, whose simulated discovery carries no
|
|
1653
|
+
// service UUIDs and therefore never reaches handleDeviceDiscovered.
|
|
1654
|
+
rememberBleName(deviceId, peripheral.advertisement?.localName);
|
|
1655
|
+
|
|
1458
1656
|
logger?.info('[NobleBLE] Connecting to device:', deviceId);
|
|
1459
1657
|
|
|
1460
1658
|
// Check if device is already connected
|
|
@@ -1530,33 +1728,12 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1530
1728
|
}
|
|
1531
1729
|
|
|
1532
1730
|
return new Promise((resolve, reject) => {
|
|
1533
|
-
let connectionTimedOut = false;
|
|
1534
|
-
const timeout = setTimeout(() => {
|
|
1535
|
-
connectionTimedOut = true;
|
|
1536
|
-
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
|
|
1537
|
-
}, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
|
|
1538
|
-
|
|
1539
1731
|
// TypeScript type assertion - peripheral is guaranteed to be defined at this point
|
|
1540
1732
|
const connectedPeripheral = peripheral as Peripheral;
|
|
1541
1733
|
connectedPeripheral.connect(async (error: Error | undefined) => {
|
|
1542
|
-
clearTimeout(timeout);
|
|
1543
|
-
|
|
1544
|
-
// Noble may invoke the callback after the SDK timed out and released the request.
|
|
1545
|
-
// Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
|
|
1546
|
-
if (connectionTimedOut) {
|
|
1547
|
-
if (!error) {
|
|
1548
|
-
try {
|
|
1549
|
-
connectedPeripheral.disconnect(() => undefined);
|
|
1550
|
-
} catch {
|
|
1551
|
-
// Best-effort cleanup only.
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
return;
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
1734
|
if (error) {
|
|
1558
1735
|
logger?.error('[NobleBLE] Connection failed:', error);
|
|
1559
|
-
reject(
|
|
1736
|
+
reject(createNobleBleConnectionError(error));
|
|
1560
1737
|
return;
|
|
1561
1738
|
}
|
|
1562
1739
|
|
|
@@ -1575,9 +1752,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1575
1752
|
resolve();
|
|
1576
1753
|
} catch (setupError) {
|
|
1577
1754
|
logger?.error('[NobleBLE] Connection setup failed:', setupError);
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1755
|
+
// Never reject from inside a raw disconnect callback: noble only fires
|
|
1756
|
+
// it on a real 'disconnect' event, so a peripheral that is already
|
|
1757
|
+
// down leaves this promise — and the renderer acquire awaiting it —
|
|
1758
|
+
// pending forever. disconnectDevice always settles (it no-ops on an
|
|
1759
|
+
// unknown peripheral and caps the confirm wait).
|
|
1760
|
+
disconnectDevice(deviceId)
|
|
1761
|
+
.catch(() => undefined)
|
|
1762
|
+
.then(() => reject(setupError));
|
|
1581
1763
|
}
|
|
1582
1764
|
});
|
|
1583
1765
|
});
|
|
@@ -1597,11 +1779,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
|
|
|
1597
1779
|
}
|
|
1598
1780
|
|
|
1599
1781
|
await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
1600
|
-
timeoutMs:
|
|
1782
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
1601
1783
|
timeoutBehavior: 'resolve',
|
|
1602
1784
|
});
|
|
1785
|
+
if (peripheral.state === 'connected') {
|
|
1786
|
+
logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
|
|
1787
|
+
}
|
|
1603
1788
|
cleanupDevice(deviceId, undefined, {
|
|
1604
1789
|
cleanupConnection: true,
|
|
1790
|
+
// macOS returns zero GATT services when a previously-disconnected
|
|
1791
|
+
// peripheral object is reconnected; drop the discovery cache so the next
|
|
1792
|
+
// connect resolves a fresh peripheral (direct connect by id, or scan).
|
|
1793
|
+
cleanupDiscoveredCache: true,
|
|
1605
1794
|
sendDisconnectEvent: false,
|
|
1606
1795
|
cancelOperations: true,
|
|
1607
1796
|
reason: 'manual-disconnect',
|
|
@@ -1727,7 +1916,7 @@ async function subscribeNotifications(
|
|
|
1727
1916
|
timeoutBehavior: 'resolve',
|
|
1728
1917
|
});
|
|
1729
1918
|
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1730
|
-
timeoutMs:
|
|
1919
|
+
timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
1731
1920
|
timeoutBehavior: 'reject',
|
|
1732
1921
|
});
|
|
1733
1922
|
|
|
@@ -1742,9 +1931,14 @@ async function subscribeNotifications(
|
|
|
1742
1931
|
});
|
|
1743
1932
|
}
|
|
1744
1933
|
|
|
1934
|
+
const subscribeStartedAt = Date.now();
|
|
1745
1935
|
try {
|
|
1746
1936
|
await rebuildAppSubscription(deviceId, notifyCharacteristic);
|
|
1747
1937
|
subscribedDevices.set(deviceId, true);
|
|
1938
|
+
logger?.info('[NobleBLE] Notification subscription active', {
|
|
1939
|
+
deviceId,
|
|
1940
|
+
ms: Date.now() - subscribeStartedAt,
|
|
1941
|
+
});
|
|
1748
1942
|
} finally {
|
|
1749
1943
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1750
1944
|
subscriptionOperations.set(deviceId, 'idle');
|
|
@@ -1765,7 +1959,13 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1765
1959
|
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1766
1960
|
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
1767
1961
|
ipcMain.removeHandler(channel);
|
|
1768
|
-
ipcMain.handle(channel,
|
|
1962
|
+
ipcMain.handle(channel, async (...args) => {
|
|
1963
|
+
try {
|
|
1964
|
+
return await Promise.resolve(listener(...args));
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
return createNobleBleIpcErrorResponse(error);
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1769
1969
|
};
|
|
1770
1970
|
|
|
1771
1971
|
safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
|
|
@@ -1927,10 +2127,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1927
2127
|
}
|
|
1928
2128
|
|
|
1929
2129
|
await stopScanning().catch(() => undefined);
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2130
|
+
// persistentStateListener is process-lifetime, NOT per-window: this
|
|
2131
|
+
// destroy handler also fires on a renderer soft restart, and removing
|
|
2132
|
+
// the listener here permanently killed poweredOff/poweredOn
|
|
2133
|
+
// reconciliation for the rest of the process (nothing re-registers it:
|
|
2134
|
+
// initializeNoble early-returns once noble is loaded). The null-guard
|
|
2135
|
+
// in setupPersistentStateListener keeps it deduped to one instance.
|
|
1934
2136
|
cleanupNobleListeners();
|
|
1935
2137
|
discoveredDevices.clear();
|
|
1936
2138
|
safeLog(logger, 'info', 'Noble BLE cleanup completed');
|