Skip to content

Commit 6f18eb6

Browse files
feat(hooks): make useBluetoothHRM data liveness timeout configurable (#1536)
* feat(hooks): make `useBluetoothHRM` data liveness timeout configurable This change addresses the feedback from PR #1438 by making the `DATA_LIVENESS_TIMEOUT_MS` in the `useBluetoothHRM` hook configurable. The hook now accepts an optional `dataLivenessTimeoutMs` parameter, defaulting to the previous hardcoded value of 10 seconds for backward compatibility. A new `disconnectionReason` state has been added to the hook's return value to provide more granular feedback on the connection status. The UI has been updated to display more informative messages based on this reason. Additionally, a comprehensive suite of unit tests has been added for the `useBluetoothHRM` hook to verify the new functionality and prevent future regressions. * fix(tests): resolve linting error in useBluetoothHRM test This commit fixes a linting error in `tests/unit/useBluetoothHRM.test.ts` by replacing an `any` type with a more specific type inferred from the `useBluetoothHRM` hook's return value. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 37003ff commit 6f18eb6

3 files changed

Lines changed: 259 additions & 5 deletions

File tree

app/client/connect/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,18 @@ export default function ConnectPage() {
2020
batteryLevel,
2121
isConnected,
2222
isSupported,
23+
disconnectionReason,
2324
} = useBluetoothHRM()
2425

2526
const { connectionStatus, hrmData } = useWebSocket()
2627

28+
let deviceStatusMessage = deviceStatus
29+
if (disconnectionReason === 'timeout') {
30+
deviceStatusMessage = 'Connection unstable. Trying to reconnect...'
31+
} else if (disconnectionReason === 'signal_loss') {
32+
deviceStatusMessage = 'Signal lost. Trying to reconnect...'
33+
}
34+
2735
const handleConnect = () => {
2836
const age = userAge ? parseInt(userAge, 10) : 0
2937
connectAndStream(userName, age)
@@ -56,7 +64,7 @@ export default function ConnectPage() {
5664
userAge={userAge}
5765
setUserAge={setUserAge}
5866
isConnected={isConnected}
59-
deviceStatus={deviceStatus}
67+
deviceStatus={deviceStatusMessage}
6068
batteryLevel={batteryLevel}
6169
onConnect={handleConnect}
6270
onDisconnect={disconnect}

hooks/useBluetoothHRM.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,18 @@ const withTimeout = <T>(
5656
})
5757
}
5858

59-
const useBluetoothHRM = () => {
59+
interface UseBluetoothHRMProps {
60+
dataLivenessTimeoutMs?: number
61+
}
62+
63+
type DisconnectionReason = 'manual' | 'timeout' | 'signal_loss' | null
64+
65+
const useBluetoothHRM = (props: UseBluetoothHRMProps = {}) => {
66+
const { dataLivenessTimeoutMs = 10000 } = props
6067
const { sendData, connectionStatus } = useWebSocket()
6168
const [deviceStatus, setDeviceStatus] = useState('Disconnected')
69+
const [disconnectionReason, setDisconnectionReason] =
70+
useState<DisconnectionReason>(null)
6271
const [savedDevice, setSavedDevice] = useState<BluetoothDevice | null>(null)
6372
const [batteryLevel, setBatteryLevel] = useState<number | null>(null)
6473
const [isSupported] = useState(
@@ -90,24 +99,29 @@ const useBluetoothHRM = () => {
9099

91100
// Watchdog for stale data
92101
useEffect(() => {
102+
// A timeout of 0 disables the watchdog
103+
if (!dataLivenessTimeoutMs) return
104+
93105
const interval = setInterval(() => {
94106
if (
95107
statusRef.current.startsWith('Connected') &&
96108
lastDataTime.current > 0
97109
) {
98-
if (Date.now() - lastDataTime.current > 10000) {
110+
if (Date.now() - lastDataTime.current > dataLivenessTimeoutMs) {
99111
console.warn('Bluetooth data stale. Forcing reconnection...')
112+
setDisconnectionReason('timeout')
100113
setDeviceStatus('Connection unstable. Reconnecting...')
101114
if (deviceRef.current?.gatt?.connected)
102115
deviceRef.current.gatt.disconnect()
103116
}
104117
}
105-
}, 2000)
118+
}, 2000) // Check every 2s
106119
return () => clearInterval(interval)
107-
}, [])
120+
}, [dataLivenessTimeoutMs])
108121

109122
const disconnect = useCallback(() => {
110123
isManualDisconnect.current = true
124+
setDisconnectionReason('manual')
111125
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current)
112126
if (deviceRef.current?.gatt?.connected) deviceRef.current.gatt.disconnect()
113127

@@ -162,6 +176,7 @@ const useBluetoothHRM = () => {
162176
setBatteryLevel(null)
163177
if (!isManualDisconnect.current && deviceRef.current) {
164178
console.log('Attempting auto-reconnect...')
179+
setDisconnectionReason('signal_loss')
165180
setDeviceStatus('Signal Lost. Retrying...')
166181
const deviceToReconnect = deviceRef.current
167182
reconnectTimeoutRef.current = setTimeout(() => {
@@ -250,6 +265,7 @@ const useBluetoothHRM = () => {
250265
setSavedDevice(device)
251266
setCookie('hrm_device_id', device.id)
252267
isManualDisconnect.current = false
268+
setDisconnectionReason(null)
253269
return true
254270
} catch (error) {
255271
console.error('GATT Connection failed:', error)
@@ -333,6 +349,7 @@ const useBluetoothHRM = () => {
333349
batteryLevel,
334350
isConnected: deviceStatus.startsWith('Connected'),
335351
isSupported, // Export this flag
352+
disconnectionReason,
336353
}
337354
}
338355

tests/unit/useBluetoothHRM.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { renderHook, act } from '@testing-library/react'
5+
import useBluetoothHRM from '@/hooks/useBluetoothHRM'
6+
import { useWebSocket } from '@/context/WebSocketContext'
7+
8+
// Mock the WebSocket context
9+
jest.mock('@/context/WebSocketContext', () => ({
10+
useWebSocket: jest.fn(),
11+
}))
12+
13+
// Mock navigator.bluetooth
14+
const mockBluetooth = {
15+
requestDevice: jest.fn(),
16+
getDevices: jest.fn(),
17+
}
18+
Object.defineProperty(navigator, 'bluetooth', {
19+
value: mockBluetooth,
20+
writable: true,
21+
})
22+
23+
describe('useBluetoothHRM', () => {
24+
let mockSendData: jest.Mock
25+
let mockCharacteristic: {
26+
startNotifications: jest.Mock
27+
addEventListener: jest.Mock
28+
removeEventListener: jest.Mock
29+
}
30+
let mockGattServer: {
31+
connect: jest.Mock
32+
disconnect: jest.Mock
33+
getPrimaryService: jest.Mock
34+
}
35+
let mockDevice: {
36+
id: string
37+
name: string
38+
gatt: {
39+
connected: boolean
40+
connect: jest.Mock
41+
disconnect: jest.Mock
42+
}
43+
addEventListener: jest.Mock
44+
removeEventListener: jest.Mock
45+
}
46+
47+
beforeEach(() => {
48+
jest.useFakeTimers()
49+
mockSendData = jest.fn()
50+
;(useWebSocket as jest.Mock).mockReturnValue({
51+
sendData: mockSendData,
52+
connectionStatus: 'Connected',
53+
})
54+
55+
mockCharacteristic = {
56+
startNotifications: jest.fn().mockResolvedValue(undefined),
57+
addEventListener: jest.fn(),
58+
removeEventListener: jest.fn(),
59+
}
60+
const mockService = {
61+
getCharacteristic: jest.fn().mockResolvedValue(mockCharacteristic),
62+
}
63+
mockGattServer = {
64+
connect: jest.fn().mockResolvedValue({
65+
getPrimaryService: jest.fn().mockResolvedValue(mockService),
66+
}),
67+
disconnect: jest.fn(),
68+
getPrimaryService: jest.fn().mockResolvedValue(mockService),
69+
}
70+
71+
mockDevice = {
72+
id: 'test-device-id',
73+
name: 'Test HRM',
74+
gatt: {
75+
connected: false,
76+
connect: jest.fn().mockResolvedValue(mockGattServer),
77+
disconnect: jest.fn(),
78+
},
79+
addEventListener: jest.fn(),
80+
removeEventListener: jest.fn(),
81+
}
82+
83+
mockBluetooth.requestDevice.mockResolvedValue(mockDevice)
84+
mockBluetooth.getDevices.mockResolvedValue([])
85+
})
86+
87+
afterEach(() => {
88+
jest.useRealTimers()
89+
jest.clearAllMocks()
90+
})
91+
92+
type UseBluetoothHRMReturn = ReturnType<typeof useBluetoothHRM>
93+
94+
const simulateConnection = async (hook: {
95+
result: { current: UseBluetoothHRMReturn }
96+
}) => {
97+
await act(async () => {
98+
hook.result.current.connectAndStream('Test User', 30)
99+
await Promise.resolve() // Allow promises to resolve
100+
})
101+
// Simulate gatt connected state
102+
Object.defineProperty(mockDevice.gatt, 'connected', {
103+
value: true,
104+
writable: true,
105+
})
106+
}
107+
108+
it('should use default timeout of 10 seconds and trigger reconnect', async () => {
109+
const { result } = renderHook(() => useBluetoothHRM())
110+
111+
await simulateConnection({ result })
112+
expect(result.current.isConnected).toBe(true)
113+
114+
// Advance time past the 10s timeout to the next 2s interval check
115+
act(() => {
116+
jest.advanceTimersByTime(12000)
117+
})
118+
119+
expect(result.current.deviceStatus).toContain('Connection unstable')
120+
expect(result.current.disconnectionReason).toBe('timeout')
121+
expect(mockDevice.gatt.disconnect).toHaveBeenCalled()
122+
})
123+
124+
it('should use custom timeout from props', async () => {
125+
const { result } = renderHook(() =>
126+
useBluetoothHRM({ dataLivenessTimeoutMs: 5000 })
127+
)
128+
await simulateConnection({ result })
129+
expect(result.current.isConnected).toBe(true)
130+
131+
// Advance time by 4 seconds (less than timeout)
132+
act(() => {
133+
jest.advanceTimersByTime(4000)
134+
})
135+
expect(result.current.deviceStatus).not.toContain('Connection unstable')
136+
137+
// Advance time by another 2 seconds (total 6s, more than timeout)
138+
act(() => {
139+
jest.advanceTimersByTime(2000)
140+
})
141+
142+
expect(result.current.deviceStatus).toContain('Connection unstable')
143+
expect(result.current.disconnectionReason).toBe('timeout')
144+
})
145+
146+
it('should disable watchdog if timeout is 0', async () => {
147+
const { result } = renderHook(() =>
148+
useBluetoothHRM({ dataLivenessTimeoutMs: 0 })
149+
)
150+
await simulateConnection({ result })
151+
expect(result.current.isConnected).toBe(true)
152+
153+
// Advance time by a large amount
154+
act(() => {
155+
jest.advanceTimersByTime(20000)
156+
})
157+
158+
expect(result.current.deviceStatus).not.toContain('Connection unstable')
159+
expect(result.current.disconnectionReason).toBe(null)
160+
})
161+
162+
it('should set disconnectionReason to "manual" on disconnect', async () => {
163+
const { result } = renderHook(() => useBluetoothHRM())
164+
await simulateConnection({ result })
165+
expect(result.current.isConnected).toBe(true)
166+
167+
act(() => {
168+
result.current.disconnect()
169+
})
170+
171+
expect(result.current.isConnected).toBe(false)
172+
expect(result.current.disconnectionReason).toBe('manual')
173+
})
174+
175+
it('should reset disconnectionReason on successful reconnect', async () => {
176+
const { result } = renderHook(() =>
177+
useBluetoothHRM({ dataLivenessTimeoutMs: 2000 })
178+
)
179+
await simulateConnection({ result })
180+
181+
// Trigger a timeout. Timeout is 2s, watchdog checks every 2s.
182+
// The check at T=2s will be (2000-0) > 2000 (false).
183+
// The check at T=4s will be (4000-0) > 2000 (true).
184+
act(() => {
185+
jest.advanceTimersByTime(4000)
186+
})
187+
expect(result.current.disconnectionReason).toBe('timeout')
188+
189+
// Simulate gatt disconnected state
190+
Object.defineProperty(mockDevice.gatt, 'connected', {
191+
value: false,
192+
writable: true,
193+
})
194+
195+
// Simulate gatt server disconnection event
196+
const onDisconnectedCallback = mockDevice.addEventListener.mock.calls.find(
197+
(call) => call[0] === 'gattserverdisconnected'
198+
)[1]
199+
act(() => {
200+
onDisconnectedCallback()
201+
})
202+
203+
// It should now be trying to reconnect
204+
expect(result.current.deviceStatus).toContain('Signal Lost. Retrying...')
205+
expect(result.current.disconnectionReason).toBe('signal_loss')
206+
207+
// Advance timers for the reconnect delay
208+
act(() => {
209+
jest.advanceTimersByTime(2000)
210+
})
211+
212+
// Simulate successful reconnection
213+
await act(async () => {
214+
await Promise.resolve() // Allow promises to resolve after reconnect attempt
215+
})
216+
Object.defineProperty(mockDevice.gatt, 'connected', {
217+
value: true,
218+
writable: true,
219+
})
220+
221+
// Re-check status after reconnect logic (might need another tick)
222+
await act(async () => {
223+
await Promise.resolve()
224+
})
225+
226+
expect(result.current.isConnected).toBe(true)
227+
expect(result.current.disconnectionReason).toBe(null)
228+
})
229+
})

0 commit comments

Comments
 (0)