Skip to content

Commit b9a1518

Browse files
committed
✨ Add Bluetooth auto mode support with command serialization
- Add readAutoMode() and setAutoMode() BLE functions - Update fetchDeviceInfoBle() to poll auto mode state - Enable onAutoModeToggle() for Bluetooth mode - Fix race condition in sendCommand() by serializing BLE commands - Add unit tests for new auto mode functions
1 parent e9d23eb commit b9a1518

File tree

6 files changed

+308
-16
lines changed

6 files changed

+308
-16
lines changed

src/hooks/useDeviceControl.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import { ErrorContext } from "@/context/error";
88
import { useNetwork } from "@/context/network";
99
import { TokenContext } from "@/context/token";
1010
import {
11+
readAutoMode,
1112
readFan1Speed,
1213
readPowerLevel,
1314
readPowerState,
1415
readTemperature,
16+
setAutoMode as setBtAutoMode,
1517
setFan1Speed as setBtFan1Speed,
1618
setPower as setBtPower,
1719
setPowerLevel as setBtPowerLevel,
@@ -151,17 +153,19 @@ export function useDeviceControl(
151153
if (!bleDeviceId || !isConnected) return;
152154

153155
try {
154-
const [power, temp, level, fan] = await Promise.all([
156+
const [power, temp, level, fan, auto] = await Promise.all([
155157
readPowerState(bleDeviceId),
156158
readTemperature(bleDeviceId),
157159
readPowerLevel(bleDeviceId),
158160
readFan1Speed(bleDeviceId),
161+
readAutoMode(bleDeviceId),
159162
]);
160163

161164
setPowerState(power);
162165
setTemperature(temp);
163166
setPowerLevelState(level);
164167
setFan1SpeedState(fan);
168+
setIsAutoState(auto);
165169
setLastUpdated(new Date());
166170
setLoading(false);
167171
} catch (error) {
@@ -304,7 +308,11 @@ export function useDeviceControl(
304308
const previousAuto = isAuto;
305309
setIsAutoState(enabled);
306310
try {
307-
await withRetry(token!, (t) => setAuto(t, mac!, enabled));
311+
if (connectionMode === "ble" && bleDeviceId && isConnected) {
312+
await setBtAutoMode(bleDeviceId, enabled);
313+
} else {
314+
await withRetry(token!, (t) => setAuto(t, mac!, enabled));
315+
}
308316
} catch (error) {
309317
console.error(error);
310318
addError({

src/test/__mocks__/capacitor-bluetooth-le.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { vi } from "vitest";
22

3+
// Store the notification callback so tests can trigger responses
4+
let notificationCallback: ((value: DataView) => void) | null = null;
5+
36
export const BleClient = {
47
initialize: vi.fn().mockResolvedValue(undefined),
58
requestDevice: vi.fn().mockResolvedValue({
@@ -14,5 +17,37 @@ export const BleClient = {
1417
disconnect: vi.fn().mockResolvedValue(undefined),
1518
getServices: vi.fn().mockResolvedValue([]),
1619
read: vi.fn().mockResolvedValue(new DataView(new ArrayBuffer(0))),
17-
write: vi.fn().mockResolvedValue(undefined),
20+
write: vi.fn().mockImplementation(async () => {
21+
// Simulate device response after write
22+
if (notificationCallback) {
23+
// Return a success response (non-error, with data)
24+
const response = new Uint8Array([0x01, 0x03, 0x02, 0x00, 0x01]);
25+
notificationCallback(new DataView(response.buffer));
26+
}
27+
}),
28+
startNotifications: vi
29+
.fn()
30+
.mockImplementation(
31+
async (
32+
_deviceId: string,
33+
_serviceUUID: string,
34+
_charUUID: string,
35+
callback: (value: DataView) => void,
36+
) => {
37+
notificationCallback = callback;
38+
},
39+
),
40+
stopNotifications: vi.fn().mockResolvedValue(undefined),
41+
};
42+
43+
// Helper to trigger a custom response in tests
44+
export const triggerNotification = (data: Uint8Array) => {
45+
if (notificationCallback) {
46+
notificationCallback(new DataView(data.buffer));
47+
}
48+
};
49+
50+
// Helper to reset the notification callback
51+
export const resetNotificationCallback = () => {
52+
notificationCallback = null;
1853
};

src/test/__mocks__/edilkamin-bluetooth.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,93 @@ export interface DiscoveredDevice {
1313
name: string;
1414
rssi?: number;
1515
}
16+
17+
// BLE protocol layer exports
18+
export const SERVICE_UUID = "0000abf0-0000-1000-8000-00805f9b34fb";
19+
export const NOTIFY_CHARACTERISTIC_UUID =
20+
"0000abf2-0000-1000-8000-00805f9b34fb";
21+
export const WRITE_CHARACTERISTIC_UUID = "0000abf1-0000-1000-8000-00805f9b34fb";
22+
23+
export const readCommands = {
24+
power: new Uint8Array([0x01, 0x03, 0x00, 0x00, 0x00, 0x01]),
25+
temperature: new Uint8Array([0x01, 0x03, 0x00, 0x01, 0x00, 0x01]),
26+
powerLevel: new Uint8Array([0x01, 0x03, 0x00, 0x02, 0x00, 0x01]),
27+
fan1Speed: new Uint8Array([0x01, 0x03, 0x00, 0x03, 0x00, 0x01]),
28+
autoMode: new Uint8Array([0x01, 0x03, 0x00, 0x10, 0x00, 0x01]),
29+
};
30+
31+
export const writeCommands = {
32+
setPower: (on: boolean) =>
33+
new Uint8Array([0x01, 0x06, 0x00, 0x00, 0x00, on ? 0x01 : 0x00]),
34+
setTemperature: (temp: number) =>
35+
new Uint8Array([0x01, 0x06, 0x00, 0x01, 0x00, temp * 2]),
36+
setPowerLevel: (level: number) =>
37+
new Uint8Array([0x01, 0x06, 0x00, 0x02, 0x00, level]),
38+
setFan1Speed: (speed: number) =>
39+
new Uint8Array([0x01, 0x06, 0x00, 0x03, 0x00, speed]),
40+
setAutoMode: (enabled: boolean) =>
41+
new Uint8Array([0x01, 0x06, 0x00, 0x10, 0x00, enabled ? 0x01 : 0x00]),
42+
};
43+
44+
export interface ModbusResponse {
45+
isError: boolean;
46+
data: Uint8Array;
47+
}
48+
49+
export const parsers = {
50+
// Boolean values are typically in the last byte of a 2-byte register value
51+
boolean: (response: ModbusResponse) => {
52+
// If 2 bytes (register value), check last byte; otherwise check first
53+
if (response.data.length >= 2) {
54+
return response.data[1] === 1;
55+
}
56+
return response.data[0] === 1;
57+
},
58+
number: (response: ModbusResponse) => {
59+
// If 2 bytes, combine as 16-bit value; otherwise return single byte
60+
if (response.data.length >= 2) {
61+
return (response.data[0] << 8) | response.data[1];
62+
}
63+
return response.data[0];
64+
},
65+
temperature: (response: ModbusResponse) => {
66+
// Temperature is stored as degrees * 2
67+
if (response.data.length >= 2) {
68+
return ((response.data[0] << 8) | response.data[1]) / 2;
69+
}
70+
return response.data[0] / 2;
71+
},
72+
};
73+
74+
export const createPacket = vi.fn(async (cmd: Uint8Array) => cmd);
75+
export const parseResponse = vi.fn((data: Uint8Array): ModbusResponse => {
76+
// Check if function code has error bit set (0x80)
77+
// Modbus error responses have function code with high bit set
78+
const functionCode = data[1];
79+
const isError = (functionCode & 0x80) !== 0;
80+
81+
if (isError) {
82+
// Error response: data[2] is the error code
83+
return {
84+
isError: true,
85+
data: new Uint8Array([data[2]]),
86+
};
87+
}
88+
89+
// Normal response: for read responses, data starts at byte 3
90+
// For function code 0x03 (read holding registers): [addr, func, byteCount, data...]
91+
if (functionCode === 0x03) {
92+
const byteCount = data[2];
93+
// Extract data bytes (typically 2 bytes for single register)
94+
return {
95+
isError: false,
96+
data: data.slice(3, 3 + byteCount),
97+
};
98+
}
99+
100+
// For function code 0x06 (write single register): echo response
101+
return {
102+
isError: false,
103+
data: new Uint8Array([]),
104+
};
105+
});

src/test/setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ vi.mock("../utils/bluetooth", () => ({
3434
readTemperature: vi.fn(),
3535
readPowerLevel: vi.fn(),
3636
readFan1Speed: vi.fn(),
37+
readAutoMode: vi.fn(),
3738
setPower: vi.fn(),
3839
setTemperature: vi.fn(),
3940
setPowerLevel: vi.fn(),
4041
setFan1Speed: vi.fn(),
42+
setAutoMode: vi.fn(),
4143
isBluetoothEnabled: vi.fn().mockResolvedValue(true),
4244
requestEnableBluetooth: vi.fn(),
4345
// scanForDevices returns empty array by default; tests can spy and override

src/utils/bluetooth.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Capacitor } from "@capacitor/core";
2-
import { BleClient } from "@capacitor-community/bluetooth-le";
2+
import {
3+
BleClient,
4+
// @ts-expect-error - Mock-only exports not in type definitions
5+
resetNotificationCallback,
6+
// @ts-expect-error - Mock-only exports not in type definitions
7+
triggerNotification,
8+
} from "@capacitor-community/bluetooth-le";
39
import { beforeEach, describe, expect, it, vi } from "vitest";
410

511
// Unmock the bluetooth module (overrides the global mock in setup.ts)
@@ -8,10 +14,13 @@ vi.unmock("@/utils/bluetooth");
814
vi.unmock("../utils/bluetooth");
915

1016
import {
17+
connectToDevice,
1118
isBluetoothEnabled,
1219
isBluetoothSupported,
20+
readAutoMode,
1321
requestEnableBluetooth,
1422
scanForDevices,
23+
setAutoMode,
1524
} from "./bluetooth";
1625

1726
// Mock Capacitor
@@ -121,4 +130,87 @@ describe("bluetooth utility", () => {
121130
expect(BleClient.requestEnable).toHaveBeenCalled();
122131
});
123132
});
133+
134+
describe("readAutoMode", () => {
135+
beforeEach(async () => {
136+
vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true);
137+
resetNotificationCallback();
138+
// Connect first to set up notification handler
139+
await connectToDevice("test-device-id");
140+
});
141+
142+
it("returns true when device is in auto mode", async () => {
143+
// Mock BleClient.write to return auto mode enabled response
144+
vi.mocked(BleClient.write).mockImplementationOnce(async () => {
145+
// Response format: [slaveAddr, funcCode, byteCount, dataHigh, dataLow]
146+
// For boolean true: data[0] = 1
147+
const response = new Uint8Array([0x01, 0x03, 0x02, 0x00, 0x01]);
148+
triggerNotification(response);
149+
});
150+
151+
const result = await readAutoMode("test-device-id");
152+
153+
expect(BleClient.write).toHaveBeenCalled();
154+
expect(result).toBe(true);
155+
});
156+
157+
it("returns false when device is in manual mode", async () => {
158+
// Mock BleClient.write to return auto mode disabled response
159+
vi.mocked(BleClient.write).mockImplementationOnce(async () => {
160+
// For boolean false: data[0] = 0
161+
const response = new Uint8Array([0x01, 0x03, 0x02, 0x00, 0x00]);
162+
triggerNotification(response);
163+
});
164+
165+
const result = await readAutoMode("test-device-id");
166+
167+
expect(result).toBe(false);
168+
});
169+
});
170+
171+
describe("setAutoMode", () => {
172+
beforeEach(async () => {
173+
vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true);
174+
resetNotificationCallback();
175+
// Connect first to set up notification handler
176+
await connectToDevice("test-device-id");
177+
});
178+
179+
it("sends correct command to enable auto mode", async () => {
180+
// Mock successful write response
181+
vi.mocked(BleClient.write).mockImplementationOnce(async () => {
182+
const response = new Uint8Array([0x01, 0x06, 0x00, 0x10, 0x00, 0x01]);
183+
triggerNotification(response);
184+
});
185+
186+
await setAutoMode("test-device-id", true);
187+
188+
expect(BleClient.write).toHaveBeenCalled();
189+
});
190+
191+
it("sends correct command to disable auto mode", async () => {
192+
// Mock successful write response
193+
vi.mocked(BleClient.write).mockImplementationOnce(async () => {
194+
const response = new Uint8Array([0x01, 0x06, 0x00, 0x10, 0x00, 0x00]);
195+
triggerNotification(response);
196+
});
197+
198+
await setAutoMode("test-device-id", false);
199+
200+
expect(BleClient.write).toHaveBeenCalled();
201+
});
202+
203+
it("throws error when command fails", async () => {
204+
// Mock error response (isError = true when function code has high bit set)
205+
vi.mocked(BleClient.write).mockImplementationOnce(async () => {
206+
// Error response: function code 0x86 (0x06 | 0x80), error code 1
207+
const response = new Uint8Array([0x01, 0x86, 0x01]);
208+
triggerNotification(response);
209+
});
210+
211+
await expect(setAutoMode("test-device-id", true)).rejects.toThrow(
212+
"Failed to set auto mode: error code 1",
213+
);
214+
});
215+
});
124216
});

0 commit comments

Comments
 (0)