homebridge-slwf-01pro 0.3.0 → 0.3.2
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/CHANGELOG.md +60 -0
- package/config.schema.json +3 -3
- package/lib/DeviceAccessory.js +43 -11
- package/lib/esphome.js +41 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,66 @@ This package is a maintained fork of [`homebridge-esphome-ac`](https://github.co
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.3.2] — 2026-05-06
|
|
11
|
+
|
|
12
|
+
Config-schema polish + a regression-prevention test suite for the Homebridge UI form.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`config.schema.json` `name` default was stale** — `"ESPHomeAC"` left over from before the 0.2.0 platform rename to `SLWFOnePro`. New users adding the plugin via the Homebridge UI got a confusing log prefix that didn't match the platform identifier. Now defaults to `"SLWFOnePro"`.
|
|
17
|
+
- **`headerDisplay` and `footerDisplay`** rewritten to mention the SLWF brand explicitly and link to the maintainer + upstream.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`test/unit/configSchema.test.js`** — 12 new tests (100 total now, up from 87) that automatically catch any future drift between the schema and the plugin code:
|
|
22
|
+
- Schema parses as valid JSON
|
|
23
|
+
- `pluginAlias` matches `PLATFORM_NAME` in `index.js` (won't silently rebrand the UI alias without rebranding the actual plugin)
|
|
24
|
+
- Every property in `schema.properties` is read by `index.js` (no dead schema fields the UI exposes but the plugin ignores)
|
|
25
|
+
- Per-device disable flags match platform-level disable flags 1:1 (so toggling a flag in the UI maps to a real code path)
|
|
26
|
+
- Layout references resolve to actual property keys (no typo'd field references in the form definition)
|
|
27
|
+
- `discoveryTimeout` condition references `autoDiscover` (so the field correctly hides when auto-discovery is off)
|
|
28
|
+
- All boolean disable flags default to `false`
|
|
29
|
+
- `name` default matches the platform identifier
|
|
30
|
+
- `discoveryTimeout` and `port` numeric constraints are sane
|
|
31
|
+
|
|
32
|
+
### Why this matters
|
|
33
|
+
|
|
34
|
+
The Homebridge UI form is rendered directly from `config.schema.json` by `homebridge-config-ui-x`. A typo, stale default, or unreferenced field shows up as a confused user not as a runtime error — the plugin still works, but the user's clicks don't end up where they think. The new tests fail CI if the schema drifts from the code, eliminating that whole class of silent UI bugs.
|
|
35
|
+
|
|
36
|
+
### Verifying the UI is writing your config correctly
|
|
37
|
+
|
|
38
|
+
After saving in the Homebridge UI, run:
|
|
39
|
+
```bash
|
|
40
|
+
sudo cat /var/lib/homebridge/config.json | jq '.platforms[] | select(.platform == "SLWFOnePro")'
|
|
41
|
+
```
|
|
42
|
+
You should see the JSON block matching what you toggled in the UI. If you toggled `disableHumiditySensor` ON for one device, expect `"disableHumiditySensor": true` under that device's block. If a UI toggle doesn't appear in this output, the UI didn't persist it.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## [0.3.1] — 2026-05-06
|
|
47
|
+
|
|
48
|
+
Hotfix for "Out of compliance" error when adding the bridge to Apple Home.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **Apple Home flagged the child bridge as "Out of compliance" during pairing.** Root cause: `DeviceAccessory` was constructed immediately on the ESPHome client's `'initialized'` event, but ESPHome state messages may not have arrived yet at that moment. Required HomeKit characteristics like `CurrentTemperature`, `CoolingThresholdTemperature`, and `HeatingThresholdTemperature` got `.updateValue(undefined)` calls. When Apple Home read the accessory tree during pairing, those characteristics returned `null` instead of valid floats — Apple's HAP validator rejects this as malformed metadata and refuses to pair.
|
|
53
|
+
|
|
54
|
+
Three layers of defense applied:
|
|
55
|
+
1. **Wait for first state event** — `lib/esphome.js` now defers `DeviceAccessory` construction until the climate entity has emitted at least one `'state'` event (5 s timeout fallback). For most devices this adds < 100 ms to startup; for slow-responding devices the warning logs and we proceed with safe defaults.
|
|
56
|
+
2. **Safe defaults at construction** — `addClimateService()` substitutes the visual-min/max-temperature midpoint for `CurrentTemperature` and `visualMinTemperature` for the threshold characteristics if state is still missing (defense in depth).
|
|
57
|
+
3. **`safeUpdate(characteristic, value)` helper** — wraps every characteristic update; skips the call entirely when value is `null` / `undefined` / `NaN` / `Infinity`.
|
|
58
|
+
|
|
59
|
+
- **`FirmwareRevision` validation.** HAP requires the format `\d+(\.\d+){0,2}` (e.g. `2024.7.3`). ESPHome's `esphomeVersion` sometimes carries suffixes like `-dev` or `-beta`, which would have produced an invalid characteristic value. New `sanitizeFirmwareRevision(raw)` helper strips anything past the SemVer prefix; if the prefix doesn't match, the characteristic is omitted (Apple Home falls back to the plugin version).
|
|
60
|
+
|
|
61
|
+
- **Primary service marked explicitly.** `addClimateService()` now calls `setPrimaryService(true)` on the `Service.HeaterCooler`. With companion DRY/FAN_ONLY/Beeper/Display Switch services on the same accessory, the absence of an explicit primary previously left the choice up to HAP-NodeJS — Apple Home iOS 16+ is stricter about this and could pick a Switch as primary, leading to a confusing accessory tile in Home.
|
|
62
|
+
|
|
63
|
+
### Internal
|
|
64
|
+
|
|
65
|
+
- New constants: `FIRST_STATE_TIMEOUT_MS = 5000` in `lib/esphome.js`.
|
|
66
|
+
- New helpers: `isPresent(value)`, `safeUpdate(characteristic, value)`, `sanitizeFirmwareRevision(raw)` in `lib/DeviceAccessory.js`.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
10
70
|
## [0.3.0] — 2026-05-06
|
|
11
71
|
|
|
12
72
|
Final piece of the "fully independent plugin" story: HomeKit UUIDs are now namespaced so this plugin can run **alongside** upstream `homebridge-esphome-ac` (or any other plugin that hashes from the same ESPHome `unique_id`) on the same Homebridge bridge without UUID collisions.
|
package/config.schema.json
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
"pluginAlias": "SLWFOnePro",
|
|
3
3
|
"pluginType": "platform",
|
|
4
4
|
"singular": true,
|
|
5
|
-
"headerDisplay": "Homebridge plugin for
|
|
6
|
-
"footerDisplay": "
|
|
5
|
+
"headerDisplay": "Homebridge plugin for the **SMLIGHT SLWF-01Pro** Wi-Fi dongle and other ESPHome `Climate` entities. Supports mDNS auto-discovery, multi-entity bundling (humidity / outdoor temperature / power sensors, Beeper / Display switches, DRY / FAN_ONLY mode tiles).",
|
|
6
|
+
"footerDisplay": "Maintained fork by [@nookied](https://github.com/nookied/homebridge-SLWF-01Pro). Based on `homebridge-esphome-ac` by [@nitaybz](https://github.com/nitaybz/homebridge-esphome-ac).",
|
|
7
7
|
"schema": {
|
|
8
8
|
"type": "object",
|
|
9
9
|
"properties": {
|
|
10
10
|
"name": {
|
|
11
11
|
"title": "Plugin Name (for logs)",
|
|
12
12
|
"type": "string",
|
|
13
|
-
"default": "
|
|
13
|
+
"default": "SLWFOnePro",
|
|
14
14
|
"required": false
|
|
15
15
|
},
|
|
16
16
|
"debug": {
|
package/lib/DeviceAccessory.js
CHANGED
|
@@ -43,6 +43,24 @@ function clampRange(value, min, max) {
|
|
|
43
43
|
return value;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function isPresent(value) {
|
|
47
|
+
if (value == null) return false;
|
|
48
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeUpdate(characteristic, value) {
|
|
53
|
+
if (!characteristic) return;
|
|
54
|
+
if (!isPresent(value)) return;
|
|
55
|
+
characteristic.updateValue(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sanitizeFirmwareRevision(raw) {
|
|
59
|
+
if (typeof raw !== 'string' || raw.length === 0) return undefined;
|
|
60
|
+
const match = raw.match(/^\d+(?:\.\d+){0,2}/);
|
|
61
|
+
return match ? match[0] : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
const SUPPLEMENTARY_SWITCH_KEYS = ['dryMode', 'fanOnlyMode', 'beeperSwitch', 'displaySwitch'];
|
|
47
65
|
const OPTIONAL_SENSOR_KEYS = ['humiditySensor', 'outdoorTempSensor', 'powerSensor'];
|
|
48
66
|
|
|
@@ -77,7 +95,7 @@ class DeviceAccessory {
|
|
|
77
95
|
this.serial = this.deviceInfo.macAddress || this.id;
|
|
78
96
|
this.manufacturer = this.deviceInfo.manufacturer || 'ESPHome';
|
|
79
97
|
this.model = this.deviceInfo.model || climate.name || 'ESPHome AC';
|
|
80
|
-
this.firmwareRevision = this.deviceInfo.esphomeVersion
|
|
98
|
+
this.firmwareRevision = sanitizeFirmwareRevision(this.deviceInfo.esphomeVersion);
|
|
81
99
|
|
|
82
100
|
this.state = climate.state || {};
|
|
83
101
|
this.connected = true;
|
|
@@ -160,6 +178,12 @@ class DeviceAccessory {
|
|
|
160
178
|
this.HeaterCoolerService = this.accessory.getService(Service.HeaterCooler)
|
|
161
179
|
|| this.accessory.addService(Service.HeaterCooler, this.name);
|
|
162
180
|
|
|
181
|
+
// Mark HeaterCooler as the accessory's primary service so Apple Home picks it
|
|
182
|
+
// for the main tile when companion Switch services (DRY/FAN_ONLY/Beeper/Display) are present.
|
|
183
|
+
if (typeof this.HeaterCoolerService.setPrimaryService === 'function') {
|
|
184
|
+
this.HeaterCoolerService.setPrimaryService(true);
|
|
185
|
+
}
|
|
186
|
+
|
|
163
187
|
this.HeaterCoolerService.getCharacteristic(Characteristic.Active)
|
|
164
188
|
.onSet(stateManager.set.Active.bind(this))
|
|
165
189
|
.updateValue(this.isModeActive(this.state.mode) ? 1 : 0);
|
|
@@ -178,27 +202,36 @@ class DeviceAccessory {
|
|
|
178
202
|
.onSet(stateManager.set.TargetHeaterCoolerState.bind(this))
|
|
179
203
|
.updateValue(espModeToHkTargetState(this.accessory.context.lastTargetState));
|
|
180
204
|
|
|
205
|
+
// CurrentTemperature MUST be a valid number for HAP (Apple Home flags accessories with
|
|
206
|
+
// undefined CurrentTemperature as "Out of compliance" during pairing). Default to a
|
|
207
|
+
// safe midpoint if state hasn't arrived yet.
|
|
208
|
+
const safeCurrent = isPresent(this.state.currentTemperature)
|
|
209
|
+
? this.state.currentTemperature
|
|
210
|
+
: (this.config.visualMinTemperature + this.config.visualMaxTemperature) / 2;
|
|
181
211
|
this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature)
|
|
182
212
|
.setProps({ minValue: -100, maxValue: 100, minStep: 0.1 })
|
|
183
|
-
.updateValue(
|
|
213
|
+
.updateValue(safeCurrent);
|
|
184
214
|
|
|
185
215
|
const tempProps = {
|
|
186
216
|
minValue: this.config.visualMinTemperature,
|
|
187
217
|
maxValue: this.config.visualMaxTemperature,
|
|
188
218
|
minStep: this.config.visualTargetTemperatureStep,
|
|
189
219
|
};
|
|
220
|
+
const safeTarget = isPresent(this.state.targetTemperature)
|
|
221
|
+
? this.clampTargetTemperature(this.state.targetTemperature)
|
|
222
|
+
: this.config.visualMinTemperature;
|
|
190
223
|
|
|
191
224
|
if (this.supportsCooling()) {
|
|
192
225
|
this.HeaterCoolerService.getCharacteristic(Characteristic.CoolingThresholdTemperature)
|
|
193
226
|
.setProps(tempProps)
|
|
194
227
|
.onSet(stateManager.set.CoolingThresholdTemperature.bind(this))
|
|
195
|
-
.updateValue(
|
|
228
|
+
.updateValue(safeTarget);
|
|
196
229
|
}
|
|
197
230
|
if (this.supportsHeating()) {
|
|
198
231
|
this.HeaterCoolerService.getCharacteristic(Characteristic.HeatingThresholdTemperature)
|
|
199
232
|
.setProps(tempProps)
|
|
200
233
|
.onSet(stateManager.set.HeatingThresholdTemperature.bind(this))
|
|
201
|
-
.updateValue(
|
|
234
|
+
.updateValue(safeTarget);
|
|
202
235
|
}
|
|
203
236
|
|
|
204
237
|
if (this.swingModeValue) {
|
|
@@ -207,9 +240,10 @@ class DeviceAccessory {
|
|
|
207
240
|
.updateValue(this.state.swingMode ? 1 : 0);
|
|
208
241
|
}
|
|
209
242
|
if (this.supportsFanControl()) {
|
|
243
|
+
const safeSpeed = fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList);
|
|
210
244
|
this.HeaterCoolerService.getCharacteristic(Characteristic.RotationSpeed)
|
|
211
245
|
.onSet(stateManager.set.RotationSpeed.bind(this))
|
|
212
|
-
.updateValue(
|
|
246
|
+
.updateValue(isPresent(safeSpeed) ? safeSpeed : 0);
|
|
213
247
|
}
|
|
214
248
|
|
|
215
249
|
this.HeaterCoolerService.getCharacteristic(Characteristic.StatusActive).updateValue(true);
|
|
@@ -435,8 +469,7 @@ class DeviceAccessory {
|
|
|
435
469
|
this.state = state;
|
|
436
470
|
this.syncModeSwitches(this.state.mode);
|
|
437
471
|
|
|
438
|
-
this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature)
|
|
439
|
-
.updateValue(this.state.currentTemperature);
|
|
472
|
+
safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature), this.state.currentTemperature);
|
|
440
473
|
|
|
441
474
|
if (this.state.mode === ESP_MODE.OFF) {
|
|
442
475
|
this.HeaterCoolerService.getCharacteristic(Characteristic.Active).updateValue(0);
|
|
@@ -450,18 +483,17 @@ class DeviceAccessory {
|
|
|
450
483
|
|
|
451
484
|
const clampedTarget = this.clampTargetTemperature(this.state.targetTemperature);
|
|
452
485
|
if (this.supportsHeating()) {
|
|
453
|
-
this.HeaterCoolerService.getCharacteristic(Characteristic.HeatingThresholdTemperature)
|
|
486
|
+
safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.HeatingThresholdTemperature), clampedTarget);
|
|
454
487
|
}
|
|
455
488
|
if (this.supportsCooling()) {
|
|
456
|
-
this.HeaterCoolerService.getCharacteristic(Characteristic.CoolingThresholdTemperature)
|
|
489
|
+
safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CoolingThresholdTemperature), clampedTarget);
|
|
457
490
|
}
|
|
458
491
|
|
|
459
492
|
if (this.swingModeValue) {
|
|
460
493
|
this.HeaterCoolerService.getCharacteristic(Characteristic.SwingMode).updateValue(this.state.swingMode ? 1 : 0);
|
|
461
494
|
}
|
|
462
495
|
if (this.supportsFanControl()) {
|
|
463
|
-
this.HeaterCoolerService.getCharacteristic(Characteristic.RotationSpeed)
|
|
464
|
-
.updateValue(fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList));
|
|
496
|
+
safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.RotationSpeed), fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList));
|
|
465
497
|
}
|
|
466
498
|
|
|
467
499
|
this.HeaterCoolerService.getCharacteristic(Characteristic.TargetHeaterCoolerState)
|
package/lib/esphome.js
CHANGED
|
@@ -7,6 +7,7 @@ const { bundleEntities } = require('./classifyEntity');
|
|
|
7
7
|
|
|
8
8
|
const RECONNECT_INTERVAL_MS = 5000;
|
|
9
9
|
const DEFAULT_DISCOVERY_TIMEOUT_S = 5;
|
|
10
|
+
const FIRST_STATE_TIMEOUT_MS = 5000;
|
|
10
11
|
|
|
11
12
|
const UPSTREAM_PLUGIN_NAME = 'homebridge-esphome-ac';
|
|
12
13
|
const LEGACY_PLATFORM_NAME = 'ESPHomeAC';
|
|
@@ -179,19 +180,47 @@ function spawnClient(platform, device) {
|
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
const displayName = device.name || (deviceInfo && deviceInfo.name) || device.host;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
183
|
+
|
|
184
|
+
const create = () => {
|
|
185
|
+
try {
|
|
186
|
+
const accessory = new DeviceAccessory({
|
|
187
|
+
device: { ...device, name: displayName },
|
|
188
|
+
deviceInfo,
|
|
189
|
+
entities: bundle,
|
|
190
|
+
platform,
|
|
191
|
+
});
|
|
192
|
+
platform.esphomeDevices[device.host] = accessory;
|
|
193
|
+
const count = Object.keys(bundle).length;
|
|
194
|
+
platform.log(`Initialized "${displayName}" with ${count} mapped entit${count === 1 ? 'y' : 'ies'}`);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
platform.log.error(`Failed to initialize "${displayName}": ${err.message || err}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Wait for the climate's first state event before creating the accessory.
|
|
201
|
+
// HAP characteristic values must be defined when Apple Home pairs (otherwise → "out of compliance").
|
|
202
|
+
// 5s timeout fallback: if state never arrives, create with whatever defaults the device gives us.
|
|
203
|
+
if (bundle.climate.state && bundle.climate.state.mode != null) {
|
|
204
|
+
create();
|
|
205
|
+
return;
|
|
194
206
|
}
|
|
207
|
+
|
|
208
|
+
platform.log.easyDebug(`${displayName}: waiting for first climate state event before creating accessory…`);
|
|
209
|
+
let created = false;
|
|
210
|
+
const onState = () => {
|
|
211
|
+
if (created) return;
|
|
212
|
+
created = true;
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
create();
|
|
215
|
+
};
|
|
216
|
+
bundle.climate.once('state', onState);
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
if (created) return;
|
|
219
|
+
created = true;
|
|
220
|
+
bundle.climate.removeListener('state', onState);
|
|
221
|
+
platform.log.warn(`${displayName}: no climate state received within ${FIRST_STATE_TIMEOUT_MS / 1000}s — creating accessory with default values.`);
|
|
222
|
+
create();
|
|
223
|
+
}, FIRST_STATE_TIMEOUT_MS);
|
|
195
224
|
});
|
|
196
225
|
|
|
197
226
|
client.connect();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-slwf-01pro",
|
|
3
3
|
"description": "Homebridge plugin for the SMLIGHT SLWF-01Pro Wi-Fi dongle and other ESPHome Climate entities. Auto-discovery, multi-entity bundling, and full Midea-protocol AC support.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/nookied/homebridge-SLWF-01Pro.git"
|