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 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
@@ -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": true },
76
- "eco": { "title": "Eco / Mildew Prevention", "type": "boolean", "default": true },
77
- "clean": { "title": "Self Clean", "type": "boolean", "default": true }
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, tempStep, pollInterval) */
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] = (f === 'tempStep') ? (parseFloat(el.value) || 0.5)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-ac-freedom",
3
- "version": "2.3.4",
3
+ "version": "2.3.5",
4
4
  "displayName": "Homebridge AC Freedom",
5
5
  "description": "Homebridge plugin for AUX air conditioners via Broadlink (local UDP) and AUX Cloud API",
6
6
  "main": "index.js",
@@ -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 ↔ canonical (cloud) fan speed conversion
43
- const LOCAL_TO_CLOUD_FAN = { 0: 0, 1: 3, 2: 2, 3: 1, 4: 4 };
44
- const CLOUD_TO_LOCAL_FAN = { 0: 0, 1: 3, 2: 2, 3: 1, 4: 4 };
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 = (config.pollInterval || 30) * 1000;
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
- this.state.power = value === C.Active.ACTIVE;
126
- await this.sendPower(this.state.power);
127
- if (!this.state.power) { this.resetFanToAuto(); this.resetSleep(); }
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
- await this.sendMode(mode);
164
- this.resetFanToAuto(true);
165
- this.resetSleep();
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 step = this.config.tempStep || 0.5;
187
+ const snapTemp = (v) => Math.round(v);
188
+
173
189
  this.heaterCooler.getCharacteristic(C.CoolingThresholdTemperature)
174
- .setProps({ minValue: 16, maxValue: 32, minStep: step })
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
- this.state.targetTemp = value;
178
- await this.sendTemperature(value);
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: step })
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
- this.state.targetTemp = value;
186
- await this.sendTemperature(value);
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
- this.state.power = value === C.Active.ACTIVE;
231
- await this.sendPower(this.state.power);
232
- if (!this.state.power) { this.resetFanToAuto(); this.resetSleep(); }
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
- // ── Fan speed helpers ──────────────────────────────────────────
337
- resetFanToAuto(sendCommand = false) {
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
- if (this.fanService) {
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
- this.state.targetTemp = (params[CLOUD.TEMP_TARGET] ?? 240) / 10;
407
- this.state.currentTemp = (params[CLOUD.TEMP_AMBIENT] ?? 240) / 10;
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 = LOCAL_TO_CLOUD_FAN[s.fanSpeed] ?? FAN_SPEED.AUTO;
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, this.state.targetTemp);
471
- this.heaterCooler.updateCharacteristic(C.HeatingThresholdTemperature, this.state.targetTemp);
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
- () => { const a = this._localApi; a.state.power = on ? 1 : 0; return a.setState(); },
519
- () => this.cloudSet({ [CLOUD.POWER]: on ? 1 : 0 }),
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
- // AUTO is 0 in local protocol (mode=8 would overflow the 3-bit field)
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 = 1;
533
- a.state.mode = localModeMap[mode] ?? 0;
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]: 1, [CLOUD.MODE]: mode }),
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 = CLOUD_TO_LOCAL_FAN[speed] ?? 0;
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: ${JSON.stringify(result)}`);
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: ${JSON.stringify(result)}`);
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: ${JSON.stringify(result)}`);
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
- const cookieRaw = JSON.parse(Buffer.from(device.cookie, 'base64').toString());
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
- throw new Error(`Device param ${act} failed: ${JSON.stringify(result)}`);
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('Found %d cloud device(s)', allDevices.length);
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 (optional)
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
- this.log.info(
215
- 'Hybrid device %s: %s (%s) via %s',
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('Failed to connect to local device at %s', local.ip);
222
+ this.log.error('%s: local connect failed at %s', config.name, local.ip);
243
223
  return null;
244
224
  }
245
- this.log.info('Local device connected: %s', local.ip);
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('Local connection failed: %s', err.message);
228
+ this.log.error('%s: local connect error — %s', config.name, err.message);
249
229
  return null;
250
230
  }
251
231
  }