homebridge-slwf-01pro 0.5.2 → 0.5.3

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,23 @@ This package is a maintained fork of [`homebridge-esphome-ac`](https://github.co
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.5.3] — 2026-05-06
11
+
12
+ ### Fixed
13
+
14
+ - **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.
15
+ - **External or stale HomeKit writes for unsupported HEAT/COOL target states are ignored** instead of sending unsupported ESPHome modes.
16
+ - **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.
17
+ - **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.
18
+ - **Main current-temperature updates are clamped to the HAP-safe range** before writing to `CurrentTemperature`.
19
+ - **Linked services are not re-linked redundantly** when the service is already present in HAP-NodeJS's `linkedServices` list.
20
+
21
+ ### Internal
22
+
23
+ - 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.
24
+
25
+ ---
26
+
10
27
  ## [0.5.2] — 2026-05-06
11
28
 
12
29
  ### 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
  },
@@ -119,7 +119,7 @@ class DeviceAccessory {
119
119
  this.accessory.context.schemaVersion = ACCESSORY_SCHEMA_VERSION;
120
120
  this.accessory.context.deviceId = this.id;
121
121
  this.accessory.context.host = this.host;
122
- this.accessory.context.lastTargetState = chooseInitialTargetMode(this.state.mode);
122
+ this.accessory.context.lastTargetState = chooseInitialTargetMode(this.state.mode, this.config.supportedModesList);
123
123
  platform.accessories.push(this.accessory);
124
124
  this.api.registerPlatformAccessories(platform.PLUGIN_NAME, platform.PLATFORM_NAME, [this.accessory]);
125
125
  } else {
@@ -128,7 +128,7 @@ class DeviceAccessory {
128
128
  this.accessory.context.schemaVersion = ACCESSORY_SCHEMA_VERSION;
129
129
  this.accessory.context.host = this.host;
130
130
  if (PRIMARY_MODES.includes(this.state.mode)) {
131
- this.accessory.context.lastTargetState = this.state.mode;
131
+ this.accessory.context.lastTargetState = chooseInitialTargetMode(this.state.mode, this.config.supportedModesList);
132
132
  }
133
133
  }
134
134
 
@@ -161,6 +161,7 @@ class DeviceAccessory {
161
161
  for (const svc of candidates) {
162
162
  if (!svc) continue;
163
163
  try {
164
+ if (Array.isArray(this.HeaterCoolerService.linkedServices) && this.HeaterCoolerService.linkedServices.includes(svc)) continue;
164
165
  this.HeaterCoolerService.addLinkedService(svc);
165
166
  } catch (err) {
166
167
  this.log.easyDebug(`linkOptionalServices: could not link ${svc.displayName}: ${err.message || err}`);
@@ -201,11 +202,10 @@ class DeviceAccessory {
201
202
  if (service.testCharacteristic && !service.testCharacteristic(Characteristic.ConfiguredName)) {
202
203
  service.addOptionalCharacteristic(Characteristic.ConfiguredName);
203
204
  }
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;
205
+ const configuredName = service.getCharacteristic(Characteristic.ConfiguredName);
206
+ // Preserve user-chosen names on cached services, but still seed ConfiguredName
207
+ // when a newly-enabled companion service didn't exist in the cache yet.
208
+ if (!this.isNewAccessory && typeof configuredName.value === 'string' && configuredName.value.length > 0) return;
209
209
  service.setCharacteristic(Characteristic.ConfiguredName, name);
210
210
  } catch (err) {
211
211
  this.log.easyDebug(`setConfiguredName(${name}) failed: ${err.message || err}`);
@@ -270,7 +270,7 @@ class DeviceAccessory {
270
270
  const visualStep = isPresent(this.config.visualTargetTemperatureStep) ? this.config.visualTargetTemperatureStep : DEFAULT_VISUAL_TEMP_STEP;
271
271
 
272
272
  const safeCurrent = isPresent(this.state.currentTemperature)
273
- ? this.state.currentTemperature
273
+ ? clampRange(this.state.currentTemperature, -100, 100)
274
274
  : (visualMin + visualMax) / 2;
275
275
  this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature)
276
276
  .setProps({ minValue: -100, maxValue: 100, minStep: 0.1 })
@@ -587,7 +587,7 @@ class DeviceAccessory {
587
587
  this.state = state;
588
588
  this.syncModeSwitches(this.state.mode);
589
589
 
590
- safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature), this.state.currentTemperature);
590
+ safeUpdate(this.HeaterCoolerService.getCharacteristic(Characteristic.CurrentTemperature), clampRange(this.state.currentTemperature, -100, 100));
591
591
 
592
592
  if (this.state.mode === ESP_MODE.OFF) {
593
593
  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.3",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/nookied/homebridge-SLWF-01Pro.git"