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 +17 -0
- package/README.md +3 -3
- package/config.schema.json +1 -1
- package/lib/DeviceAccessory.js +9 -9
- package/lib/esphome.js +32 -6
- package/lib/state.js +16 -4
- package/lib/stateManager.js +16 -5
- package/package.json +1 -1
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
|
|
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
|
|
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 —
|
|
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
|
|
package/config.schema.json
CHANGED
package/lib/DeviceAccessory.js
CHANGED
|
@@ -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
|
-
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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 || [])
|
|
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)
|
package/lib/stateManager.js
CHANGED
|
@@ -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
|
|
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(
|
|
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:
|
|
92
|
-
|
|
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.
|
|
4
|
+
"version": "0.5.3",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/nookied/homebridge-SLWF-01Pro.git"
|