Skip to content

Commit 5ff82da

Browse files
committed
fix(app): activate Neo after BLE connect
1 parent f8fcc8d commit 5ff82da

7 files changed

Lines changed: 204 additions & 3 deletions

File tree

‎app/app.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"expo": {
33
"name": "chronicle",
44
"slug": "friend-lite-app",
5-
"version": "1.13.0",
5+
"version": "1.14.0",
66
"scheme": "chronicle",
77
"orientation": "portrait",
88
"icon": "./assets/icon.png",

‎app/app/diagnostics.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const EVENT_BADGE_TONES: Record<ConnectionEventType, Tone> = {
1111
scan_stop: 'neutral',
1212
scan_result: 'suggest',
1313
connect_start: 'warning',
14+
device_active: 'success',
1415
connect_success: 'success',
1516
connect_fail: 'danger',
1617
disconnect: 'danger',

‎app/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"ios": "expo run:ios",
99
"web": "expo start --web",
1010
"typecheck": "tsc --noEmit",
11+
"test:wearable-activation": "node scripts/test-wearable-activation.cjs",
1112
"test:voice-protocol": "node scripts/test-voice-protocol.cjs",
1213
"test:phone-duplex": "node scripts/test-phone-duplex-controller.cjs",
1314
"check:theme": "node scripts/check-theme-tokens.mjs"
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
const assert = require('node:assert/strict');
2+
const fs = require('node:fs');
3+
const Module = require('node:module');
4+
const path = require('node:path');
5+
const ts = require('typescript');
6+
7+
function loadTypeScript(sourcePath) {
8+
const source = fs.readFileSync(sourcePath, 'utf8');
9+
const compiled = ts.transpileModule(source, {
10+
compilerOptions: {
11+
esModuleInterop: true,
12+
module: ts.ModuleKind.CommonJS,
13+
target: ts.ScriptTarget.ES2020,
14+
strict: true,
15+
},
16+
fileName: sourcePath,
17+
});
18+
const loaded = new Module(sourcePath, module);
19+
loaded.filename = sourcePath;
20+
loaded.paths = Module._nodeModulePaths(path.dirname(sourcePath));
21+
loaded._compile(compiled.outputText, sourcePath);
22+
return loaded.exports;
23+
}
24+
25+
const activationPath = path.join(__dirname, '../src/services/wearableActivation.ts');
26+
const {
27+
NEO_ACTIVE_CONTROL_UUID,
28+
NEO_ACTIVE_VALUE_BASE64,
29+
WEARABLE_SERVICE_UUID,
30+
activateWearableAfterConnect,
31+
} = loadTypeScript(activationPath);
32+
33+
(async () => {
34+
const writes = [];
35+
const neoTransport = {
36+
characteristicsForDevice: async (deviceId, serviceUuid) => {
37+
assert.equal(deviceId, 'neo-1');
38+
assert.equal(serviceUuid, WEARABLE_SERVICE_UUID);
39+
return [{
40+
uuid: NEO_ACTIVE_CONTROL_UUID.toUpperCase(),
41+
isWritableWithResponse: true,
42+
}];
43+
},
44+
writeCharacteristicWithResponseForDevice: async (...args) => {
45+
writes.push(args);
46+
},
47+
};
48+
49+
assert.equal(
50+
await activateWearableAfterConnect(neoTransport, 'neo-1'),
51+
'neo_activated',
52+
);
53+
assert.deepEqual(writes, [[
54+
'neo-1',
55+
WEARABLE_SERVICE_UUID,
56+
NEO_ACTIVE_CONTROL_UUID,
57+
NEO_ACTIVE_VALUE_BASE64,
58+
]]);
59+
assert.equal(NEO_ACTIVE_VALUE_BASE64, 'AQ==', 'Neo Active is the single byte 0x01');
60+
61+
let genericWriteCount = 0;
62+
const genericTransport = {
63+
characteristicsForDevice: async () => [{
64+
uuid: '19b10001-e8f2-537e-4f6c-d104768a1214',
65+
isWritableWithResponse: true,
66+
}],
67+
writeCharacteristicWithResponseForDevice: async () => {
68+
genericWriteCount += 1;
69+
},
70+
};
71+
assert.equal(
72+
await activateWearableAfterConnect(genericTransport, 'omi-1'),
73+
'not_required',
74+
);
75+
assert.equal(genericWriteCount, 0, 'ordinary OMI devices remain unchanged');
76+
77+
const invalidNeoTransport = {
78+
characteristicsForDevice: async () => [{
79+
uuid: NEO_ACTIVE_CONTROL_UUID,
80+
isWritableWithResponse: false,
81+
}],
82+
writeCharacteristicWithResponseForDevice: async () => {
83+
throw new Error('must not attempt an unsupported write');
84+
},
85+
};
86+
await assert.rejects(
87+
activateWearableAfterConnect(invalidNeoTransport, 'neo-bad'),
88+
/does not support writes with response/,
89+
);
90+
91+
let releaseWrite;
92+
const delayedWrite = new Promise((resolve) => {
93+
releaseWrite = resolve;
94+
});
95+
const delayedTransport = {
96+
characteristicsForDevice: async () => [{
97+
uuid: NEO_ACTIVE_CONTROL_UUID,
98+
isWritableWithResponse: true,
99+
}],
100+
writeCharacteristicWithResponseForDevice: async () => delayedWrite,
101+
};
102+
let activationFinished = false;
103+
const activation = activateWearableAfterConnect(delayedTransport, 'neo-slow')
104+
.then(() => { activationFinished = true; });
105+
await Promise.resolve();
106+
await Promise.resolve();
107+
assert.equal(activationFinished, false, 'connection readiness must wait for the Active write');
108+
releaseWrite();
109+
await activation;
110+
assert.equal(activationFinished, true);
111+
112+
console.log('wearable activation contract tests passed');
113+
})().catch((error) => {
114+
console.error(error);
115+
process.exitCode = 1;
116+
});

‎app/src/contexts/ConnectionLogContext.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type ConnectionEventType =
1616
| 'scan_stop'
1717
| 'scan_result'
1818
| 'connect_start'
19+
| 'device_active'
1920
| 'connect_success'
2021
| 'connect_fail'
2122
| 'disconnect'

‎app/src/hooks/useDeviceConnection.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Alert } from 'react-native';
33
import { OmiConnection, BleAudioCodec, OmiDevice } from 'friend-lite-react-native';
44
import { BleError, BleManager, Subscription } from 'react-native-ble-plx';
55
import { useConnectionLog } from '../contexts/ConnectionLogContext';
6+
import { activateWearableAfterConnect } from '../services/wearableActivation';
67

78
interface UseDeviceConnection {
89
connectedDevice: OmiDevice | null;
@@ -91,7 +92,7 @@ export const useDeviceConnection = (
9192
if (onDisconnect) onDisconnect();
9293
}, 500);
9394
}
94-
}, [onDisconnect, onConnect]);
95+
}, [addEvent, onDisconnect, onConnect]);
9596

9697
const connectToDevice = useCallback(async (deviceId: string) => {
9798
// Connect debounce: ignore rapid double-taps within 100ms
@@ -130,9 +131,33 @@ export const useDeviceConnection = (
130131
},
131132
);
132133

134+
let connectionReady = false;
135+
let connectionDroppedBeforeReady = false;
136+
let connectedStateDeviceId: string | null = null;
137+
let reportedConnected = false;
138+
const reportConnectionStateWhenReady = (id: string, state: string) => {
139+
if (state === 'connected') {
140+
connectedStateDeviceId = id;
141+
if (!connectionReady || reportedConnected) return;
142+
reportedConnected = true;
143+
} else if (!connectionReady) {
144+
connectionDroppedBeforeReady = true;
145+
}
146+
handleConnectionStateChange(id, state);
147+
};
148+
133149
try {
134-
const success = await omiConnection.connect(deviceId, handleConnectionStateChange);
150+
const success = await omiConnection.connect(deviceId, reportConnectionStateWhenReady);
135151
if (success) {
152+
const activation = await activateWearableAfterConnect(diagnosticBleManager, deviceId);
153+
if (activation === 'neo_activated') {
154+
addEvent('device_active', 'Neo Active mode enabled', { deviceId });
155+
}
156+
connectionReady = true;
157+
if (connectionDroppedBeforeReady) {
158+
throw new Error('Device disconnected before the Active handshake completed');
159+
}
160+
reportConnectionStateWhenReady(connectedStateDeviceId ?? deviceId, 'connected');
136161
console.log('Successfully initiated connection to device:', deviceId);
137162
} else {
138163
setIsConnecting(false);
@@ -143,6 +168,12 @@ export const useDeviceConnection = (
143168
}
144169
} catch (error) {
145170
console.error('Connection error:', error);
171+
intentionalDisconnectRef.current = true;
172+
try {
173+
await omiConnection.disconnect();
174+
} catch (disconnectError) {
175+
console.warn('Cleanup after connection error failed:', disconnectError);
176+
}
146177
setIsConnecting(false);
147178
setConnectedDevice(null);
148179
setConnectedDeviceId(null);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export const WEARABLE_SERVICE_UUID = '19b10000-e8f2-537e-4f6c-d104768a1214';
2+
export const NEO_ACTIVE_CONTROL_UUID = '19b10003-e8f2-537e-4f6c-d104768a1214';
3+
export const NEO_ACTIVE_VALUE_BASE64 = 'AQ==';
4+
5+
interface WearableCharacteristic {
6+
uuid: string;
7+
isWritableWithResponse: boolean;
8+
}
9+
10+
export interface WearableControlTransport {
11+
characteristicsForDevice(
12+
deviceId: string,
13+
serviceUuid: string,
14+
): Promise<WearableCharacteristic[]>;
15+
writeCharacteristicWithResponseForDevice(
16+
deviceId: string,
17+
serviceUuid: string,
18+
characteristicUuid: string,
19+
valueBase64: string,
20+
): Promise<unknown>;
21+
}
22+
23+
export type WearableActivationResult = 'neo_activated' | 'not_required';
24+
25+
export async function activateWearableAfterConnect(
26+
transport: WearableControlTransport,
27+
deviceId: string,
28+
): Promise<WearableActivationResult> {
29+
const characteristics = await transport.characteristicsForDevice(
30+
deviceId,
31+
WEARABLE_SERVICE_UUID,
32+
);
33+
const neoControl = characteristics.find(
34+
(characteristic) => characteristic.uuid.toLowerCase() === NEO_ACTIVE_CONTROL_UUID,
35+
);
36+
37+
if (!neoControl) {
38+
return 'not_required';
39+
}
40+
if (!neoControl.isWritableWithResponse) {
41+
throw new Error('Neo Active control does not support writes with response');
42+
}
43+
44+
await transport.writeCharacteristicWithResponseForDevice(
45+
deviceId,
46+
WEARABLE_SERVICE_UUID,
47+
NEO_ACTIVE_CONTROL_UUID,
48+
NEO_ACTIVE_VALUE_BASE64,
49+
);
50+
return 'neo_activated';
51+
}

0 commit comments

Comments
 (0)