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/README.md +115 -0
- package/config.schema.json +39 -0
- package/dist/accessory.d.ts +15 -0
- package/dist/accessory.js +136 -0
- package/dist/accessory.js.map +1 -0
- package/dist/bedjet/BedJet.d.ts +42 -0
- package/dist/bedjet/BedJet.js +330 -0
- package/dist/bedjet/BedJet.js.map +1 -0
- package/dist/bedjet/constants.d.ts +63 -0
- package/dist/bedjet/constants.js +84 -0
- package/dist/bedjet/constants.js.map +1 -0
- package/dist/bedjet/types.d.ts +27 -0
- package/dist/bedjet/types.js +19 -0
- package/dist/bedjet/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +62 -0
- package/dist/platform.js.map +1 -0
- package/package.json +26 -0
- package/src/accessory.ts +195 -0
- package/src/bedjet/BedJet.ts +400 -0
- package/src/bedjet/constants.ts +83 -0
- package/src/bedjet/types.ts +44 -0
- package/src/index.ts +6 -0
- package/src/platform.ts +71 -0
- package/tsconfig.json +17 -0
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
|
+
}
|
package/src/accessory.ts
ADDED
|
@@ -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
|
+
}
|