homebridge-slwf-01pro 0.2.0 → 0.3.1

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,75 @@ This package is a maintained fork of [`homebridge-esphome-ac`](https://github.co
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.3.1] — 2026-05-06
11
+
12
+ Hotfix for "Out of compliance" error when adding the bridge to Apple Home.
13
+
14
+ ### Fixed
15
+
16
+ - **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.
17
+
18
+ Three layers of defense applied:
19
+ 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.
20
+ 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).
21
+ 3. **`safeUpdate(characteristic, value)` helper** — wraps every characteristic update; skips the call entirely when value is `null` / `undefined` / `NaN` / `Infinity`.
22
+
23
+ - **`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).
24
+
25
+ - **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.
26
+
27
+ ### Internal
28
+
29
+ - New constants: `FIRST_STATE_TIMEOUT_MS = 5000` in `lib/esphome.js`.
30
+ - New helpers: `isPresent(value)`, `safeUpdate(characteristic, value)`, `sanitizeFirmwareRevision(raw)` in `lib/DeviceAccessory.js`.
31
+
32
+ ---
33
+
34
+ ## [0.3.0] — 2026-05-06
35
+
36
+ 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.
37
+
38
+ ### ⚠️ Breaking — accessories will re-pair
39
+
40
+ The UUID input is now `homebridge-slwf-01pro:<deviceId>` instead of bare `<deviceId>`. The same device gets a new UUID under this plugin → Apple Home treats it as a new accessory. **You'll lose room assignments, scenes, and automations** wired to the old accessory tiles. They need to be re-set up in Apple Home after the upgrade.
41
+
42
+ The plugin **automatically evicts** old-schema cached accessories on startup (no manual cleanup required) and registers fresh ones with the new UUIDs.
43
+
44
+ ### Why
45
+
46
+ Before 0.3.0, both upstream and this fork derived HomeKit UUIDs from `entity.config.uniqueId` (or its fallback). For a user with both plugins running in the **same** Homebridge bridge (not child-bridged) and both auto-discovering the same physical AC:
47
+
48
+ - Upstream registers UUID `abc-123` for "Air Conditioner"
49
+ - Our fork tries to register UUID `abc-123` too
50
+ - HomeKit rejects the second one (UUID collision within a bridge)
51
+
52
+ Child bridges (which the user is using) insulate against this — each child bridge is its own HomeKit instance — so the bug is theoretical for child-bridge users. But for users who run plugins on the main Homebridge bridge, the fix is mandatory. And it costs nothing to apply universally.
53
+
54
+ After 0.3.0, the two plugins produce **deterministically different UUIDs** for the same device. Both can be installed on the same Homebridge with full auto-discovery enabled and no interaction.
55
+
56
+ ### Added
57
+
58
+ - **`accessory.context.schemaVersion`** — stamped on every accessory created or restored. Currently `2` (1 was the unstamped pre-0.3.0 schema). Future schema breakages bump this number.
59
+ - **`evictStaleSchemaAccessories(platform)`** in `lib/esphome.js` — runs first thing in `init()`. Walks `platform.staleAccessories` (populated by `configureAccessory` for entries with the wrong schema version) and unregisters them in one batch via `api.unregisterPlatformAccessories`. Clean cache transition with no manual user action.
60
+ - **`UUID_NAMESPACE = 'homebridge-slwf-01pro'`** constant in `lib/DeviceAccessory.js`. Prefixed onto the device id before hashing.
61
+
62
+ ### Changed
63
+
64
+ - **HomeKit UUID input format**: `homebridge-slwf-01pro:<deviceId>` (was bare `<deviceId>`). For devices with `unique_id` set in YAML, the old UUID was the same as upstream's; now ours is distinct. For devices without `unique_id` (the SLWF-01Pro/Midea default), the old UUID was already MAC-based via `deriveDeviceId` (added in 0.1.2); the prefix makes it distinct from upstream regardless.
65
+ - **`configureAccessory` in `index.js`** now diverts schema-mismatched cached entries to `this.staleAccessories` and emits a warning. The init flow evicts them before doing anything else.
66
+
67
+ ### Migration
68
+
69
+ For anyone on `homebridge-slwf-01pro@0.2.x` or earlier:
70
+ 1. `sudo npm install -g homebridge-slwf-01pro@latest`
71
+ 2. Restart Homebridge.
72
+ 3. The log shows `Evicting N cached accessor… from an older plugin schema. They will be re-registered fresh with stable UUIDs.` Old accessories disappear from Apple Home; new ones appear with the same names but no automations attached.
73
+ 4. Re-add the new accessories to your Apple Home rooms / scenes / automations.
74
+
75
+ For anyone migrating from upstream: same as 0.2.0 (config.json edit `"ESPHomeAC"` → `"SLWFOnePro"`), plus the same UUID-driven re-pairing.
76
+
77
+ ---
78
+
10
79
  ## [0.2.0] — 2026-05-06
11
80
 
12
81
  Clean separation from upstream `homebridge-esphome-ac`. Breaking config change (one-line edit) but eliminates all namespace collision risk.
package/index.js CHANGED
@@ -2,6 +2,7 @@ const ESPHome = require('./lib/esphome');
2
2
 
3
3
  const PLUGIN_NAME = 'homebridge-slwf-01pro';
4
4
  const PLATFORM_NAME = 'SLWFOnePro';
5
+ const ACCESSORY_SCHEMA_VERSION = 2;
5
6
 
6
7
  class ESPHomeAC {
7
8
  constructor(log, config, api) {
@@ -9,9 +10,11 @@ class ESPHomeAC {
9
10
  this.log = log;
10
11
 
11
12
  this.accessories = [];
13
+ this.staleAccessories = [];
12
14
  this.esphomeDevices = {};
13
15
  this.PLUGIN_NAME = PLUGIN_NAME;
14
16
  this.PLATFORM_NAME = PLATFORM_NAME;
17
+ this.ACCESSORY_SCHEMA_VERSION = ACCESSORY_SCHEMA_VERSION;
15
18
  this.name = config.name || PLATFORM_NAME;
16
19
  this.devices = config.devices || [];
17
20
  this.debug = config.debug || false;
@@ -40,6 +43,12 @@ class ESPHomeAC {
40
43
  }
41
44
 
42
45
  configureAccessory(accessory) {
46
+ const cachedVersion = accessory.context && accessory.context.schemaVersion;
47
+ if (cachedVersion !== ACCESSORY_SCHEMA_VERSION) {
48
+ this.log.warn(`Cached accessory "${accessory.displayName}" is from an older plugin schema (v${cachedVersion || 1}); will be re-registered with the current schema (v${ACCESSORY_SCHEMA_VERSION}).`);
49
+ this.staleAccessories.push(accessory);
50
+ return;
51
+ }
43
52
  this.log.easyDebug(`Found cached accessory: ${accessory.displayName} (${accessory.context.deviceId || 'no id'})`);
44
53
  this.accessories.push(accessory);
45
54
  }
@@ -26,6 +26,9 @@ let Characteristic;
26
26
  const SET_DEBOUNCE_MS = 600;
27
27
  const PRIMARY_MODES = [ESP_MODE.COOL, ESP_MODE.HEAT, ESP_MODE.AUTO, ESP_MODE.HEAT_COOL];
28
28
 
29
+ const UUID_NAMESPACE = 'homebridge-slwf-01pro';
30
+ const ACCESSORY_SCHEMA_VERSION = 2;
31
+
29
32
  function readSensorValue(entity) {
30
33
  if (!entity || !entity.state) return null;
31
34
  if (entity.state.missingState) return null;
@@ -40,6 +43,24 @@ function clampRange(value, min, max) {
40
43
  return value;
41
44
  }
42
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
+
43
64
  const SUPPLEMENTARY_SWITCH_KEYS = ['dryMode', 'fanOnlyMode', 'beeperSwitch', 'displaySwitch'];
44
65
  const OPTIONAL_SENSOR_KEYS = ['humiditySensor', 'outdoorTempSensor', 'powerSensor'];
45
66
 
@@ -74,7 +95,7 @@ class DeviceAccessory {
74
95
  this.serial = this.deviceInfo.macAddress || this.id;
75
96
  this.manufacturer = this.deviceInfo.manufacturer || 'ESPHome';
76
97
  this.model = this.deviceInfo.model || climate.name || 'ESPHome AC';
77
- this.firmwareRevision = this.deviceInfo.esphomeVersion || undefined;
98
+ this.firmwareRevision = sanitizeFirmwareRevision(this.deviceInfo.esphomeVersion);
78
99
 
79
100
  this.state = climate.state || {};
80
101
  this.connected = true;
@@ -84,12 +105,13 @@ class DeviceAccessory {
84
105
 
85
106
  this.swingModeValue = pickDefaultSwingValue(this.config.supportedSwingModesList);
86
107
 
87
- this.UUID = this.api.hap.uuid.generate(this.id);
108
+ this.UUID = this.api.hap.uuid.generate(`${UUID_NAMESPACE}:${this.id}`);
88
109
  this.accessory = platform.accessories.find(acc => acc.UUID === this.UUID);
89
110
 
90
111
  if (!this.accessory) {
91
112
  this.log(`Creating new ESPHome AC accessory: "${this.name}"`);
92
113
  this.accessory = new this.api.platformAccessory(this.name, this.UUID);
114
+ this.accessory.context.schemaVersion = ACCESSORY_SCHEMA_VERSION;
93
115
  this.accessory.context.deviceId = this.id;
94
116
  this.accessory.context.host = this.host;
95
117
  this.accessory.context.lastTargetState = chooseInitialTargetMode(this.state.mode);
@@ -97,6 +119,7 @@ class DeviceAccessory {
97
119
  this.api.registerPlatformAccessories(platform.PLUGIN_NAME, platform.PLATFORM_NAME, [this.accessory]);
98
120
  } else {
99
121
  this.log(`ESPHome device "${this.name}" reconnected.`);
122
+ this.accessory.context.schemaVersion = ACCESSORY_SCHEMA_VERSION;
100
123
  this.accessory.context.host = this.host;
101
124
  if (PRIMARY_MODES.includes(this.state.mode)) {
102
125
  this.accessory.context.lastTargetState = this.state.mode;
@@ -155,6 +178,12 @@ class DeviceAccessory {
155
178
  this.HeaterCoolerService = this.accessory.getService(Service.HeaterCooler)
156
179
  || this.accessory.addService(Service.HeaterCooler, this.name);
157
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
+
158
187
  this.HeaterCoolerService.getCharacteristic(Characteristic.Active)
159
188
  .onSet(stateManager.set.Active.bind(this))
160
189
  .updateValue(this.isModeActive(this.state.mode) ? 1 : 0);
@@ -173,27 +202,36 @@ class DeviceAccessory {
173
202
  .onSet(stateManager.set.TargetHeaterCoolerState.bind(this))
174
203
  .updateValue(espModeToHkTargetState(this.accessory.context.lastTargetState));
175
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;
176
211
  this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature)
177
212
  .setProps({ minValue: -100, maxValue: 100, minStep: 0.1 })
178
- .updateValue(this.state.currentTemperature);
213
+ .updateValue(safeCurrent);
179
214
 
180
215
  const tempProps = {
181
216
  minValue: this.config.visualMinTemperature,
182
217
  maxValue: this.config.visualMaxTemperature,
183
218
  minStep: this.config.visualTargetTemperatureStep,
184
219
  };
220
+ const safeTarget = isPresent(this.state.targetTemperature)
221
+ ? this.clampTargetTemperature(this.state.targetTemperature)
222
+ : this.config.visualMinTemperature;
185
223
 
186
224
  if (this.supportsCooling()) {
187
225
  this.HeaterCoolerService.getCharacteristic(Characteristic.CoolingThresholdTemperature)
188
226
  .setProps(tempProps)
189
227
  .onSet(stateManager.set.CoolingThresholdTemperature.bind(this))
190
- .updateValue(this.clampTargetTemperature(this.state.targetTemperature));
228
+ .updateValue(safeTarget);
191
229
  }
192
230
  if (this.supportsHeating()) {
193
231
  this.HeaterCoolerService.getCharacteristic(Characteristic.HeatingThresholdTemperature)
194
232
  .setProps(tempProps)
195
233
  .onSet(stateManager.set.HeatingThresholdTemperature.bind(this))
196
- .updateValue(this.clampTargetTemperature(this.state.targetTemperature));
234
+ .updateValue(safeTarget);
197
235
  }
198
236
 
199
237
  if (this.swingModeValue) {
@@ -202,9 +240,10 @@ class DeviceAccessory {
202
240
  .updateValue(this.state.swingMode ? 1 : 0);
203
241
  }
204
242
  if (this.supportsFanControl()) {
243
+ const safeSpeed = fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList);
205
244
  this.HeaterCoolerService.getCharacteristic(Characteristic.RotationSpeed)
206
245
  .onSet(stateManager.set.RotationSpeed.bind(this))
207
- .updateValue(fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList));
246
+ .updateValue(isPresent(safeSpeed) ? safeSpeed : 0);
208
247
  }
209
248
 
210
249
  this.HeaterCoolerService.getCharacteristic(Characteristic.StatusActive).updateValue(true);
@@ -430,8 +469,7 @@ class DeviceAccessory {
430
469
  this.state = state;
431
470
  this.syncModeSwitches(this.state.mode);
432
471
 
433
- this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature)
434
- .updateValue(this.state.currentTemperature);
472
+ safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature), this.state.currentTemperature);
435
473
 
436
474
  if (this.state.mode === ESP_MODE.OFF) {
437
475
  this.HeaterCoolerService.getCharacteristic(Characteristic.Active).updateValue(0);
@@ -445,18 +483,17 @@ class DeviceAccessory {
445
483
 
446
484
  const clampedTarget = this.clampTargetTemperature(this.state.targetTemperature);
447
485
  if (this.supportsHeating()) {
448
- this.HeaterCoolerService.getCharacteristic(Characteristic.HeatingThresholdTemperature).updateValue(clampedTarget);
486
+ safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.HeatingThresholdTemperature), clampedTarget);
449
487
  }
450
488
  if (this.supportsCooling()) {
451
- this.HeaterCoolerService.getCharacteristic(Characteristic.CoolingThresholdTemperature).updateValue(clampedTarget);
489
+ safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CoolingThresholdTemperature), clampedTarget);
452
490
  }
453
491
 
454
492
  if (this.swingModeValue) {
455
493
  this.HeaterCoolerService.getCharacteristic(Characteristic.SwingMode).updateValue(this.state.swingMode ? 1 : 0);
456
494
  }
457
495
  if (this.supportsFanControl()) {
458
- this.HeaterCoolerService.getCharacteristic(Characteristic.RotationSpeed)
459
- .updateValue(fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList));
496
+ safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.RotationSpeed), fanModeToSpeed(this.state.fanMode, this.config.supportedFanModesList));
460
497
  }
461
498
 
462
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';
@@ -60,10 +61,23 @@ function detectOrphanedAccessories(platform) {
60
61
  }
61
62
  }
62
63
 
64
+ function evictStaleSchemaAccessories(platform) {
65
+ if (!platform.staleAccessories || platform.staleAccessories.length === 0) return;
66
+ const stale = platform.staleAccessories.slice();
67
+ platform.staleAccessories = [];
68
+ platform.log(`Evicting ${stale.length} cached accessor${stale.length === 1 ? 'y' : 'ies'} from an older plugin schema. They will be re-registered fresh with stable UUIDs.`);
69
+ try {
70
+ platform.api.unregisterPlatformAccessories(platform.PLUGIN_NAME, platform.PLATFORM_NAME, stale);
71
+ } catch (err) {
72
+ platform.log.error(`Failed to evict stale-schema accessories: ${err.message || err}`);
73
+ }
74
+ }
75
+
63
76
  async function init() {
64
77
  const platform = this;
65
- detectOrphanedAccessories(platform);
66
78
  platform._clients = [];
79
+ evictStaleSchemaAccessories(platform);
80
+ detectOrphanedAccessories(platform);
67
81
 
68
82
  const manualDevices = (platform.devices || []).map(d => ({
69
83
  ...d,
@@ -166,19 +180,47 @@ function spawnClient(platform, device) {
166
180
  }
167
181
 
168
182
  const displayName = device.name || (deviceInfo && deviceInfo.name) || device.host;
169
- try {
170
- const accessory = new DeviceAccessory({
171
- device: { ...device, name: displayName },
172
- deviceInfo,
173
- entities: bundle,
174
- platform,
175
- });
176
- platform.esphomeDevices[device.host] = accessory;
177
- const count = Object.keys(bundle).length;
178
- platform.log(`Initialized "${displayName}" with ${count} mapped entit${count === 1 ? 'y' : 'ies'}`);
179
- } catch (err) {
180
- 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;
181
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);
182
224
  });
183
225
 
184
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.2.0",
4
+ "version": "0.3.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/nookied/homebridge-SLWF-01Pro.git"