homebridge-ac-freedom 2.3.4 → 2.3.5
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 +20 -0
- package/config.schema.json +4 -25
- package/homebridge-ui/public/index.html +2 -39
- package/package.json +1 -1
- package/src/ac-accessory.js +145 -61
- package/src/cloud-api.js +20 -13
- package/src/platform.js +10 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.5
|
|
4
|
+
|
|
5
|
+
- **Fix double beep on power-on** — `TargetHeaterCoolerState` command is suppressed when fired within 200 ms of a power command; mode is already included in `sendPower`
|
|
6
|
+
- **Fix device turns back on after power-off** — `sendPower(false)` no longer sends a separate sleep or fan command; UI reset is local-only, no extra cloud calls
|
|
7
|
+
- **Fix fan not resetting on device** — `sendPower(false)` now includes `fanSpeed=0` in the same API call, so the device restores AUTO fan on next power-on
|
|
8
|
+
- **Fix stale poll overriding command** — poll is suppressed for 5 s after any successful user command; a failed command no longer blocks state refresh
|
|
9
|
+
- **Fix mode command turning device on** — `sendMode` uses `state.power` instead of hardcoded `power=1`; changing mode while AC is off no longer wakes the device
|
|
10
|
+
- **Fix preset switches showing ON when AC is off** — `onGet` and `updateCharacteristics` now gate preset, fan and comfwind values on `state.power`
|
|
11
|
+
- **Fix temperature step** — temperature is snapped in `onSet` and pushed back to HomeKit immediately; corrects slider behaviour even when HomeKit has a cached `minStep`
|
|
12
|
+
- **Fix temperature out-of-range HAP error** — invalid temperature readings from cloud (e.g. 0°C or 100°C when AC is off) are ignored instead of forwarded to HomeKit
|
|
13
|
+
- **Fix unhandled errors in onSet** — all send handlers now use `_trySend()` which catches errors and returns `SERVICE_COMMUNICATION_FAILURE` to HomeKit instead of logging "Unhandled error"
|
|
14
|
+
- **Fix cloud error messages** — cloud API errors now extract `payload.message` instead of dumping the full JSON response
|
|
15
|
+
- **Fix `connection: "cloud"` trying local** — local connection is only attempted when `connection` is explicitly `"hybrid"`
|
|
16
|
+
- **Fix service tile order** — HomeKit climate card always shows tiles in the correct order (Fan → Sleep → Display → Health → Clean → Eco → Comf. Wind) regardless of when each service was first registered
|
|
17
|
+
- **Fix crash on command failure** — `_trySend` now correctly references `this.platform.api.hap` instead of the undefined `this.api`
|
|
18
|
+
- **Fix malformed device cookie** — cloud API now throws an actionable error instead of crashing with a JSON parse exception
|
|
19
|
+
- **Remove Temperature Step and Poll Interval settings** — both are hardcoded (1 °C step, 30 s poll); removed from Config UI and schema to prevent misconfiguration
|
|
20
|
+
- **Cleaner startup log** — reduced to one line per device; removed redundant "Restoring / Updating accessory" messages
|
|
21
|
+
- **Code cleanup** — `LOCAL_MODE_MAP` and `FAN_REMAP` extracted as module-level constants; cloud API default params moved to `DEVICE_PARAMS` constant
|
|
22
|
+
|
|
3
23
|
## 2.3.4
|
|
4
24
|
|
|
5
25
|
- **UI: badge colors** — Cloud (teal), Hybrid (green), Local (yellow) — clearly distinct from each other
|
package/config.schema.json
CHANGED
|
@@ -72,9 +72,9 @@
|
|
|
72
72
|
"type": "object",
|
|
73
73
|
"properties": {
|
|
74
74
|
"sleep": { "title": "Sleep Mode", "type": "boolean", "default": true },
|
|
75
|
-
"health": { "title": "Health / Ionizer", "type": "boolean", "default":
|
|
76
|
-
"eco": { "title": "Eco / Mildew Prevention", "type": "boolean", "default":
|
|
77
|
-
"clean": { "title": "Self Clean", "type": "boolean", "default":
|
|
75
|
+
"health": { "title": "Health / Ionizer", "type": "boolean", "default": false },
|
|
76
|
+
"eco": { "title": "Eco / Mildew Prevention", "type": "boolean", "default": false },
|
|
77
|
+
"clean": { "title": "Self Clean", "type": "boolean", "default": false }
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
80
|
"showFan": {
|
|
@@ -91,19 +91,6 @@
|
|
|
91
91
|
"title": "Display",
|
|
92
92
|
"type": "boolean",
|
|
93
93
|
"default": true
|
|
94
|
-
},
|
|
95
|
-
"pollInterval": {
|
|
96
|
-
"title": "Poll Interval (seconds)",
|
|
97
|
-
"type": "integer",
|
|
98
|
-
"default": 30,
|
|
99
|
-
"minimum": 5,
|
|
100
|
-
"maximum": 300
|
|
101
|
-
},
|
|
102
|
-
"tempStep": {
|
|
103
|
-
"title": "Temperature Step",
|
|
104
|
-
"type": "number",
|
|
105
|
-
"default": 0.5,
|
|
106
|
-
"enum": [0.5, 1]
|
|
107
94
|
}
|
|
108
95
|
}
|
|
109
96
|
}
|
|
@@ -172,15 +159,7 @@
|
|
|
172
159
|
{ "key": "devices[].showComfWind" },
|
|
173
160
|
{ "key": "devices[].showDisplay" }
|
|
174
161
|
]
|
|
175
|
-
}
|
|
176
|
-
{
|
|
177
|
-
"key": "devices[].tempStep",
|
|
178
|
-
"titleMap": {
|
|
179
|
-
"0.5": "0.5 °C",
|
|
180
|
-
"1": "1 °C"
|
|
181
|
-
}
|
|
182
|
-
},
|
|
183
|
-
{ "key": "devices[].pollInterval" }
|
|
162
|
+
}
|
|
184
163
|
]
|
|
185
164
|
}
|
|
186
165
|
]
|
|
@@ -224,7 +224,6 @@
|
|
|
224
224
|
fan: 'Fan', sleep: 'Sleep', display: 'Display',
|
|
225
225
|
health: 'Health', clean: 'Clean', eco: 'Eco',
|
|
226
226
|
comfWind: 'Comf. Wind',
|
|
227
|
-
tempStep: 'Temperature Step', pollInterval: 'Poll Interval',
|
|
228
227
|
addDevice: 'Add Device', remove: 'Remove',
|
|
229
228
|
hybrid: 'Hybrid', cloudCredentials: 'Cloud Credentials',
|
|
230
229
|
fetching: 'Fetching…', devicesFound: 'devices found',
|
|
@@ -276,32 +275,6 @@
|
|
|
276
275
|
`;
|
|
277
276
|
}
|
|
278
277
|
|
|
279
|
-
function settingsRow(d, i) {
|
|
280
|
-
const step = d.tempStep !== undefined ? d.tempStep : 0.5;
|
|
281
|
-
const poll = d.pollInterval || 30;
|
|
282
|
-
return `
|
|
283
|
-
<div class="fieldset" style="margin-bottom:12px">
|
|
284
|
-
<div class="row-2">
|
|
285
|
-
<div class="fg" style="margin-bottom:0">
|
|
286
|
-
<label>${t.tempStep||'Temperature Step'}</label>
|
|
287
|
-
<select class="d-field" data-i="${i}" data-f="tempStep">
|
|
288
|
-
<option value="0.5" ${step==0.5?'selected':''}>0.5 °C</option>
|
|
289
|
-
<option value="1" ${step==1 ?'selected':''}>1.0 °C</option>
|
|
290
|
-
</select>
|
|
291
|
-
</div>
|
|
292
|
-
<div class="fg" style="margin-bottom:0">
|
|
293
|
-
<label>${t.pollInterval||'Poll Interval'}</label>
|
|
294
|
-
<select class="d-field" data-i="${i}" data-f="pollInterval">
|
|
295
|
-
<option value="5" ${poll==5 ?'selected':''}>5 seconds</option>
|
|
296
|
-
<option value="10" ${poll==10?'selected':''}>10 seconds</option>
|
|
297
|
-
<option value="20" ${poll==20?'selected':''}>20 seconds</option>
|
|
298
|
-
<option value="30" ${poll==30?'selected':''}>30 seconds</option>
|
|
299
|
-
<option value="60" ${poll==60?'selected':''}>60 seconds</option>
|
|
300
|
-
</select>
|
|
301
|
-
</div>
|
|
302
|
-
</div>
|
|
303
|
-
</div>`;
|
|
304
|
-
}
|
|
305
278
|
|
|
306
279
|
/* ── Render cloud device card ─────────────────────────────── */
|
|
307
280
|
function renderCloudDevice(d, i) {
|
|
@@ -310,8 +283,6 @@
|
|
|
310
283
|
const conn = (d.connection === 'local' || !d.connection) ? 'cloud' : d.connection;
|
|
311
284
|
const isHybrid = conn === 'hybrid';
|
|
312
285
|
const local = d.local || {};
|
|
313
|
-
const step = d.tempStep !== undefined ? d.tempStep : 0.5;
|
|
314
|
-
const poll = d.pollInterval || 30;
|
|
315
286
|
const cardClass = isHybrid ? 'hybrid-card' : '';
|
|
316
287
|
const badge = isHybrid ? (t.hybrid||'Hybrid') : (t.cloud||'Cloud');
|
|
317
288
|
|
|
@@ -371,7 +342,6 @@
|
|
|
371
342
|
</div>
|
|
372
343
|
</div>
|
|
373
344
|
|
|
374
|
-
${settingsRow(d, i)}
|
|
375
345
|
${featToggles(d, i)}
|
|
376
346
|
</div>
|
|
377
347
|
</div>`;
|
|
@@ -382,8 +352,6 @@
|
|
|
382
352
|
const isOpen = expanded.has(i);
|
|
383
353
|
const name = d.name || `Local #${li + 1}`;
|
|
384
354
|
const local = d.local || {};
|
|
385
|
-
const step = d.tempStep !== undefined ? d.tempStep : 0.5;
|
|
386
|
-
const poll = d.pollInterval || 30;
|
|
387
355
|
|
|
388
356
|
return `
|
|
389
357
|
<div class="device-card" id="dev-card-${i}">
|
|
@@ -421,7 +389,6 @@
|
|
|
421
389
|
</div>
|
|
422
390
|
</div>
|
|
423
391
|
|
|
424
|
-
${settingsRow(d, i)}
|
|
425
392
|
${featToggles(d, i)}
|
|
426
393
|
</div>
|
|
427
394
|
</div>`;
|
|
@@ -572,7 +539,6 @@
|
|
|
572
539
|
local: { ip: '', mac: '' },
|
|
573
540
|
presets: { sleep: true, health: false, eco: false, clean: false },
|
|
574
541
|
showFan: true, showComfWind: false, showDisplay: false,
|
|
575
|
-
tempStep: 0.5, pollInterval: 30,
|
|
576
542
|
});
|
|
577
543
|
expanded.add(config.devices.length - 1);
|
|
578
544
|
added++;
|
|
@@ -602,7 +568,6 @@
|
|
|
602
568
|
local: { ip: '', mac: '' },
|
|
603
569
|
presets: { sleep: true, health: false, eco: false, clean: false },
|
|
604
570
|
showFan: true, showComfWind: false, showDisplay: false,
|
|
605
|
-
tempStep: 0.5, pollInterval: 30,
|
|
606
571
|
});
|
|
607
572
|
expanded.add(i);
|
|
608
573
|
render(); save();
|
|
@@ -634,13 +599,11 @@
|
|
|
634
599
|
});
|
|
635
600
|
});
|
|
636
601
|
|
|
637
|
-
/* Device-level fields (name
|
|
602
|
+
/* Device-level fields (name) */
|
|
638
603
|
document.querySelectorAll('.d-field').forEach(el => {
|
|
639
604
|
el.addEventListener('change', () => {
|
|
640
605
|
const i = +el.dataset.i, f = el.dataset.f;
|
|
641
|
-
config.devices[i][f] =
|
|
642
|
-
: (f === 'pollInterval') ? (parseInt(el.value) || 30)
|
|
643
|
-
: el.value;
|
|
606
|
+
config.devices[i][f] = el.value;
|
|
644
607
|
save();
|
|
645
608
|
});
|
|
646
609
|
el.addEventListener('input', () => {
|
package/package.json
CHANGED
package/src/ac-accessory.js
CHANGED
|
@@ -39,9 +39,17 @@ const CLOUD_MODE = { AUTO: 4, COOL: 0, HEAT: 1, DRY: 2, FAN: 3 };
|
|
|
39
39
|
// Conversion tables applied in pollLocal / sendFanSpeed
|
|
40
40
|
const FAN_SPEED = { AUTO: 0, LOW: 1, MEDIUM: 2, HIGH: 3, TURBO: 4 };
|
|
41
41
|
|
|
42
|
-
// Local ↔
|
|
43
|
-
const
|
|
44
|
-
|
|
42
|
+
// Local ↔ cloud fan speed remap (mapping is self-inverse: 1↔3, others unchanged)
|
|
43
|
+
const FAN_REMAP = { 0: 0, 1: 3, 2: 2, 3: 1, 4: 4 };
|
|
44
|
+
|
|
45
|
+
// Cloud mode → local protocol mode byte (AUTO=0 avoids 3-bit overflow at value 8)
|
|
46
|
+
const LOCAL_MODE_MAP = {
|
|
47
|
+
[4]: 0, // AUTO
|
|
48
|
+
[0]: 1, // COOL
|
|
49
|
+
[1]: 4, // HEAT
|
|
50
|
+
[2]: 2, // DRY
|
|
51
|
+
[3]: 6, // FAN
|
|
52
|
+
};
|
|
45
53
|
|
|
46
54
|
class AcFreedomAccessory {
|
|
47
55
|
constructor(platform, accessory, config, deviceApi) {
|
|
@@ -84,9 +92,10 @@ class AcFreedomAccessory {
|
|
|
84
92
|
this.setupPresetSwitches();
|
|
85
93
|
this.setupComfWindSwitch();
|
|
86
94
|
this.setupDisplaySwitch();
|
|
95
|
+
this._reorderServices();
|
|
87
96
|
|
|
88
97
|
// Start polling
|
|
89
|
-
const interval =
|
|
98
|
+
const interval = 30000;
|
|
90
99
|
this.pollTimer = setInterval(() => this.pollState(), interval);
|
|
91
100
|
this.pollState();
|
|
92
101
|
}
|
|
@@ -122,9 +131,13 @@ class AcFreedomAccessory {
|
|
|
122
131
|
this.heaterCooler.getCharacteristic(C.Active)
|
|
123
132
|
.onGet(() => this.state.power ? C.Active.ACTIVE : C.Active.INACTIVE)
|
|
124
133
|
.onSet(async (value) => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
const newPower = value === C.Active.ACTIVE;
|
|
135
|
+
if (newPower === this.state.power) return;
|
|
136
|
+
this.state.power = newPower;
|
|
137
|
+
if (!newPower) this._powerOffReset();
|
|
138
|
+
this._powerJustChanged = true;
|
|
139
|
+
setTimeout(() => { this._powerJustChanged = false; }, 300);
|
|
140
|
+
await this._trySend(() => this.sendPower(newPower));
|
|
128
141
|
});
|
|
129
142
|
|
|
130
143
|
this.heaterCooler.getCharacteristic(C.CurrentHeaterCoolerState)
|
|
@@ -160,30 +173,43 @@ class AcFreedomAccessory {
|
|
|
160
173
|
default: mode = CLOUD_MODE.AUTO; break;
|
|
161
174
|
}
|
|
162
175
|
this.state.mode = mode;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
176
|
+
// Wait for Active.onSet to fire first — HomeKit sends both in the same
|
|
177
|
+
// batch but order is not guaranteed; 200 ms covers the race window.
|
|
178
|
+
await new Promise(r => setTimeout(r, 200));
|
|
179
|
+
if (this._powerJustChanged) return;
|
|
180
|
+
await this._trySend(() => this.sendMode(mode));
|
|
166
181
|
});
|
|
167
182
|
|
|
168
183
|
this.heaterCooler.getCharacteristic(C.CurrentTemperature)
|
|
169
184
|
.setProps({ minValue: -20, maxValue: 60 })
|
|
170
185
|
.onGet(() => this.state.currentTemp);
|
|
171
186
|
|
|
172
|
-
const
|
|
187
|
+
const snapTemp = (v) => Math.round(v);
|
|
188
|
+
|
|
173
189
|
this.heaterCooler.getCharacteristic(C.CoolingThresholdTemperature)
|
|
174
|
-
.setProps({ minValue: 16, maxValue: 32, minStep:
|
|
175
|
-
.onGet(() => this.state.targetTemp)
|
|
190
|
+
.setProps({ minValue: 16, maxValue: 32, minStep: 1 })
|
|
191
|
+
.onGet(() => snapTemp(this.state.targetTemp))
|
|
176
192
|
.onSet(async (value) => {
|
|
177
|
-
|
|
178
|
-
|
|
193
|
+
const snapped = snapTemp(value);
|
|
194
|
+
this.state.targetTemp = snapped;
|
|
195
|
+
if (snapped !== value) {
|
|
196
|
+
this.heaterCooler.updateCharacteristic(C.CoolingThresholdTemperature, snapped);
|
|
197
|
+
this.heaterCooler.updateCharacteristic(C.HeatingThresholdTemperature, snapped);
|
|
198
|
+
}
|
|
199
|
+
await this._trySend(() => this.sendTemperature(snapped));
|
|
179
200
|
});
|
|
180
201
|
|
|
181
202
|
this.heaterCooler.getCharacteristic(C.HeatingThresholdTemperature)
|
|
182
|
-
.setProps({ minValue: 16, maxValue: 32, minStep:
|
|
183
|
-
.onGet(() => this.state.targetTemp)
|
|
203
|
+
.setProps({ minValue: 16, maxValue: 32, minStep: 1 })
|
|
204
|
+
.onGet(() => snapTemp(this.state.targetTemp))
|
|
184
205
|
.onSet(async (value) => {
|
|
185
|
-
|
|
186
|
-
|
|
206
|
+
const snapped = snapTemp(value);
|
|
207
|
+
this.state.targetTemp = snapped;
|
|
208
|
+
if (snapped !== value) {
|
|
209
|
+
this.heaterCooler.updateCharacteristic(C.CoolingThresholdTemperature, snapped);
|
|
210
|
+
this.heaterCooler.updateCharacteristic(C.HeatingThresholdTemperature, snapped);
|
|
211
|
+
}
|
|
212
|
+
await this._trySend(() => this.sendTemperature(snapped));
|
|
187
213
|
});
|
|
188
214
|
|
|
189
215
|
this.heaterCooler.getCharacteristic(C.SwingMode)
|
|
@@ -194,7 +220,7 @@ class AcFreedomAccessory {
|
|
|
194
220
|
const on = value === C.SwingMode.SWING_ENABLED;
|
|
195
221
|
this.state.swingV = on;
|
|
196
222
|
this.state.swingH = on;
|
|
197
|
-
await this.sendSwing(on, on);
|
|
223
|
+
await this._trySend(() => this.sendSwing(on, on));
|
|
198
224
|
});
|
|
199
225
|
}
|
|
200
226
|
|
|
@@ -227,18 +253,22 @@ class AcFreedomAccessory {
|
|
|
227
253
|
this.fanService.getCharacteristic(C.Active)
|
|
228
254
|
.onGet(() => this.state.power ? C.Active.ACTIVE : C.Active.INACTIVE)
|
|
229
255
|
.onSet(async (value) => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
256
|
+
const newPower = value === C.Active.ACTIVE;
|
|
257
|
+
if (newPower === this.state.power) return;
|
|
258
|
+
this.state.power = newPower;
|
|
259
|
+
if (!newPower) this._powerOffReset();
|
|
260
|
+
this._powerJustChanged = true;
|
|
261
|
+
setTimeout(() => { this._powerJustChanged = false; }, 300);
|
|
262
|
+
await this._trySend(() => this.sendPower(newPower));
|
|
233
263
|
});
|
|
234
264
|
|
|
235
265
|
// 0=Auto 25=Low 50=Medium 75=High 100=Turbo
|
|
236
266
|
this.fanService.getCharacteristic(C.RotationSpeed)
|
|
237
267
|
.setProps({ minValue: 0, maxValue: 100, minStep: 25 })
|
|
238
|
-
.onGet(() => this.fanSpeedToPercent(this.state.fanSpeed))
|
|
268
|
+
.onGet(() => this.state.power ? this.fanSpeedToPercent(this.state.fanSpeed) : 0)
|
|
239
269
|
.onSet(async (value) => {
|
|
240
270
|
this.state.fanSpeed = this.percentToFanSpeed(value);
|
|
241
|
-
await this.sendFanSpeed(this.state.fanSpeed);
|
|
271
|
+
await this._trySend(() => this.sendFanSpeed(this.state.fanSpeed));
|
|
242
272
|
});
|
|
243
273
|
|
|
244
274
|
this.heaterCooler.addLinkedService(this.fanService);
|
|
@@ -262,7 +292,7 @@ class AcFreedomAccessory {
|
|
|
262
292
|
svc.setCharacteristic(this.Characteristic.Name, cfg.label);
|
|
263
293
|
|
|
264
294
|
svc.getCharacteristic(this.Characteristic.On)
|
|
265
|
-
.onGet(() => this.state[key])
|
|
295
|
+
.onGet(() => this.state.power && this.state[key])
|
|
266
296
|
.onSet(async (value) => {
|
|
267
297
|
if (value) {
|
|
268
298
|
for (const otherKey of Object.keys(this.presetConfigs)) {
|
|
@@ -270,7 +300,7 @@ class AcFreedomAccessory {
|
|
|
270
300
|
}
|
|
271
301
|
}
|
|
272
302
|
this.state[key] = value;
|
|
273
|
-
await this.sendPreset(key, value);
|
|
303
|
+
await this._trySend(() => this.sendPreset(key, value));
|
|
274
304
|
this.refreshPresetSwitches(key);
|
|
275
305
|
});
|
|
276
306
|
|
|
@@ -301,10 +331,10 @@ class AcFreedomAccessory {
|
|
|
301
331
|
this.comfWindSwitch.setCharacteristic(this.Characteristic.Name, 'Comfortable Wind');
|
|
302
332
|
|
|
303
333
|
this.comfWindSwitch.getCharacteristic(this.Characteristic.On)
|
|
304
|
-
.onGet(() => this.state.comfwind)
|
|
334
|
+
.onGet(() => this.state.power && this.state.comfwind)
|
|
305
335
|
.onSet(async (value) => {
|
|
306
336
|
this.state.comfwind = value;
|
|
307
|
-
await this.sendComfWind(value);
|
|
337
|
+
await this._trySend(() => this.sendComfWind(value));
|
|
308
338
|
});
|
|
309
339
|
|
|
310
340
|
this.heaterCooler.addLinkedService(this.comfWindSwitch);
|
|
@@ -327,28 +357,22 @@ class AcFreedomAccessory {
|
|
|
327
357
|
.onGet(() => this.state.display)
|
|
328
358
|
.onSet(async (value) => {
|
|
329
359
|
this.state.display = value;
|
|
330
|
-
await this.sendDisplay(value);
|
|
360
|
+
await this._trySend(() => this.sendDisplay(value));
|
|
331
361
|
});
|
|
332
362
|
|
|
333
363
|
this.heaterCooler.addLinkedService(this.displaySwitch);
|
|
334
364
|
}
|
|
335
365
|
|
|
336
|
-
// ──
|
|
337
|
-
|
|
366
|
+
// ── Power-off UI reset ─────────────────────────────────────────
|
|
367
|
+
// Updates HomeKit display only. No cloud commands — avoids interfering
|
|
368
|
+
// with the power-off command and prevents spurious device wake-up.
|
|
369
|
+
_powerOffReset() {
|
|
338
370
|
this.state.fanSpeed = FAN_SPEED.AUTO;
|
|
339
|
-
|
|
371
|
+
this.state.sleep = false;
|
|
372
|
+
if (this.fanService)
|
|
340
373
|
this.fanService.updateCharacteristic(this.Characteristic.RotationSpeed, 0);
|
|
341
|
-
|
|
342
|
-
if (sendCommand) this.sendFanSpeed(FAN_SPEED.AUTO).catch(() => {});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
resetSleep() {
|
|
346
|
-
if (!this.state.sleep) return;
|
|
347
|
-
this.state.sleep = false;
|
|
348
|
-
if (this.presetSwitches?.sleep) {
|
|
374
|
+
if (this.presetSwitches?.sleep)
|
|
349
375
|
this.presetSwitches.sleep.updateCharacteristic(this.Characteristic.On, false);
|
|
350
|
-
}
|
|
351
|
-
this.sendPreset('sleep', false).catch(() => {});
|
|
352
376
|
}
|
|
353
377
|
|
|
354
378
|
// Fan speed ↔ HomeKit percent (canonical cloud numbering)
|
|
@@ -368,6 +392,7 @@ class AcFreedomAccessory {
|
|
|
368
392
|
|
|
369
393
|
// ── Poll state ─────────────────────────────────────────────────
|
|
370
394
|
async pollState() {
|
|
395
|
+
if (this._pollLockUntil && Date.now() < this._pollLockUntil) return;
|
|
371
396
|
try {
|
|
372
397
|
const t = this.deviceApi.type;
|
|
373
398
|
if (t === 'local') {
|
|
@@ -403,8 +428,10 @@ class AcFreedomAccessory {
|
|
|
403
428
|
|
|
404
429
|
this.state.power = !!params[CLOUD.POWER];
|
|
405
430
|
this.state.mode = params[CLOUD.MODE] ?? CLOUD_MODE.AUTO;
|
|
406
|
-
|
|
407
|
-
|
|
431
|
+
const rawTarget = (params[CLOUD.TEMP_TARGET] ?? 240) / 10;
|
|
432
|
+
const rawAmbient = (params[CLOUD.TEMP_AMBIENT] ?? 240) / 10;
|
|
433
|
+
if (rawTarget >= 16 && rawTarget <= 32) this.state.targetTemp = rawTarget;
|
|
434
|
+
if (rawAmbient >= 0 && rawAmbient <= 60) this.state.currentTemp = rawAmbient;
|
|
408
435
|
this.state.fanSpeed = params[CLOUD.FAN_SPEED] ?? FAN_SPEED.AUTO;
|
|
409
436
|
this.state.swingV = !!params[CLOUD.SWING_V];
|
|
410
437
|
this.state.swingH = !!params[CLOUD.SWING_H];
|
|
@@ -434,7 +461,7 @@ class AcFreedomAccessory {
|
|
|
434
461
|
this.state.targetTemp = s.temperature;
|
|
435
462
|
this.state.currentTemp = s.ambientTemp;
|
|
436
463
|
// Normalise local fan speed → canonical cloud numbering
|
|
437
|
-
this.state.fanSpeed =
|
|
464
|
+
this.state.fanSpeed = FAN_REMAP[s.fanSpeed] ?? FAN_SPEED.AUTO;
|
|
438
465
|
this.state.swingV = s.verticalFixation === 7;
|
|
439
466
|
this.state.swingH = s.horizontalFixation === 7;
|
|
440
467
|
this.state.sleep = !!s.sleep;
|
|
@@ -466,9 +493,10 @@ class AcFreedomAccessory {
|
|
|
466
493
|
this.heaterCooler.updateCharacteristic(C.CurrentHeaterCoolerState, hcState);
|
|
467
494
|
}
|
|
468
495
|
|
|
496
|
+
const snapped = Math.round(this.state.targetTemp);
|
|
469
497
|
this.heaterCooler.updateCharacteristic(C.CurrentTemperature, this.state.currentTemp);
|
|
470
|
-
this.heaterCooler.updateCharacteristic(C.CoolingThresholdTemperature,
|
|
471
|
-
this.heaterCooler.updateCharacteristic(C.HeatingThresholdTemperature,
|
|
498
|
+
this.heaterCooler.updateCharacteristic(C.CoolingThresholdTemperature, snapped);
|
|
499
|
+
this.heaterCooler.updateCharacteristic(C.HeatingThresholdTemperature, snapped);
|
|
472
500
|
this.heaterCooler.updateCharacteristic(C.SwingMode,
|
|
473
501
|
(this.state.swingV || this.state.swingH) ? C.SwingMode.SWING_ENABLED : C.SwingMode.SWING_DISABLED);
|
|
474
502
|
|
|
@@ -480,11 +508,11 @@ class AcFreedomAccessory {
|
|
|
480
508
|
}
|
|
481
509
|
|
|
482
510
|
for (const [key, svc] of Object.entries(this.presetSwitches || {})) {
|
|
483
|
-
svc.updateCharacteristic(C.On, this.state[key]);
|
|
511
|
+
svc.updateCharacteristic(C.On, this.state.power && this.state[key]);
|
|
484
512
|
}
|
|
485
513
|
|
|
486
514
|
if (this.comfWindSwitch) {
|
|
487
|
-
this.comfWindSwitch.updateCharacteristic(C.On, this.state.comfwind);
|
|
515
|
+
this.comfWindSwitch.updateCharacteristic(C.On, this.state.power && this.state.comfwind);
|
|
488
516
|
}
|
|
489
517
|
if (this.displaySwitch) {
|
|
490
518
|
this.displaySwitch.updateCharacteristic(C.On, this.state.display);
|
|
@@ -512,28 +540,50 @@ class AcFreedomAccessory {
|
|
|
512
540
|
return cloudFn(); // legacy 'cloud' type
|
|
513
541
|
}
|
|
514
542
|
|
|
543
|
+
// Wraps a send call for use inside onSet handlers.
|
|
544
|
+
// Suppresses poll for 5 s after any command to avoid stale cloud state overriding UI.
|
|
545
|
+
// Logs the error and throws HapStatusError so HomeKit shows failure correctly.
|
|
546
|
+
async _trySend(fn) {
|
|
547
|
+
try {
|
|
548
|
+
await fn();
|
|
549
|
+
this._pollLockUntil = Date.now() + 5000;
|
|
550
|
+
} catch (err) {
|
|
551
|
+
this.log.warn('%s: command failed: %s', this.config.name, err.message);
|
|
552
|
+
throw new this.platform.api.hap.HapStatusError(
|
|
553
|
+
this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
515
558
|
// ── Send commands ──────────────────────────────────────────────
|
|
516
559
|
async sendPower(on) {
|
|
517
560
|
return this._send(
|
|
518
|
-
() => {
|
|
519
|
-
|
|
561
|
+
() => {
|
|
562
|
+
const a = this._localApi;
|
|
563
|
+
a.state.power = on ? 1 : 0;
|
|
564
|
+
if (on) a.state.mode = LOCAL_MODE_MAP[this.state.mode] ?? 0;
|
|
565
|
+
if (!on) a.state.fanSpeed = 0;
|
|
566
|
+
return a.setState();
|
|
567
|
+
},
|
|
568
|
+
() => {
|
|
569
|
+
const p = { [CLOUD.POWER]: on ? 1 : 0 };
|
|
570
|
+
if (on) p[CLOUD.MODE] = this.state.mode;
|
|
571
|
+
if (!on) p[CLOUD.FAN_SPEED] = 0;
|
|
572
|
+
return this.cloudSet(p);
|
|
573
|
+
},
|
|
520
574
|
);
|
|
521
575
|
}
|
|
522
576
|
|
|
523
577
|
async sendMode(mode) {
|
|
524
|
-
|
|
525
|
-
const localModeMap = {
|
|
526
|
-
[CLOUD_MODE.AUTO]: 0, [CLOUD_MODE.COOL]: 1,
|
|
527
|
-
[CLOUD_MODE.HEAT]: 4, [CLOUD_MODE.DRY]: 2, [CLOUD_MODE.FAN]: 6,
|
|
528
|
-
};
|
|
578
|
+
const pwr = this.state.power ? 1 : 0;
|
|
529
579
|
return this._send(
|
|
530
580
|
() => {
|
|
531
581
|
const a = this._localApi;
|
|
532
|
-
a.state.power =
|
|
533
|
-
a.state.mode =
|
|
582
|
+
a.state.power = pwr;
|
|
583
|
+
a.state.mode = LOCAL_MODE_MAP[mode] ?? 0;
|
|
534
584
|
return a.setState();
|
|
535
585
|
},
|
|
536
|
-
() => this.cloudSet({ [CLOUD.POWER]:
|
|
586
|
+
() => this.cloudSet({ [CLOUD.POWER]: pwr, [CLOUD.MODE]: mode }),
|
|
537
587
|
);
|
|
538
588
|
}
|
|
539
589
|
|
|
@@ -546,7 +596,7 @@ class AcFreedomAccessory {
|
|
|
546
596
|
|
|
547
597
|
async sendFanSpeed(speed) {
|
|
548
598
|
// speed is canonical cloud numbering; convert for local
|
|
549
|
-
const localSpeed =
|
|
599
|
+
const localSpeed = FAN_REMAP[speed] ?? 0;
|
|
550
600
|
return this._send(
|
|
551
601
|
() => {
|
|
552
602
|
const a = this._localApi;
|
|
@@ -609,6 +659,40 @@ class AcFreedomAccessory {
|
|
|
609
659
|
await this._cloudApi.setDeviceParams(this._cloudDevice, params);
|
|
610
660
|
}
|
|
611
661
|
|
|
662
|
+
// ── Service ordering ───────────────────────────────────────────
|
|
663
|
+
// Sorts accessory.services so HomeKit always shows tiles in the
|
|
664
|
+
// correct order regardless of when each service was first added.
|
|
665
|
+
_reorderServices() {
|
|
666
|
+
const rank = (svc) => {
|
|
667
|
+
const s = svc.subtype;
|
|
668
|
+
if (svc.UUID === this.Service.AccessoryInformation.UUID) return 0;
|
|
669
|
+
if (svc.UUID === this.Service.HeaterCooler.UUID) return 1;
|
|
670
|
+
if (s === 'fan') return 2;
|
|
671
|
+
if (s === 'sleep') return 3;
|
|
672
|
+
if (s === 'display') return 4;
|
|
673
|
+
if (s === 'health') return 5;
|
|
674
|
+
if (s === 'clean') return 6;
|
|
675
|
+
if (s === 'eco') return 7;
|
|
676
|
+
if (s === 'comfwind') return 8;
|
|
677
|
+
return 99;
|
|
678
|
+
};
|
|
679
|
+
this.accessory.services.sort((a, b) => rank(a) - rank(b));
|
|
680
|
+
|
|
681
|
+
// Rebuild linkedServices explicitly in the desired order so the HAP
|
|
682
|
+
// "linked" array is sent correctly regardless of IID values.
|
|
683
|
+
const linked = [
|
|
684
|
+
this.fanService,
|
|
685
|
+
this.presetSwitches?.sleep,
|
|
686
|
+
this.displaySwitch,
|
|
687
|
+
this.presetSwitches?.health,
|
|
688
|
+
this.presetSwitches?.clean,
|
|
689
|
+
this.presetSwitches?.eco,
|
|
690
|
+
this.comfWindSwitch,
|
|
691
|
+
].filter(Boolean);
|
|
692
|
+
this.heaterCooler.linkedServices.length = 0;
|
|
693
|
+
for (const svc of linked) this.heaterCooler.linkedServices.push(svc);
|
|
694
|
+
}
|
|
695
|
+
|
|
612
696
|
// ── Cleanup ────────────────────────────────────────────────────
|
|
613
697
|
destroy() {
|
|
614
698
|
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
|
package/src/cloud-api.js
CHANGED
|
@@ -80,6 +80,13 @@ function httpRequest(url, options, body) {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Default device parameter keys fetched on every poll
|
|
84
|
+
const DEVICE_PARAMS = [
|
|
85
|
+
'pwr', 'ac_mode', 'temp', 'envtemp', 'ac_mark',
|
|
86
|
+
'ac_vdir', 'ac_hdir', 'ac_slp', 'ac_health',
|
|
87
|
+
'mldprf', 'ac_clean', 'comfwind', 'scrdisp',
|
|
88
|
+
];
|
|
89
|
+
|
|
83
90
|
// ── AuxCloudAPI ──────────────────────────────────────────────────
|
|
84
91
|
class AuxCloudAPI {
|
|
85
92
|
constructor(region = 'eu') {
|
|
@@ -139,7 +146,7 @@ class AuxCloudAPI {
|
|
|
139
146
|
this.userid = result.userid;
|
|
140
147
|
return true;
|
|
141
148
|
}
|
|
142
|
-
throw new Error(`Login failed: ${
|
|
149
|
+
throw new Error(`Login failed: ${result.msg || result.status || 'unknown error'}`);
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
// ── Families ───────────────────────────────────────────────────
|
|
@@ -148,7 +155,7 @@ class AuxCloudAPI {
|
|
|
148
155
|
headers: this._headers(),
|
|
149
156
|
});
|
|
150
157
|
if (result.status === 0) return result.data.familyList || [];
|
|
151
|
-
throw new Error(`Get families failed: ${
|
|
158
|
+
throw new Error(`Get families failed: ${result.msg || result.status || 'unknown error'}`);
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
// ── Devices ────────────────────────────────────────────────────
|
|
@@ -159,7 +166,7 @@ class AuxCloudAPI {
|
|
|
159
166
|
});
|
|
160
167
|
|
|
161
168
|
if (result.status !== 0) {
|
|
162
|
-
throw new Error(`Get devices failed: ${
|
|
169
|
+
throw new Error(`Get devices failed: ${result.msg || result.status || 'unknown error'}`);
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
const devices = result.data?.endpoints || [];
|
|
@@ -179,7 +186,12 @@ class AuxCloudAPI {
|
|
|
179
186
|
|
|
180
187
|
// ── Get / Set device params ────────────────────────────────────
|
|
181
188
|
async _actDeviceParams(device, act, params = [], vals = []) {
|
|
182
|
-
|
|
189
|
+
let cookieRaw;
|
|
190
|
+
try {
|
|
191
|
+
cookieRaw = JSON.parse(Buffer.from(device.cookie, 'base64').toString());
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error('Device cookie is malformed — re-fetch devices in plugin settings');
|
|
194
|
+
}
|
|
183
195
|
const mappedCookie = Buffer.from(JSON.stringify({
|
|
184
196
|
device: {
|
|
185
197
|
id: cookieRaw.terminalid,
|
|
@@ -239,17 +251,12 @@ class AuxCloudAPI {
|
|
|
239
251
|
return out;
|
|
240
252
|
}
|
|
241
253
|
|
|
242
|
-
|
|
254
|
+
const errPayload = result?.event?.payload || {};
|
|
255
|
+
const reason = errPayload.message || errPayload.type || result?.event?.header?.name || 'unknown error';
|
|
256
|
+
throw new Error(`Device unreachable: ${reason}`);
|
|
243
257
|
}
|
|
244
258
|
|
|
245
|
-
async getDeviceParams(device, params) {
|
|
246
|
-
if (!params) {
|
|
247
|
-
params = [
|
|
248
|
-
'pwr', 'ac_mode', 'temp', 'envtemp', 'ac_mark',
|
|
249
|
-
'ac_vdir', 'ac_hdir', 'ac_slp', 'ac_health',
|
|
250
|
-
'mldprf', 'ac_clean', 'comfwind', 'scrdisp',
|
|
251
|
-
];
|
|
252
|
-
}
|
|
259
|
+
async getDeviceParams(device, params = DEVICE_PARAMS) {
|
|
253
260
|
return this._actDeviceParams(device, 'get', params);
|
|
254
261
|
}
|
|
255
262
|
|
package/src/platform.js
CHANGED
|
@@ -34,13 +34,11 @@ class AcFreedomPlatform {
|
|
|
34
34
|
if (!api || !config) return;
|
|
35
35
|
|
|
36
36
|
this.api.on('didFinishLaunching', () => {
|
|
37
|
-
this.log.info('AC Freedom platform loaded');
|
|
38
37
|
this.discoverDevices().catch(err => this.log.error('Fatal: %s', err.message));
|
|
39
38
|
});
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
configureAccessory(accessory) {
|
|
43
|
-
this.log.info('Restoring cached accessory: %s', accessory.displayName);
|
|
44
42
|
this.accessories.set(accessory.UUID, accessory);
|
|
45
43
|
}
|
|
46
44
|
|
|
@@ -72,7 +70,6 @@ class AcFreedomPlatform {
|
|
|
72
70
|
const api = new AuxCloudAPI(cloudCreds.region || 'eu');
|
|
73
71
|
try {
|
|
74
72
|
await api.login(cloudCreds.email, cloudCreds.password);
|
|
75
|
-
this.log.info('Cloud login successful: %s', cloudCreds.email);
|
|
76
73
|
|
|
77
74
|
const families = await api.getFamilies();
|
|
78
75
|
const allDevices = [];
|
|
@@ -80,7 +77,7 @@ class AcFreedomPlatform {
|
|
|
80
77
|
const devs = await api.getDevices(fam.familyid);
|
|
81
78
|
allDevices.push(...devs);
|
|
82
79
|
}
|
|
83
|
-
this.log.info('
|
|
80
|
+
this.log.info('Cloud: %s — %d device(s)', cloudCreds.email, allDevices.length);
|
|
84
81
|
return { api, devices: allDevices };
|
|
85
82
|
} catch (err) {
|
|
86
83
|
this.log.error('Cloud login failed: %s', err.message);
|
|
@@ -114,12 +111,10 @@ class AcFreedomPlatform {
|
|
|
114
111
|
|
|
115
112
|
const existingAccessory = this.accessories.get(uuid);
|
|
116
113
|
if (existingAccessory) {
|
|
117
|
-
this.log.info('Updating existing accessory: %s', deviceConfig.name);
|
|
118
114
|
const instance = new AcFreedomAccessory(this, existingAccessory, deviceConfig, deviceApi);
|
|
119
115
|
this.instances.set(uuid, instance);
|
|
120
116
|
this.api.updatePlatformAccessories([existingAccessory]);
|
|
121
117
|
} else {
|
|
122
|
-
this.log.info('Adding new accessory: %s', deviceConfig.name);
|
|
123
118
|
const accessory = new this.api.platformAccessory(deviceConfig.name, uuid);
|
|
124
119
|
const instance = new AcFreedomAccessory(this, accessory, deviceConfig, deviceApi);
|
|
125
120
|
this.instances.set(uuid, instance);
|
|
@@ -184,40 +179,25 @@ class AcFreedomPlatform {
|
|
|
184
179
|
}
|
|
185
180
|
}
|
|
186
181
|
|
|
187
|
-
// Try local connection
|
|
182
|
+
// Try local connection only when explicitly set to hybrid mode
|
|
188
183
|
let localApi = null;
|
|
189
184
|
const localCfg = deviceConfig.local;
|
|
190
|
-
if (localCfg?.ip && localCfg?.mac) {
|
|
185
|
+
if (deviceConfig.connection === 'hybrid' && localCfg?.ip && localCfg?.mac) {
|
|
191
186
|
const broadlink = new BroadlinkAcApi(localCfg.ip, localCfg.mac);
|
|
192
187
|
try {
|
|
193
188
|
const connected = await broadlink.connect();
|
|
194
189
|
if (connected) {
|
|
195
|
-
this.log.info(
|
|
196
|
-
'Hybrid device %s: local connected at %s (preferred)',
|
|
197
|
-
deviceConfig.name, localCfg.ip,
|
|
198
|
-
);
|
|
199
190
|
localApi = broadlink;
|
|
200
191
|
} else {
|
|
201
|
-
this.log.warn(
|
|
202
|
-
'Hybrid device %s: local connect failed at %s — using cloud fallback',
|
|
203
|
-
deviceConfig.name, localCfg.ip,
|
|
204
|
-
);
|
|
192
|
+
this.log.warn('%s: local connect failed at %s — using cloud', deviceConfig.name, localCfg.ip);
|
|
205
193
|
}
|
|
206
194
|
} catch (err) {
|
|
207
|
-
this.log.warn(
|
|
208
|
-
'Hybrid device %s: local connect error (%s) — using cloud fallback',
|
|
209
|
-
deviceConfig.name, err.message,
|
|
210
|
-
);
|
|
195
|
+
this.log.warn('%s: local connect error (%s) — using cloud', deviceConfig.name, err.message);
|
|
211
196
|
}
|
|
212
197
|
}
|
|
213
198
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
deviceConfig.name,
|
|
217
|
-
cloudDevice.friendlyName || 'AUX AC',
|
|
218
|
-
cloudDevice.endpointId,
|
|
219
|
-
localApi ? 'local+cloud' : 'cloud only',
|
|
220
|
-
);
|
|
199
|
+
const mode = localApi ? `local+cloud (${localCfg.ip})` : 'cloud';
|
|
200
|
+
this.log.info('%s: %s', deviceConfig.name, mode);
|
|
221
201
|
|
|
222
202
|
return {
|
|
223
203
|
type: 'hybrid',
|
|
@@ -239,13 +219,13 @@ class AcFreedomPlatform {
|
|
|
239
219
|
try {
|
|
240
220
|
const connected = await api.connect();
|
|
241
221
|
if (!connected) {
|
|
242
|
-
this.log.error('
|
|
222
|
+
this.log.error('%s: local connect failed at %s', config.name, local.ip);
|
|
243
223
|
return null;
|
|
244
224
|
}
|
|
245
|
-
this.log.info('
|
|
225
|
+
this.log.info('%s: local (%s)', config.name, local.ip);
|
|
246
226
|
return { type: 'local', api };
|
|
247
227
|
} catch (err) {
|
|
248
|
-
this.log.error('
|
|
228
|
+
this.log.error('%s: local connect error — %s', config.name, err.message);
|
|
249
229
|
return null;
|
|
250
230
|
}
|
|
251
231
|
}
|