homebridge-bedjet 0.1.0

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/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "homebridge-bedjet",
3
+ "displayName": "BedJet",
4
+ "version": "0.1.0",
5
+ "description": "Homebridge plugin for BedJet V3 via Bluetooth LE",
6
+ "license": "MIT",
7
+ "main": "dist/index.js",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "watch": "tsc --watch",
11
+ "prepublishOnly": "npm run build"
12
+ },
13
+ "keywords": ["homebridge-plugin", "bedjet", "homekit", "bluetooth"],
14
+ "engines": {
15
+ "homebridge": ">=1.8.0",
16
+ "node": ">=18.0.0"
17
+ },
18
+ "dependencies": {
19
+ "@abandonware/noble": "^1.9.2-15"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.0.0",
23
+ "homebridge": "^2.0.0",
24
+ "typescript": "^5.0.0"
25
+ }
26
+ }
@@ -0,0 +1,195 @@
1
+ import type { CharacteristicValue, PlatformAccessory, Service } from 'homebridge';
2
+ import { BedJet } from './bedjet/BedJet';
3
+ import { OperatingMode } from './bedjet/constants';
4
+ import type { BedJetConfig, BedJetState } from './bedjet/types';
5
+ import type { BedJetPlatform } from './platform';
6
+
7
+ // OperatingMode → CurrentHeatingCoolingState value
8
+ const CURRENT_STATE_MAP: Record<OperatingMode, number> = {
9
+ [OperatingMode.STANDBY]: 0, // OFF
10
+ [OperatingMode.HEAT]: 1, // HEAT
11
+ [OperatingMode.TURBO]: 1, // HEAT
12
+ [OperatingMode.EXTENDED_HEAT]: 1, // HEAT
13
+ [OperatingMode.COOL]: 2, // COOL
14
+ [OperatingMode.DRY]: 2, // COOL
15
+ [OperatingMode.WAIT]: 0, // OFF
16
+ };
17
+
18
+ // TargetHeatingCoolingState value → OperatingMode
19
+ const TARGET_TO_MODE: Record<number, OperatingMode> = {
20
+ 0: OperatingMode.STANDBY,
21
+ 1: OperatingMode.HEAT,
22
+ 2: OperatingMode.COOL,
23
+ 3: OperatingMode.HEAT, // AUTO — fall back to heat
24
+ };
25
+
26
+ export class BedJetAccessory {
27
+ private readonly thermostatService: Service;
28
+ private readonly fanService: Service;
29
+ private readonly bedjet: BedJet;
30
+
31
+ // Debounce handles for setter commands
32
+ private tempDebounce: NodeJS.Timeout | null = null;
33
+ private fanDebounce: NodeJS.Timeout | null = null;
34
+
35
+ constructor(
36
+ private readonly platform: BedJetPlatform,
37
+ private readonly accessory: PlatformAccessory,
38
+ private readonly config: BedJetConfig,
39
+ ) {
40
+ const { Service, Characteristic } = platform.api.hap;
41
+
42
+ // AccessoryInformation
43
+ const infoService = this.accessory.getService(Service.AccessoryInformation)
44
+ ?? this.accessory.addService(Service.AccessoryInformation);
45
+ infoService
46
+ .setCharacteristic(Characteristic.Manufacturer, 'BedJet')
47
+ .setCharacteristic(Characteristic.Model, 'BedJet 3')
48
+ .setCharacteristic(Characteristic.SerialNumber, config.address);
49
+
50
+ // Thermostat service
51
+ this.thermostatService = this.accessory.getService(Service.Thermostat)
52
+ ?? this.accessory.addService(Service.Thermostat, config.name, 'thermostat');
53
+
54
+ this.thermostatService.getCharacteristic(Characteristic.TemperatureDisplayUnits)
55
+ .onGet(() => Characteristic.TemperatureDisplayUnits.CELSIUS);
56
+
57
+ this.thermostatService.getCharacteristic(Characteristic.CurrentTemperature)
58
+ .onGet(() => this.bedjet.state.currentTemperature);
59
+
60
+ this.thermostatService.getCharacteristic(Characteristic.TargetTemperature)
61
+ .setProps({ minValue: 19, maxValue: 43, minStep: 0.5 })
62
+ .onGet(() => this.bedjet.state.targetTemperature)
63
+ .onSet((value: CharacteristicValue) => {
64
+ if (this.tempDebounce) {
65
+ clearTimeout(this.tempDebounce);
66
+ }
67
+ this.tempDebounce = setTimeout(() => {
68
+ this.bedjet.setTemperature(value as number).catch(err =>
69
+ this.platform.log.error(`[${config.name}] setTemperature failed: ${err}`),
70
+ );
71
+ }, 100);
72
+ });
73
+
74
+ this.thermostatService.getCharacteristic(Characteristic.CurrentHeatingCoolingState)
75
+ .onGet(() => CURRENT_STATE_MAP[this.bedjet.state.operatingMode] ?? 0);
76
+
77
+ this.thermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState)
78
+ .onGet(() => {
79
+ const mode = this.bedjet.state.operatingMode;
80
+ if (mode === OperatingMode.STANDBY || mode === OperatingMode.WAIT) return 0;
81
+ if (mode === OperatingMode.COOL || mode === OperatingMode.DRY) return 2;
82
+ return 1; // HEAT / TURBO / EXTENDED_HEAT
83
+ })
84
+ .onSet((value: CharacteristicValue) => {
85
+ const mode = TARGET_TO_MODE[value as number] ?? OperatingMode.STANDBY;
86
+ this.bedjet.setOperatingMode(mode).catch(err =>
87
+ this.platform.log.error(`[${config.name}] setOperatingMode failed: ${err}`),
88
+ );
89
+ });
90
+
91
+ // FanV2 service
92
+ this.fanService = this.accessory.getService(Service.Fanv2)
93
+ ?? this.accessory.addService(Service.Fanv2, `${config.name} Fan`, 'fan');
94
+
95
+ this.fanService.getCharacteristic(Characteristic.Active)
96
+ .onGet(() =>
97
+ this.bedjet.state.operatingMode !== OperatingMode.STANDBY
98
+ ? Characteristic.Active.ACTIVE
99
+ : Characteristic.Active.INACTIVE,
100
+ )
101
+ .onSet((value: CharacteristicValue) => {
102
+ if (value === Characteristic.Active.INACTIVE) {
103
+ this.bedjet.setOperatingMode(OperatingMode.STANDBY).catch(err =>
104
+ this.platform.log.error(`[${config.name}] setOperatingMode(STANDBY) failed: ${err}`),
105
+ );
106
+ } else {
107
+ // Only turn on if currently off
108
+ if (this.bedjet.state.operatingMode === OperatingMode.STANDBY) {
109
+ this.bedjet.setOperatingMode(OperatingMode.HEAT).catch(err =>
110
+ this.platform.log.error(`[${config.name}] setOperatingMode(HEAT) failed: ${err}`),
111
+ );
112
+ }
113
+ }
114
+ });
115
+
116
+ this.fanService.getCharacteristic(Characteristic.RotationSpeed)
117
+ .setProps({ minValue: 5, maxValue: 100, minStep: 5 })
118
+ .onGet(() => this.bedjet.state.fanSpeed)
119
+ .onSet((value: CharacteristicValue) => {
120
+ if (this.fanDebounce) {
121
+ clearTimeout(this.fanDebounce);
122
+ }
123
+ this.fanDebounce = setTimeout(() => {
124
+ this.bedjet.setFanSpeed(value as number).catch(err =>
125
+ this.platform.log.error(`[${config.name}] setFanSpeed failed: ${err}`),
126
+ );
127
+ }, 100);
128
+ });
129
+
130
+ // Create BLE client and wire up state change events
131
+ this.bedjet = new BedJet(config, platform.log);
132
+
133
+ this.bedjet.on('stateChange', (state: BedJetState) => this._syncHomeKit(state));
134
+ this.bedjet.on('connected', () => {
135
+ // Update FirmwareRevision once we have it
136
+ const fw = this.bedjet.firmware;
137
+ if (fw) {
138
+ infoService.setCharacteristic(Characteristic.FirmwareRevision, fw);
139
+ }
140
+ });
141
+
142
+ // Start connecting — errors are logged but don't crash Homebridge
143
+ this.bedjet.connect().catch(err =>
144
+ this.platform.log.error(`[${config.name}] Initial connect failed: ${err}`),
145
+ );
146
+ }
147
+
148
+ private _syncHomeKit(state: BedJetState): void {
149
+ const { Characteristic } = this.platform.api.hap;
150
+
151
+ // Update TargetTemperature bounds from live packet values
152
+ this.thermostatService
153
+ .getCharacteristic(Characteristic.TargetTemperature)
154
+ .setProps({ minValue: state.minimumTemperature, maxValue: state.maximumTemperature });
155
+
156
+ this.thermostatService.updateCharacteristic(
157
+ Characteristic.CurrentTemperature,
158
+ state.currentTemperature,
159
+ );
160
+
161
+ this.thermostatService.updateCharacteristic(
162
+ Characteristic.TargetTemperature,
163
+ state.targetTemperature,
164
+ );
165
+
166
+ this.thermostatService.updateCharacteristic(
167
+ Characteristic.CurrentHeatingCoolingState,
168
+ CURRENT_STATE_MAP[state.operatingMode] ?? 0,
169
+ );
170
+
171
+ const targetState =
172
+ state.operatingMode === OperatingMode.STANDBY || state.operatingMode === OperatingMode.WAIT
173
+ ? 0
174
+ : state.operatingMode === OperatingMode.COOL || state.operatingMode === OperatingMode.DRY
175
+ ? 2
176
+ : 1;
177
+
178
+ this.thermostatService.updateCharacteristic(
179
+ Characteristic.TargetHeatingCoolingState,
180
+ targetState,
181
+ );
182
+
183
+ this.fanService.updateCharacteristic(
184
+ Characteristic.Active,
185
+ state.operatingMode !== OperatingMode.STANDBY
186
+ ? Characteristic.Active.ACTIVE
187
+ : Characteristic.Active.INACTIVE,
188
+ );
189
+
190
+ this.fanService.updateCharacteristic(
191
+ Characteristic.RotationSpeed,
192
+ state.fanSpeed,
193
+ );
194
+ }
195
+ }
@@ -0,0 +1,400 @@
1
+ import { EventEmitter } from 'events';
2
+ import type { Peripheral, Characteristic } from '@abandonware/noble';
3
+ import type { Logger } from 'homebridge';
4
+
5
+ // noble is a singleton process-level instance; require() gives us the live object.
6
+ // The default ESM export doesn't expose `.state` in its type — we patch it here.
7
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
8
+ const noble = require('@abandonware/noble') as typeof import('@abandonware/noble') & { state: string };
9
+ import {
10
+ BEDJET3_SERVICE_UUID,
11
+ BEDJET3_STATUS_UUID,
12
+ BEDJET3_NAME_UUID,
13
+ BEDJET3_COMMAND_UUID,
14
+ BEDJET3_NOTIFICATION_LENGTH,
15
+ BEDJET3_STATUS_LENGTH,
16
+ DISCONNECT_DELAY_MS,
17
+ MAX_RECONNECT_ATTEMPTS,
18
+ OperatingMode,
19
+ BedJetCommand,
20
+ BedJetButton,
21
+ OPERATING_MODE_BUTTON_MAP,
22
+ } from './constants';
23
+ import type { BedJetConfig, BedJetState } from './types';
24
+ import { DEFAULT_STATE } from './types';
25
+
26
+ // noble strips hyphens from UUIDs internally
27
+ const normalize = (uuid: string) => uuid.replace(/-/g, '').toLowerCase();
28
+
29
+ const NORM_STATUS_UUID = normalize(BEDJET3_STATUS_UUID);
30
+ const NORM_NAME_UUID = normalize(BEDJET3_NAME_UUID);
31
+ const NORM_COMMAND_UUID = normalize(BEDJET3_COMMAND_UUID);
32
+ const NORM_SERVICE_UUID = normalize(BEDJET3_SERVICE_UUID);
33
+
34
+ export class BedJet extends EventEmitter {
35
+ private peripheral: Peripheral | null = null;
36
+ private commandChar: Characteristic | null = null;
37
+ private statusChar: Characteristic | null = null;
38
+ private disconnectTimer: NodeJS.Timeout | null = null;
39
+ private staleTimer: NodeJS.Timeout | null = null;
40
+ private reconnectAttempts = 0;
41
+ private connecting = false;
42
+ private _state: BedJetState = { ...DEFAULT_STATE };
43
+ private deviceName: string | null = null;
44
+ private firmwareVersion: string | null = null;
45
+
46
+ constructor(
47
+ private readonly config: BedJetConfig,
48
+ private readonly log: Logger,
49
+ ) {
50
+ super();
51
+ }
52
+
53
+ get state(): BedJetState {
54
+ return this._state;
55
+ }
56
+
57
+ get name(): string {
58
+ return this.deviceName ?? this.config.name;
59
+ }
60
+
61
+ get firmware(): string | null {
62
+ return this.firmwareVersion;
63
+ }
64
+
65
+ // ── Connection ────────────────────────────────────────────────────────────
66
+
67
+ async connect(): Promise<void> {
68
+ if (this.connecting) {
69
+ return;
70
+ }
71
+ this.connecting = true;
72
+
73
+ try {
74
+ await this._scan();
75
+ } catch (err) {
76
+ this.connecting = false;
77
+ throw err;
78
+ }
79
+
80
+ this.connecting = false;
81
+ }
82
+
83
+ private _scan(): Promise<void> {
84
+ return new Promise((resolve, reject) => {
85
+ const targetAddress = this.config.address.toLowerCase().replace(/:/g, '');
86
+ const timeoutMs = (this.config.scanTimeout ?? 30) * 1000;
87
+
88
+ const scanTimeout = setTimeout(() => {
89
+ noble.stopScanning();
90
+ noble.removeListener('discover', onDiscover);
91
+ reject(new Error(`[${this.config.name}] Scan timed out after ${this.config.scanTimeout ?? 30}s`));
92
+ }, timeoutMs);
93
+
94
+ const onDiscover = async (peripheral: Peripheral) => {
95
+ const addr = peripheral.address.toLowerCase().replace(/:/g, '');
96
+ if (addr !== targetAddress) {
97
+ return;
98
+ }
99
+
100
+ clearTimeout(scanTimeout);
101
+ noble.stopScanning();
102
+ noble.removeListener('discover', onDiscover);
103
+
104
+ try {
105
+ await this._connectPeripheral(peripheral);
106
+ resolve();
107
+ } catch (err) {
108
+ reject(err);
109
+ }
110
+ };
111
+
112
+ noble.on('discover', onDiscover);
113
+
114
+ const startScan = () => {
115
+ // Pass full UUID with hyphens — noble handles normalisation for scanning
116
+ noble.startScanning([BEDJET3_SERVICE_UUID], false, (err) => {
117
+ if (err) {
118
+ clearTimeout(scanTimeout);
119
+ noble.removeListener('discover', onDiscover);
120
+ reject(new Error(`[${this.config.name}] startScanning failed: ${err}`));
121
+ }
122
+ });
123
+ };
124
+
125
+ if (noble.state === 'poweredOn') {
126
+ startScan();
127
+ } else {
128
+ noble.once('stateChange', (state) => {
129
+ if (state === 'poweredOn') {
130
+ startScan();
131
+ } else {
132
+ clearTimeout(scanTimeout);
133
+ noble.removeListener('discover', onDiscover);
134
+ reject(new Error(`[${this.config.name}] Bluetooth not available (state: ${state})`));
135
+ }
136
+ });
137
+ }
138
+ });
139
+ }
140
+
141
+ private async _connectPeripheral(peripheral: Peripheral): Promise<void> {
142
+ this.peripheral = peripheral;
143
+ this.log.info(`[${this.config.name}] Connecting to ${peripheral.address}…`);
144
+
145
+ peripheral.once('disconnect', () => this._onDisconnected());
146
+
147
+ await peripheral.connectAsync();
148
+ this.log.debug(`[${this.config.name}] Connected — discovering services…`);
149
+
150
+ const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
151
+ [NORM_SERVICE_UUID],
152
+ [NORM_STATUS_UUID, NORM_NAME_UUID, NORM_COMMAND_UUID],
153
+ );
154
+
155
+ for (const char of characteristics) {
156
+ const uuid = normalize(char.uuid);
157
+ if (uuid === NORM_STATUS_UUID) {
158
+ this.statusChar = char;
159
+ } else if (uuid === NORM_COMMAND_UUID) {
160
+ this.commandChar = char;
161
+ }
162
+ }
163
+
164
+ if (!this.commandChar || !this.statusChar) {
165
+ throw new Error(`[${this.config.name}] Required characteristics not found`);
166
+ }
167
+
168
+ // Subscribe to notifications on the status characteristic
169
+ await this.statusChar.subscribeAsync();
170
+ this.statusChar.on('data', (data: Buffer) => this._handleNotification(data));
171
+
172
+ // Read extended status and device name
173
+ await this._readDeviceStatus();
174
+ await this._readDeviceName();
175
+
176
+ this.reconnectAttempts = 0;
177
+ this._state = { ...this._state, isConnected: true };
178
+ this._resetDisconnectTimer();
179
+ this._resetStaleTimer();
180
+
181
+ this.emit('connected');
182
+ this.emit('stateChange', this._state);
183
+
184
+ this.log.info(`[${this.config.name}] Ready (firmware: ${this.firmwareVersion ?? 'unknown'})`);
185
+ }
186
+
187
+ // ── Packet parsing ────────────────────────────────────────────────────────
188
+
189
+ private _handleNotification(data: Buffer): void {
190
+ if (data.length !== BEDJET3_NOTIFICATION_LENGTH) {
191
+ return;
192
+ }
193
+
194
+ this._state = {
195
+ ...this._state,
196
+ hoursRemaining: data[4],
197
+ minutesRemaining: data[5],
198
+ secondsRemaining: data[6],
199
+ currentTemperature: data[7] / 2,
200
+ targetTemperature: data[8] / 2,
201
+ operatingMode: data[9] as OperatingMode,
202
+ fanSpeed: (data[10] + 1) * 5,
203
+ // data[11] = maximum_hours, data[12] = maximum_minutes (not stored in state)
204
+ minimumTemperature: data[13] / 2,
205
+ maximumTemperature: data[14] / 2,
206
+ // bytes [15–16] = turbo_time uint16 big-endian
207
+ turboTimeSeconds: (data[15] << 8) | data[16],
208
+ ambientTemperature: data[17] / 2,
209
+ // data[18] = shutdown_reason (not stored)
210
+ isConnected: true,
211
+ };
212
+
213
+ this._resetStaleTimer();
214
+ this.emit('stateChange', this._state);
215
+ }
216
+
217
+ private _handleStatusRead(data: Buffer): void {
218
+ if (data.length !== BEDJET3_STATUS_LENGTH) {
219
+ return;
220
+ }
221
+
222
+ const flags = data[7];
223
+ this._state = {
224
+ ...this._state,
225
+ isDualZone: (data[2] & 0x02) !== 0,
226
+ // flags bits MSB→LSB: [_, _, connTestPassed, ledEnabled, _, unitsSetup, _, beepsMuted]
227
+ connTestPassed: (flags & 0x20) !== 0,
228
+ ledEnabled: (flags & 0x10) !== 0,
229
+ unitsSetup: (flags & 0x04) !== 0,
230
+ beepsMuted: (flags & 0x01) !== 0,
231
+ notificationCode: data[9],
232
+ };
233
+ }
234
+
235
+ private async _readDeviceName(): Promise<void> {
236
+ if (!this.peripheral) {
237
+ return;
238
+ }
239
+ try {
240
+ // Rediscover to get the name characteristic
241
+ const { characteristics } = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync(
242
+ [NORM_SERVICE_UUID],
243
+ [NORM_NAME_UUID],
244
+ );
245
+ const nameChar = characteristics.find(c => normalize(c.uuid) === NORM_NAME_UUID);
246
+ if (nameChar) {
247
+ const data = await nameChar.readAsync();
248
+ this.deviceName = data.toString('utf8').replace(/\0/g, '').trim();
249
+ this.log.debug(`[${this.config.name}] Device name: ${this.deviceName}`);
250
+ }
251
+ } catch (err) {
252
+ this.log.warn(`[${this.config.name}] Could not read device name: ${err}`);
253
+ }
254
+ }
255
+
256
+ private async _readDeviceStatus(): Promise<void> {
257
+ if (!this.statusChar) {
258
+ return;
259
+ }
260
+ try {
261
+ const data = await this.statusChar.readAsync();
262
+ this._handleStatusRead(data);
263
+ } catch (err) {
264
+ this.log.warn(`[${this.config.name}] Could not read device status: ${err}`);
265
+ }
266
+ }
267
+
268
+ // ── Commands ──────────────────────────────────────────────────────────────
269
+
270
+ private async _sendCommand(command: BedJetCommand, ...args: number[]): Promise<void> {
271
+ if (!this.commandChar) {
272
+ throw new Error(`[${this.config.name}] Not connected`);
273
+ }
274
+ const buf = Buffer.from([command, ...args]);
275
+ try {
276
+ // withoutResponse = false (write with response) — required for V3
277
+ await this.commandChar.writeAsync(buf, false);
278
+ this._resetDisconnectTimer();
279
+ } catch (err) {
280
+ this.log.error(`[${this.config.name}] Command 0x${command.toString(16)} failed: ${err}`);
281
+ throw err;
282
+ }
283
+ }
284
+
285
+ async setTemperature(celsius: number): Promise<void> {
286
+ await this._sendCommand(BedJetCommand.SET_TEMPERATURE, Math.round(celsius * 2));
287
+ }
288
+
289
+ async setFanSpeed(percent: number): Promise<void> {
290
+ const step = Math.max(0, Math.min(19, Math.round(percent / 5) - 1));
291
+ await this._sendCommand(BedJetCommand.SET_FAN, step);
292
+ }
293
+
294
+ async setOperatingMode(mode: OperatingMode): Promise<void> {
295
+ await this._sendCommand(BedJetCommand.BUTTON, OPERATING_MODE_BUTTON_MAP[mode]);
296
+ }
297
+
298
+ async pressButton(button: BedJetButton): Promise<void> {
299
+ await this._sendCommand(BedJetCommand.BUTTON, button);
300
+ }
301
+
302
+ async setClock(hour: number, minute: number): Promise<void> {
303
+ await this._sendCommand(BedJetCommand.SET_CLOCK, hour, minute);
304
+ }
305
+
306
+ async setRuntimeRemaining(hours: number, minutes: number): Promise<void> {
307
+ await this._sendCommand(BedJetCommand.SET_RUNTIME, hours, minutes);
308
+ }
309
+
310
+ async setLed(on: boolean): Promise<void> {
311
+ await this.pressButton(on ? BedJetButton.LED_ON : BedJetButton.LED_OFF);
312
+ }
313
+
314
+ async setMuted(muted: boolean): Promise<void> {
315
+ await this.pressButton(muted ? BedJetButton.MUTE : BedJetButton.UNMUTE);
316
+ }
317
+
318
+ // ── Timers ────────────────────────────────────────────────────────────────
319
+
320
+ private _resetDisconnectTimer(): void {
321
+ if (this.disconnectTimer) {
322
+ clearTimeout(this.disconnectTimer);
323
+ }
324
+ this.disconnectTimer = setTimeout(async () => {
325
+ this.log.debug(`[${this.config.name}] Inactivity timeout — disconnecting`);
326
+ await this.disconnect();
327
+ }, DISCONNECT_DELAY_MS);
328
+ }
329
+
330
+ // Mark stale and update HomeKit if no notification arrives within 65s
331
+ private _resetStaleTimer(): void {
332
+ if (this.staleTimer) {
333
+ clearTimeout(this.staleTimer);
334
+ }
335
+ this.staleTimer = setTimeout(() => {
336
+ this.log.warn(`[${this.config.name}] No notification received — marking disconnected`);
337
+ this._state = { ...this._state, isConnected: false };
338
+ this.emit('stateChange', this._state);
339
+ }, 65_000);
340
+ }
341
+
342
+ // ── Disconnect / reconnect ────────────────────────────────────────────────
343
+
344
+ private _onDisconnected(): void {
345
+ this.commandChar = null;
346
+ this.statusChar = null;
347
+
348
+ if (this.disconnectTimer) {
349
+ clearTimeout(this.disconnectTimer);
350
+ this.disconnectTimer = null;
351
+ }
352
+ if (this.staleTimer) {
353
+ clearTimeout(this.staleTimer);
354
+ this.staleTimer = null;
355
+ }
356
+
357
+ this._state = { ...this._state, isConnected: false };
358
+ this.emit('disconnected');
359
+ this.emit('stateChange', this._state);
360
+
361
+ if (this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
362
+ // Exponential backoff: 2s, 4s, 8s, 16s, 32s
363
+ const delay = Math.pow(2, this.reconnectAttempts + 1) * 1000;
364
+ this.reconnectAttempts++;
365
+ this.log.info(
366
+ `[${this.config.name}] Disconnected — reconnecting in ${delay / 1000}s ` +
367
+ `(attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`,
368
+ );
369
+ setTimeout(() => {
370
+ this.connect().catch(err =>
371
+ this.log.error(`[${this.config.name}] Reconnect failed: ${err}`),
372
+ );
373
+ }, delay);
374
+ } else {
375
+ this.log.error(`[${this.config.name}] Max reconnect attempts reached — giving up`);
376
+ this.emit('maxReconnectReached');
377
+ }
378
+ }
379
+
380
+ async disconnect(): Promise<void> {
381
+ if (this.disconnectTimer) {
382
+ clearTimeout(this.disconnectTimer);
383
+ this.disconnectTimer = null;
384
+ }
385
+ if (this.staleTimer) {
386
+ clearTimeout(this.staleTimer);
387
+ this.staleTimer = null;
388
+ }
389
+ // Setting max attempts prevents the disconnect handler from triggering a reconnect
390
+ this.reconnectAttempts = MAX_RECONNECT_ATTEMPTS;
391
+ if (this.peripheral) {
392
+ try {
393
+ await this.peripheral.disconnectAsync();
394
+ } catch {
395
+ // ignore errors during intentional disconnect
396
+ }
397
+ this.peripheral = null;
398
+ }
399
+ }
400
+ }
@@ -0,0 +1,83 @@
1
+ // BedJet V3 BLE UUIDs (confirmed from ESPHome + pybedjet)
2
+ export const BEDJET3_SERVICE_UUID = '00001000-bed0-0080-aa55-4265644a6574';
3
+ export const BEDJET3_STATUS_UUID = '00002000-bed0-0080-aa55-4265644a6574';
4
+ export const BEDJET3_NAME_UUID = '00002001-bed0-0080-aa55-4265644a6574';
5
+ export const BEDJET3_SSID_UUID = '00002002-bed0-0080-aa55-4265644a6574';
6
+ export const BEDJET3_PASSWORD_UUID = '00002003-bed0-0080-aa55-4265644a6574';
7
+ export const BEDJET3_COMMAND_UUID = '00002004-bed0-0080-aa55-4265644a6574';
8
+ export const BEDJET3_BIODATA_UUID = '00002005-bed0-0080-aa55-4265644a6574';
9
+ export const BEDJET3_BIODATA_FULL_UUID = '00002006-bed0-0080-aa55-4265644a6574';
10
+
11
+ // Discard notification packets that are not exactly this length
12
+ export const BEDJET3_NOTIFICATION_LENGTH = 20;
13
+ // Discard direct status reads that are not exactly this length
14
+ export const BEDJET3_STATUS_LENGTH = 11;
15
+
16
+ export const DISCONNECT_DELAY_MS = 60_000;
17
+ export const MAX_RECONNECT_ATTEMPTS = 5;
18
+
19
+ // byte [9] of notification packet
20
+ export enum OperatingMode {
21
+ STANDBY = 0,
22
+ HEAT = 1,
23
+ TURBO = 2,
24
+ EXTENDED_HEAT = 3,
25
+ COOL = 4, // fan only — no compressor
26
+ DRY = 5,
27
+ WAIT = 6, // pause step in a biorhythm program
28
+ }
29
+
30
+ // Write to BEDJET3_COMMAND_UUID as Buffer.from([command_byte, ...args])
31
+ export enum BedJetCommand {
32
+ BUTTON = 0x01,
33
+ SET_RUNTIME = 0x02,
34
+ SET_TEMPERATURE = 0x03,
35
+ SET_STEP = 0x04,
36
+ SET_HACKS = 0x05,
37
+ STATUS = 0x06,
38
+ SET_FAN = 0x07,
39
+ SET_CLOCK = 0x08,
40
+ SET_BIO = 0x40,
41
+ GET_BIO = 0x41,
42
+ }
43
+
44
+ export enum BedJetButton {
45
+ OFF = 0x01,
46
+ COOL = 0x02,
47
+ HEAT = 0x03,
48
+ TURBO = 0x04,
49
+ DRY = 0x05,
50
+ EXTENDED_HEAT = 0x06,
51
+ M1 = 0x20,
52
+ M2 = 0x21,
53
+ M3 = 0x22,
54
+ DEBUG_ON = 0x40,
55
+ DEBUG_OFF = 0x41,
56
+ CONNECTION_TEST = 0x42,
57
+ UPDATE_FIRMWARE = 0x43,
58
+ LED_ON = 0x46,
59
+ LED_OFF = 0x47,
60
+ MUTE = 0x48,
61
+ UNMUTE = 0x49,
62
+ NOTIFY_ACK = 0x52,
63
+ BIORHYTHM_1 = 0x80,
64
+ BIORHYTHM_2 = 0x81,
65
+ BIORHYTHM_3 = 0x82,
66
+ }
67
+
68
+ export const OPERATING_MODE_BUTTON_MAP: Record<OperatingMode, BedJetButton> = {
69
+ [OperatingMode.STANDBY]: BedJetButton.OFF,
70
+ [OperatingMode.HEAT]: BedJetButton.HEAT,
71
+ [OperatingMode.TURBO]: BedJetButton.TURBO,
72
+ [OperatingMode.EXTENDED_HEAT]: BedJetButton.EXTENDED_HEAT,
73
+ [OperatingMode.COOL]: BedJetButton.COOL,
74
+ [OperatingMode.DRY]: BedJetButton.DRY,
75
+ [OperatingMode.WAIT]: BedJetButton.OFF,
76
+ };
77
+
78
+ export enum BioDataRequest {
79
+ DEVICE_NAME = 0,
80
+ MEMORY_NAMES = 1,
81
+ BIORHYTHM_NAMES = 4,
82
+ FIRMWARE_VERSIONS = 32,
83
+ }