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 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.
@@ -2,15 +2,15 @@
2
2
  "pluginAlias": "SLWFOnePro",
3
3
  "pluginType": "platform",
4
4
  "singular": true,
5
- "headerDisplay": "Homebridge plugin for ESPHome AC controllers (SLWF-01Pro Wi-Fi dongle and other ESPHome `Climate` entities). Supports auto-discovery, humidity / outdoor-temperature / power sensors, beeper / display switches, and DRY / FAN_ONLY mode tiles.",
6
- "footerDisplay": "Original by @nitaybz; SLWF-01Pro fork by @nookied.",
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": "ESPHomeAC",
13
+ "default": "SLWFOnePro",
14
14
  "required": false
15
15
  },
16
16
  "debug": {
@@ -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 || undefined;
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(this.state.currentTemperature);
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(this.clampTargetTemperature(this.state.targetTemperature));
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(this.clampTargetTemperature(this.state.targetTemperature));
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(fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList));
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).updateValue(clampedTarget);
486
+ safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.HeatingThresholdTemperature), clampedTarget);
454
487
  }
455
488
  if (this.supportsCooling()) {
456
- this.HeaterCoolerService.getCharacteristic(Characteristic.CoolingThresholdTemperature).updateValue(clampedTarget);
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
- try {
183
- const accessory = new DeviceAccessory({
184
- device: { ...device, name: displayName },
185
- deviceInfo,
186
- entities: bundle,
187
- platform,
188
- });
189
- platform.esphomeDevices[device.host] = accessory;
190
- const count = Object.keys(bundle).length;
191
- platform.log(`Initialized "${displayName}" with ${count} mapped entit${count === 1 ? 'y' : 'ies'}`);
192
- } catch (err) {
193
- platform.log.error(`Failed to initialize "${displayName}": ${err.message || err}`);
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.0",
4
+ "version": "0.3.2",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/nookied/homebridge-SLWF-01Pro.git"