homebridge-slwf-01pro 0.5.2 → 0.5.4

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,32 @@ This package is a maintained fork of [`homebridge-esphome-ac`](https://github.co
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.5.4] — 2026-05-13
11
+
12
+ ### Fixed
13
+
14
+ - **Fault indicator (⚠️) no longer persists after a Homebridge restart.** Apple Home subscribes to HAP events asynchronously after the bridge starts; when a device had a fault from the previous session, the startup `StatusFault = NO_FAULT` push arrived before Apple Home subscribed and was silently missed, leaving the ⚠️ visible until the user manually interacted with the tile. A 3-second delayed re-push (`STARTUP_FAULT_CLEAR_DELAY_MS`) after accessory construction ensures the cleared state reaches Apple Home once it has subscribed.
15
+ - **Fault indicator no longer flashes on transient standby disconnects.** ESPHome dongles that briefly lose their TCP connection when the AC unit enters standby would immediately set `StatusFault = GENERAL_FAULT`. A 15-second grace period (`DISCONNECT_FAULT_DELAY_MS`) now absorbs brief drops: if the device reconnects within the window, no fault is ever shown. Persistent disconnects (> 15 s) still correctly surface the fault. Both new timers call `.unref()` so they don't prevent clean process exit.
16
+
17
+ ---
18
+
19
+ ## [0.5.3] — 2026-05-06
20
+
21
+ ### Fixed
22
+
23
+ - **Heat-only / auto-only devices no longer restore an unsupported COOL mode when turned back on.** `chooseInitialTargetMode` now considers `supportedModesList`, and `Active` uses that helper for cached restore modes too.
24
+ - **External or stale HomeKit writes for unsupported HEAT/COOL target states are ignored** instead of sending unsupported ESPHome modes.
25
+ - **Newly-enabled companion services on cached accessories now get `ConfiguredName` seeded.** Existing Apple Home renames are still preserved, but services that did not exist in the cache yet no longer start with a blank ConfiguredName.
26
+ - **Hostless manual device entries are non-destructive.** Empty Homebridge UI form scaffolding is dropped before orchestration, and real but invalid manual entries now keep cached accessories instead of allowing orphan pruning while the config is incomplete.
27
+ - **Main current-temperature updates are clamped to the HAP-safe range** before writing to `CurrentTemperature`.
28
+ - **Linked services are not re-linked redundantly** when the service is already present in HAP-NodeJS's `linkedServices` list.
29
+
30
+ ### Internal
31
+
32
+ - Added regression coverage for capability-aware restore modes, cached-service `ConfiguredName` seeding, current-temperature clamping, manual-device filtering, and invalid-config prune protection. 159 tests total.
33
+
34
+ ---
35
+
10
36
  ## [0.5.2] — 2026-05-06
11
37
 
12
38
  ### Fixed
package/README.md CHANGED
@@ -15,7 +15,7 @@ The upstream plugin works for single-device setups but has a few sharp edges tha
15
15
  - **Multi-device command coalescing.** A module-level send timeout meant changing one AC could cancel a pending command on another. Each AC now owns its own debouncer.
16
16
  - **Crash on disconnected device.** The upstream `device disconnected` error path referenced an undefined `log` symbol → `ReferenceError` instead of a clean HomeKit error. Fixed.
17
17
  - **Auto-discovery via mDNS** — listed `_esphomelib._tcp` services are picked up automatically; you don't need to type six IP addresses for six ACs.
18
- - **Multi-entity bundling** — humidity, outdoor temperature, power consumption, beeper, display toggle, DRY, and FAN_ONLY all show up in HomeKit when the device exposes them.
18
+ - **Multi-entity bundling** — humidity, outdoor temperature, power consumption, beeper, display toggle, DRY, and FAN_ONLY can be exposed when the device supports them; fresh installs hide the extras by default for a clean Apple Home pairing.
19
19
 
20
20
  See [CHANGELOG.md](CHANGELOG.md) for the full restoration story.
21
21
 
@@ -30,7 +30,7 @@ Anything that publishes a `Climate` entity over the ESPHome native API will work
30
30
  | Alpine, Pioneer, Samsung, Toshiba, Zanussi | ~30 brands total — see [smartlight.me](https://smartlight.me/smart-home-devices/wifi-devices/wifi-dongle-air-conditioners-midea-idea-electrolux-for-home-assistant) for the full list |
31
31
  | Newer 2024+ AC firmwares with proprietary protocols | May not work — check the manufacturer's compatibility notes before buying |
32
32
 
33
- DRY and FAN_ONLY ESPHome modes are surfaced as **companion `Switch` services** ("AC Dry", "AC Fan Only") next to the main `HeaterCooler` tile. Toggling one ON puts the AC into that mode; toggling OFF restores the previous primary mode (HEAT/COOL/AUTO). Hide via `disableDryMode` / `disableFanOnlyMode`.
33
+ DRY and FAN_ONLY ESPHome modes can be surfaced as **companion `Switch` services** ("AC Dry", "AC Fan Only") next to the main `HeaterCooler` tile. Set `disableDryMode: false` / `disableFanOnlyMode: false` to opt in. Toggling one ON puts the AC into that mode; toggling OFF restores the previous primary mode (HEAT/COOL/AUTO).
34
34
 
35
35
  ESPHome's **custom fan modes** (`silent`, `turbo` on Midea) and **presets** (`eco`, `boost`, `sleep`, `away`) are not yet surfaced — the plugin only handles the standard fan-modes list. See [ROADMAP.md](ROADMAP.md).
36
36
 
@@ -291,7 +291,7 @@ git clone https://github.com/nookied/homebridge-SLWF-01Pro.git
291
291
  cd homebridge-SLWF-01Pro
292
292
  npm install
293
293
  npm run lint # ESLint
294
- npm test # Jest — 144 unit tests across 7 suites
294
+ npm test # Jest — 159 unit tests across 8 suites
295
295
  node -e "require('./index.js')" # smoke test (loads cleanly)
296
296
  ```
297
297
 
@@ -43,7 +43,7 @@
43
43
  "default": true
44
44
  },
45
45
  "disablePowerSensor": {
46
- "title": "Hide Power Usage characteristic (Eve.Energy)",
46
+ "title": "Hide linked Power Usage service (Eve.Energy)",
47
47
  "type": "boolean",
48
48
  "default": true
49
49
  },
@@ -26,6 +26,8 @@ let Characteristic;
26
26
  const { UUID_NAMESPACE, ACCESSORY_SCHEMA_VERSION } = require('./constants');
27
27
 
28
28
  const SET_DEBOUNCE_MS = 600;
29
+ const DISCONNECT_FAULT_DELAY_MS = 15000;
30
+ const STARTUP_FAULT_CLEAR_DELAY_MS = 3000;
29
31
  const PRIMARY_MODES = [ESP_MODE.COOL, ESP_MODE.HEAT, ESP_MODE.AUTO, ESP_MODE.HEAT_COOL];
30
32
 
31
33
  const DEFAULT_VISUAL_MIN_TEMP = 16;
@@ -119,7 +121,7 @@ class DeviceAccessory {
119
121
  this.accessory.context.schemaVersion = ACCESSORY_SCHEMA_VERSION;
120
122
  this.accessory.context.deviceId = this.id;
121
123
  this.accessory.context.host = this.host;
122
- this.accessory.context.lastTargetState = chooseInitialTargetMode(this.state.mode);
124
+ this.accessory.context.lastTargetState = chooseInitialTargetMode(this.state.mode, this.config.supportedModesList);
123
125
  platform.accessories.push(this.accessory);
124
126
  this.api.registerPlatformAccessories(platform.PLUGIN_NAME, platform.PLATFORM_NAME, [this.accessory]);
125
127
  } else {
@@ -128,7 +130,7 @@ class DeviceAccessory {
128
130
  this.accessory.context.schemaVersion = ACCESSORY_SCHEMA_VERSION;
129
131
  this.accessory.context.host = this.host;
130
132
  if (PRIMARY_MODES.includes(this.state.mode)) {
131
- this.accessory.context.lastTargetState = this.state.mode;
133
+ this.accessory.context.lastTargetState = chooseInitialTargetMode(this.state.mode, this.config.supportedModesList);
132
134
  }
133
135
  }
134
136
 
@@ -145,6 +147,19 @@ class DeviceAccessory {
145
147
 
146
148
  this.esphome.on('state', this.updateClimateState.bind(this));
147
149
  this.attachOptionalEntityListeners();
150
+
151
+ // Apple Home subscribes to HAP events asynchronously after startup.
152
+ // Re-push the cleared fault state after a short delay so it isn't
153
+ // missed if Apple Home hadn't subscribed when addClimateService ran.
154
+ setTimeout(() => {
155
+ if (this.connected !== false && this.HeaterCoolerService && Characteristic) {
156
+ try {
157
+ this.HeaterCoolerService.getCharacteristic(Characteristic.StatusActive).updateValue(true);
158
+ this.HeaterCoolerService.getCharacteristic(Characteristic.StatusFault)
159
+ .updateValue(Characteristic.StatusFault.NO_FAULT);
160
+ } catch (_e) { /* characteristic update on startup */ }
161
+ }
162
+ }, STARTUP_FAULT_CLEAR_DELAY_MS).unref();
148
163
  }
149
164
 
150
165
  linkOptionalServices() {
@@ -161,6 +176,7 @@ class DeviceAccessory {
161
176
  for (const svc of candidates) {
162
177
  if (!svc) continue;
163
178
  try {
179
+ if (Array.isArray(this.HeaterCoolerService.linkedServices) && this.HeaterCoolerService.linkedServices.includes(svc)) continue;
164
180
  this.HeaterCoolerService.addLinkedService(svc);
165
181
  } catch (err) {
166
182
  this.log.easyDebug(`linkOptionalServices: could not link ${svc.displayName}: ${err.message || err}`);
@@ -201,11 +217,10 @@ class DeviceAccessory {
201
217
  if (service.testCharacteristic && !service.testCharacteristic(Characteristic.ConfiguredName)) {
202
218
  service.addOptionalCharacteristic(Characteristic.ConfiguredName);
203
219
  }
204
- // Only seed ConfiguredName for newly-created accessories. For cached ones,
205
- // the user may have renamed the device in Apple Home — overwriting that on
206
- // every restart would clobber their choice. The HAP database keeps the
207
- // previous value across restarts, so the rename persists if we leave it alone.
208
- if (!this.isNewAccessory) return;
220
+ const configuredName = service.getCharacteristic(Characteristic.ConfiguredName);
221
+ // Preserve user-chosen names on cached services, but still seed ConfiguredName
222
+ // when a newly-enabled companion service didn't exist in the cache yet.
223
+ if (!this.isNewAccessory && typeof configuredName.value === 'string' && configuredName.value.length > 0) return;
209
224
  service.setCharacteristic(Characteristic.ConfiguredName, name);
210
225
  } catch (err) {
211
226
  this.log.easyDebug(`setConfiguredName(${name}) failed: ${err.message || err}`);
@@ -270,7 +285,7 @@ class DeviceAccessory {
270
285
  const visualStep = isPresent(this.config.visualTargetTemperatureStep) ? this.config.visualTargetTemperatureStep : DEFAULT_VISUAL_TEMP_STEP;
271
286
 
272
287
  const safeCurrent = isPresent(this.state.currentTemperature)
273
- ? this.state.currentTemperature
288
+ ? clampRange(this.state.currentTemperature, -100, 100)
274
289
  : (visualMin + visualMax) / 2;
275
290
  this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature)
276
291
  .setProps({ minValue: -100, maxValue: 100, minStep: 0.1 })
@@ -322,12 +337,31 @@ class DeviceAccessory {
322
337
  setConnectedStatus(connected) {
323
338
  this.connected = connected;
324
339
  if (!this.HeaterCoolerService || !Characteristic) return;
325
- try {
326
- this.HeaterCoolerService.getCharacteristic(Characteristic.StatusActive).updateValue(!!connected);
327
- this.HeaterCoolerService.getCharacteristic(Characteristic.StatusFault)
328
- .updateValue(connected ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT);
329
- } catch (err) {
330
- this.log.easyDebug(`setConnectedStatus failed for ${this.name}: ${err.message || err}`);
340
+ if (connected) {
341
+ if (this._disconnectFaultTimer) {
342
+ clearTimeout(this._disconnectFaultTimer);
343
+ this._disconnectFaultTimer = null;
344
+ }
345
+ try {
346
+ this.HeaterCoolerService.getCharacteristic(Characteristic.StatusActive).updateValue(true);
347
+ this.HeaterCoolerService.getCharacteristic(Characteristic.StatusFault)
348
+ .updateValue(Characteristic.StatusFault.NO_FAULT);
349
+ } catch (err) {
350
+ this.log.easyDebug(`setConnectedStatus failed for ${this.name}: ${err.message || err}`);
351
+ }
352
+ } else {
353
+ this._disconnectFaultTimer = setTimeout(() => {
354
+ this._disconnectFaultTimer = null;
355
+ if (!this.connected) {
356
+ try {
357
+ this.HeaterCoolerService.getCharacteristic(Characteristic.StatusActive).updateValue(false);
358
+ this.HeaterCoolerService.getCharacteristic(Characteristic.StatusFault)
359
+ .updateValue(Characteristic.StatusFault.GENERAL_FAULT);
360
+ } catch (err) {
361
+ this.log.easyDebug(`setConnectedStatus failed for ${this.name}: ${err.message || err}`);
362
+ }
363
+ }
364
+ }, DISCONNECT_FAULT_DELAY_MS).unref();
331
365
  }
332
366
  }
333
367
 
@@ -587,7 +621,7 @@ class DeviceAccessory {
587
621
  this.state = state;
588
622
  this.syncModeSwitches(this.state.mode);
589
623
 
590
- safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature), this.state.currentTemperature);
624
+ safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature), clampRange(this.state.currentTemperature, -100, 100));
591
625
 
592
626
  if (this.state.mode === ESP_MODE.OFF) {
593
627
  this.HeaterCoolerService.getCharacteristic(Characteristic.Active).updateValue(0);
package/lib/esphome.js CHANGED
@@ -79,11 +79,7 @@ async function init() {
79
79
  evictStaleSchemaAccessories(platform);
80
80
  detectOrphanedAccessories(platform);
81
81
 
82
- const manualDevices = (platform.devices || []).map(d => ({
83
- ...d,
84
- port: d.port || 6053,
85
- discovered: false,
86
- }));
82
+ const { manualDevices, invalidManualDeviceCount } = collectManualDevices(platform.devices || [], platform.log);
87
83
 
88
84
  const allDevices = manualDevices.slice();
89
85
  let discoverySucceeded = !platform.autoDiscover;
@@ -111,6 +107,11 @@ async function init() {
111
107
  }
112
108
 
113
109
  if (allDevices.length === 0) {
110
+ if (invalidManualDeviceCount > 0) {
111
+ platform.log.warn('No valid manual device hosts were configured. Keeping cached accessories until the device list is fixed.');
112
+ if (platform.autoDiscover) pruneOrphanedAccessories(platform, []);
113
+ return;
114
+ }
114
115
  if (manualDevices.length === 0 && (!platform.autoDiscover || discoverySucceeded)) {
115
116
  platform.log('No SLWF-01Pro / ESPHome devices configured and none discovered. Plugin will idle.');
116
117
  pruneOrphanedAccessories(platform, []);
@@ -135,9 +136,34 @@ async function init() {
135
136
  }
136
137
 
137
138
  const liveHosts = allDevices.map(d => d.host).filter(Boolean);
139
+ if (invalidManualDeviceCount > 0 && !platform.autoDiscover) {
140
+ platform.log.warn('One or more manual device entries are missing a host. Skipping orphan pruning to avoid removing cached accessories while the config is invalid.');
141
+ return;
142
+ }
138
143
  pruneOrphanedAccessories(platform, liveHosts);
139
144
  }
140
145
 
146
+ function collectManualDevices(devices, log) {
147
+ const manualDevices = [];
148
+ let invalidManualDeviceCount = 0;
149
+ for (const d of devices) {
150
+ const device = {
151
+ ...d,
152
+ port: d.port || 6053,
153
+ discovered: false,
154
+ };
155
+ if (device.host) {
156
+ manualDevices.push(device);
157
+ continue;
158
+ }
159
+ if (looksLikeRealEntry(device)) {
160
+ invalidManualDeviceCount++;
161
+ log.warn(`Skipping device "${device.name || '(unnamed)'}" — no host configured.`);
162
+ }
163
+ }
164
+ return { manualDevices, invalidManualDeviceCount };
165
+ }
166
+
141
167
  function spawnClient(platform, device) {
142
168
  const client = new Client({
143
169
  host: device.host,
@@ -264,4 +290,4 @@ function pruneOrphanedAccessories(platform, liveHosts) {
264
290
  }
265
291
  }
266
292
 
267
- module.exports = { init, pruneOrphanedAccessories, looksLikeRealEntry };
293
+ module.exports = { init, pruneOrphanedAccessories, looksLikeRealEntry, collectManualDevices };
package/lib/state.js CHANGED
@@ -79,10 +79,6 @@ function fanModeToSpeed(fanMode, fanModesList) {
79
79
  return Math.ceil((idx + 1) * (100 / fanModesList.length));
80
80
  }
81
81
 
82
- function chooseInitialTargetMode(stateMode) {
83
- return [ESP_MODE.COOL, ESP_MODE.HEAT, ESP_MODE.AUTO, ESP_MODE.HEAT_COOL].includes(stateMode) ? stateMode : ESP_MODE.COOL;
84
- }
85
-
86
82
  function nonEmpty(value) {
87
83
  return typeof value === 'string' && value.length > 0;
88
84
  }
@@ -106,6 +102,22 @@ function pickAutoMode(supportedModesList) {
106
102
  return null;
107
103
  }
108
104
 
105
+ function chooseInitialTargetMode(stateMode, supportedModesList) {
106
+ const supported = Array.isArray(supportedModesList) ? supportedModesList : null;
107
+ const isSupported = mode => !supported || supported.includes(mode);
108
+
109
+ if ([ESP_MODE.COOL, ESP_MODE.HEAT, ESP_MODE.AUTO, ESP_MODE.HEAT_COOL].includes(stateMode) && isSupported(stateMode)) {
110
+ return stateMode;
111
+ }
112
+ if (supported) {
113
+ if (supported.includes(ESP_MODE.COOL)) return ESP_MODE.COOL;
114
+ if (supported.includes(ESP_MODE.HEAT)) return ESP_MODE.HEAT;
115
+ const autoMode = pickAutoMode(supported);
116
+ if (autoMode !== null) return autoMode;
117
+ }
118
+ return ESP_MODE.COOL;
119
+ }
120
+
109
121
  function supportsCool(supportedModesList) {
110
122
  if (!supportedModesList) return false;
111
123
  return supportedModesList.includes(ESP_MODE.COOL)
@@ -1,4 +1,4 @@
1
- const { fanSpeedToFanMode, pickAutoMode, ESP_MODE, HK_TARGET } = require('./state');
1
+ const { fanSpeedToFanMode, pickAutoMode, chooseInitialTargetMode, ESP_MODE, HK_TARGET } = require('./state');
2
2
 
3
3
  const ACTIVE_BATCH_MS = 100;
4
4
  const TEMP_BATCH_MS = 50;
@@ -70,7 +70,9 @@ module.exports = {
70
70
  const wantOn = !!active;
71
71
  const isOn = this.state.mode != null && this.state.mode !== ESP_MODE.OFF;
72
72
  if (wantOn === isOn) return resolve();
73
- this.state.mode = wantOn ? (this.accessory.context.lastTargetState || ESP_MODE.COOL) : ESP_MODE.OFF;
73
+ this.state.mode = wantOn
74
+ ? chooseInitialTargetMode(this.accessory.context.lastTargetState, this.config.supportedModesList)
75
+ : ESP_MODE.OFF;
74
76
  markDirty(this, 'mode');
75
77
  this.log(`${this.name} - Setting AC Active to ${wantOn ? 'ON' : 'OFF'}`);
76
78
  sendState(this).then(resolve).catch(reject);
@@ -80,16 +82,25 @@ module.exports = {
80
82
 
81
83
  TargetHeaterCoolerState(hkTarget) {
82
84
  return new Promise((resolve, reject) => {
85
+ const supportedModes = Array.isArray(this.config.supportedModesList) ? this.config.supportedModesList : [];
83
86
  let espMode;
84
87
  let logMode;
85
88
  switch (hkTarget) {
86
89
  case HK_TARGET.AUTO:
87
- espMode = pickAutoMode(this.config.supportedModesList);
90
+ espMode = pickAutoMode(supportedModes);
88
91
  if (espMode === null) return resolve();
89
92
  logMode = espMode === ESP_MODE.HEAT_COOL ? 'HEAT_COOL' : 'AUTO';
90
93
  break;
91
- case HK_TARGET.HEAT: espMode = ESP_MODE.HEAT; logMode = 'HEAT'; break;
92
- case HK_TARGET.COOL: espMode = ESP_MODE.COOL; logMode = 'COOL'; break;
94
+ case HK_TARGET.HEAT:
95
+ if (!supportedModes.includes(ESP_MODE.HEAT)) return resolve();
96
+ espMode = ESP_MODE.HEAT;
97
+ logMode = 'HEAT';
98
+ break;
99
+ case HK_TARGET.COOL:
100
+ if (!supportedModes.includes(ESP_MODE.COOL)) return resolve();
101
+ espMode = ESP_MODE.COOL;
102
+ logMode = 'COOL';
103
+ break;
93
104
  default: return resolve();
94
105
  }
95
106
  if (this.state.mode === espMode) return resolve();
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 AC dongle. Auto-discovery, multi-entity bundling, and full Midea-protocol support over the ESPHome native API.",
4
- "version": "0.5.2",
4
+ "version": "0.5.4",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/nookied/homebridge-SLWF-01Pro.git"