homebridge-ac-freedom 2.3.4 → 2.3.6

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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.6
4
+
5
+ - **Config UI: feature toggles redesigned** — checkboxes removed; each feature is now a colour-coded pill: bright green when enabled, muted grey when disabled
6
+
7
+ ## 2.3.5
8
+
9
+ - **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`
10
+ - **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
11
+ - **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
12
+ - **Fix stale poll overriding command** — poll is suppressed for 5 s after any successful user command; a failed command no longer blocks state refresh
13
+ - **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
14
+ - **Fix preset switches showing ON when AC is off** — `onGet` and `updateCharacteristics` now gate preset, fan and comfwind values on `state.power`
15
+ - **Fix temperature step** — temperature is snapped in `onSet` and pushed back to HomeKit immediately; corrects slider behaviour even when HomeKit has a cached `minStep`
16
+ - **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
17
+ - **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"
18
+ - **Fix cloud error messages** — cloud API errors now extract `payload.message` instead of dumping the full JSON response
19
+ - **Fix `connection: "cloud"` trying local** — local connection is only attempted when `connection` is explicitly `"hybrid"`
20
+ - **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
21
+ - **Fix crash on command failure** — `_trySend` now correctly references `this.platform.api.hap` instead of the undefined `this.api`
22
+ - **Fix malformed device cookie** — cloud API now throws an actionable error instead of crashing with a JSON parse exception
23
+ - **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
24
+ - **Cleaner startup log** — reduced to one line per device; removed redundant "Restoring / Updating accessory" messages
25
+ - **Code cleanup** — `LOCAL_MODE_MAP` and `FAN_REMAP` extracted as module-level constants; cloud API default params moved to `DEVICE_PARAMS` constant
26
+
3
27
  ## 2.3.4
4
28
 
5
29
  - **UI: badge colors** — Cloud (teal), Hybrid (green), Local (yellow) — clearly distinct from each other
package/README.md CHANGED
@@ -31,13 +31,13 @@ A [Homebridge](https://homebridge.io) plugin for controlling **AUX-based air con
31
31
  - Self Clean
32
32
  - Comfortable Wind
33
33
  - **Display** switch — LED display on/off
34
- - **Auto-reset** — fan and sleep reset to default when AC turns on/off or mode changes
35
- - **Hybrid** — single cloud login at platform level; optional IP + MAC per device for local Broadlink UDP (local preferred, cloud fallback)
36
- - **Local** — Direct Broadlink UDP only, no internet required
34
+ - **Auto-reset** — fan resets to Auto and Sleep turns off when the AC is powered off
35
+ - **Consistent tile order** — Fan is always first, followed by Sleep, Display, Health, Clean, Eco, Comf. Wind
36
+ - **Hybrid** — single cloud login at platform level; optional IP + MAC per device for direct local Broadlink UDP (local preferred, cloud fallback)
37
+ - **Local** — direct Broadlink UDP only, no internet required
37
38
  - **Multi-device** — configure as many ACs as needed
38
39
  - **Custom config UI** — built-in Homebridge UI with Fetch Devices button
39
40
  - Homebridge v1 & v2 compatible
40
- - English UI
41
41
 
42
42
  ---
43
43
 
@@ -196,8 +196,6 @@ Required for Hybrid mode. Configured once, shared by all hybrid devices.
196
196
  | `name` | string | Yes | — | Device name in HomeKit |
197
197
  | `connection` | string | No | `"hybrid"` | `"hybrid"` or `"local"` |
198
198
  | `endpointId` | string | No | — | Cloud device ID (auto-detected if empty) |
199
- | `pollInterval` | integer | No | `30` | Polling interval in seconds (5–300) |
200
- | `tempStep` | number | No | `0.5` | Temperature step: `0.5` or `1` |
201
199
 
202
200
  ### Local Settings (`local` object, per device)
203
201
 
@@ -218,7 +216,7 @@ Required for Hybrid mode. Configured once, shared by all hybrid devices.
218
216
  | `showComfWind` | boolean | `false` | Comfortable Wind switch |
219
217
  | `showDisplay` | boolean | `false` | Display (LED) switch |
220
218
 
221
- > **Tip:** For a clean HomeKit appearance, enable **Fan** and only one preset mode**Sleep** is the recommended choice.
219
+ > **Tip:** For a clean HomeKit appearance, enable only the features you use. **Fan** and **Sleep** are enabled by default most users need nothing else.
222
220
 
223
221
  ---
224
222
 
@@ -226,13 +224,11 @@ Required for Hybrid mode. Configured once, shared by all hybrid devices.
226
224
 
227
225
  Once configured, the AC appears as a **HeaterCooler** tile. Tapping it reveals:
228
226
 
229
- - Temperature control with heating/cooling thresholds
227
+ - Temperature control (1 °C steps, 16–32 °C)
230
228
  - Mode selector — Auto / Heat / Cool
231
229
  - Fan speed slider — 0% Auto · 25% Low · 50% Medium · 75% High · 100% Turbo
232
230
  - Swing toggle
233
- - Preset switches Sleep, Health, Eco, Clean, Comfortable Wind, Display
234
-
235
- All linked services appear as tiles inside the climate card.
231
+ - Linked service tiles in a fixed order: Fan → Sleep → Display → Health Clean Eco → Comf. Wind
236
232
 
237
233
  ---
238
234
 
@@ -246,7 +242,7 @@ All linked services appear as tiles inside the climate card.
246
242
  | Works remotely | Yes | No |
247
243
  | Local fallback | Yes (when IP + MAC configured) | — |
248
244
 
249
- > ¹ **Hybrid + IP/MAC:** if internet is unavailable at startup, the device starts in local-only mode automatically. Full hybrid resumes on the next restart when internet is available.
245
+ > ¹ **Hybrid + IP/MAC:** if internet is unavailable at Homebridge startup, the device starts in local-only mode automatically. Full hybrid resumes on the next restart when internet is available.
250
246
 
251
247
  ---
252
248
 
@@ -258,19 +254,22 @@ All linked services appear as tiles inside the climate card.
258
254
  - Check the `region` matches your account region
259
255
  - Logging into the AC Freedom app may invalidate the plugin session — restart Homebridge after using the app
260
256
 
261
- **Device not found**
257
+ **Device not found after Fetch**
262
258
  - Leave `endpointId` empty for auto-detection (first discovered device is used)
263
259
  - Use the **Fetch** button in the Homebridge UI to discover and auto-fill your device IDs
260
+ - If the device is missing from the cloud list, power cycle the AC and try again
264
261
 
265
- **"Server busy" errors**
266
- - These are transient and automatically suppressed
267
- - If persistent, increase `pollInterval` to 60 or higher
268
-
269
- **AC not responding**
262
+ **AC not responding / "Service Communication Failure" in HomeKit**
270
263
  - Check the AC is powered on and connected to Wi-Fi
271
- - Local mode: Homebridge and AC must be on the same network
264
+ - Local mode: Homebridge and the AC must be on the same network
272
265
  - Try restarting Homebridge
273
266
 
267
+ **"Server busy" errors in logs**
268
+ - These are transient cloud API errors and are automatically suppressed — no action needed
269
+
270
+ **Tile order is wrong in HomeKit**
271
+ - Remove the accessory from the Home app (the individual tile, not the whole bridge), then restart Homebridge — it will re-appear with the correct order
272
+
274
273
  ---
275
274
 
276
275
  ## Support
@@ -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
  ]
@@ -154,17 +154,17 @@
154
154
  .toggle-row { display: flex; gap: 6px; }
155
155
  .toggle-item {
156
156
  flex: 1; min-width: 0;
157
- display: flex; align-items: center; justify-content: center; gap: 5px;
157
+ display: flex; align-items: center; justify-content: center;
158
158
  padding: 7px 4px; border-radius: 7px;
159
159
  border: 1px solid var(--border); background: var(--card-bg);
160
- cursor: pointer; font-size: 11px; font-weight: 500; color: var(--text);
161
- white-space: nowrap; transition: border-color .15s, background .15s;
160
+ cursor: pointer; font-size: 11px; font-weight: 500; color: var(--text-muted);
161
+ white-space: nowrap; transition: border-color .15s, background .15s, color .15s;
162
+ user-select: none;
162
163
  }
163
- .toggle-item:hover { border-color: var(--accent); }
164
- .toggle-item input[type="checkbox"] {
165
- width: 13px; height: 13px; accent-color: var(--accent); flex-shrink: 0; cursor: pointer;
164
+ .toggle-item:not(.on):hover { border-color: #9ca3af; }
165
+ .toggle-item.on {
166
+ background: #16a34a; border-color: #16a34a; color: #fff; font-weight: 600;
166
167
  }
167
- .toggle-item.checked { border-color: var(--accent-border); background: var(--accent-light); }
168
168
 
169
169
  /* ── Fieldset ── */
170
170
  .fieldset {
@@ -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',
@@ -254,54 +253,27 @@
254
253
  function featToggles(d, i) {
255
254
  const presets = d.presets || {};
256
255
  const feats = [
257
- { key: 'showFan', label: t.fan||'Fan', val: bool(d.showFan) },
258
- { key: 'sleep', label: t.sleep||'Sleep', val: bool(presets.sleep), preset: true },
259
- { key: 'showDisplay', label: t.display||'Display', val: bool(d.showDisplay) },
260
- { key: 'health', label: t.health||'Health', val: bool(presets.health), preset: true },
261
- { key: 'clean', label: t.clean||'Clean', val: bool(presets.clean), preset: true },
262
- { key: 'eco', label: t.eco||'Eco', val: bool(presets.eco), preset: true },
263
- { key: 'showComfWind', label: t.comfWind||'Comf. Wind', val: bool(d.showComfWind) },
256
+ { key: 'showFan', label: t.fan||'Fan', val: bool(d.showFan) },
257
+ { key: 'sleep', label: t.sleep||'Sleep', val: bool(presets.sleep), preset: true },
258
+ { key: 'showDisplay', label: t.display||'Display', val: bool(d.showDisplay) },
259
+ { key: 'health', label: t.health||'Health', val: bool(presets.health), preset: true },
260
+ { key: 'clean', label: t.clean||'Clean', val: bool(presets.clean), preset: true },
261
+ { key: 'eco', label: t.eco||'Eco', val: bool(presets.eco), preset: true },
262
+ { key: 'showComfWind', label: t.comfWind||'Comf. Wind', val: bool(d.showComfWind) },
264
263
  ];
264
+
265
265
  return `
266
266
  <div class="toggle-row">
267
267
  ${feats.map(f => `
268
- <label class="toggle-item ${f.val ? 'checked' : ''}">
269
- <input type="checkbox" class="feat-cb"
270
- data-i="${i}" data-k="${f.key}" data-preset="${f.preset||false}"
271
- ${f.val ? 'checked' : ''}>
268
+ <div class="toggle-item ${f.val ? 'on' : ''}"
269
+ data-i="${i}" data-k="${f.key}" data-preset="${f.preset||false}">
272
270
  ${f.label}
273
- </label>
271
+ </div>
274
272
  `).join('')}
275
273
  </div>
276
274
  `;
277
275
  }
278
276
 
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
277
 
306
278
  /* ── Render cloud device card ─────────────────────────────── */
307
279
  function renderCloudDevice(d, i) {
@@ -310,8 +282,6 @@
310
282
  const conn = (d.connection === 'local' || !d.connection) ? 'cloud' : d.connection;
311
283
  const isHybrid = conn === 'hybrid';
312
284
  const local = d.local || {};
313
- const step = d.tempStep !== undefined ? d.tempStep : 0.5;
314
- const poll = d.pollInterval || 30;
315
285
  const cardClass = isHybrid ? 'hybrid-card' : '';
316
286
  const badge = isHybrid ? (t.hybrid||'Hybrid') : (t.cloud||'Cloud');
317
287
 
@@ -371,7 +341,6 @@
371
341
  </div>
372
342
  </div>
373
343
 
374
- ${settingsRow(d, i)}
375
344
  ${featToggles(d, i)}
376
345
  </div>
377
346
  </div>`;
@@ -382,8 +351,6 @@
382
351
  const isOpen = expanded.has(i);
383
352
  const name = d.name || `Local #${li + 1}`;
384
353
  const local = d.local || {};
385
- const step = d.tempStep !== undefined ? d.tempStep : 0.5;
386
- const poll = d.pollInterval || 30;
387
354
 
388
355
  return `
389
356
  <div class="device-card" id="dev-card-${i}">
@@ -421,7 +388,6 @@
421
388
  </div>
422
389
  </div>
423
390
 
424
- ${settingsRow(d, i)}
425
391
  ${featToggles(d, i)}
426
392
  </div>
427
393
  </div>`;
@@ -572,7 +538,6 @@
572
538
  local: { ip: '', mac: '' },
573
539
  presets: { sleep: true, health: false, eco: false, clean: false },
574
540
  showFan: true, showComfWind: false, showDisplay: false,
575
- tempStep: 0.5, pollInterval: 30,
576
541
  });
577
542
  expanded.add(config.devices.length - 1);
578
543
  added++;
@@ -602,7 +567,6 @@
602
567
  local: { ip: '', mac: '' },
603
568
  presets: { sleep: true, health: false, eco: false, clean: false },
604
569
  showFan: true, showComfWind: false, showDisplay: false,
605
- tempStep: 0.5, pollInterval: 30,
606
570
  });
607
571
  expanded.add(i);
608
572
  render(); save();
@@ -634,13 +598,11 @@
634
598
  });
635
599
  });
636
600
 
637
- /* Device-level fields (name, tempStep, pollInterval) */
601
+ /* Device-level fields (name) */
638
602
  document.querySelectorAll('.d-field').forEach(el => {
639
603
  el.addEventListener('change', () => {
640
604
  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;
605
+ config.devices[i][f] = el.value;
644
606
  save();
645
607
  });
646
608
  el.addEventListener('input', () => {
@@ -661,19 +623,18 @@
661
623
  });
662
624
  });
663
625
 
664
- /* Feature checkboxes */
665
- document.querySelectorAll('.feat-cb').forEach(cb => {
666
- cb.closest('.toggle-item').classList.toggle('checked', cb.checked);
667
- cb.addEventListener('change', () => {
668
- const i = +cb.dataset.i, k = cb.dataset.k;
669
- const isPreset = cb.dataset.preset === 'true';
626
+ /* Feature toggles */
627
+ document.querySelectorAll('.toggle-item').forEach(el => {
628
+ el.addEventListener('click', () => {
629
+ const isOn = el.classList.toggle('on');
630
+ const i = +el.dataset.i, k = el.dataset.k;
631
+ const isPreset = el.dataset.preset === 'true';
670
632
  if (isPreset) {
671
633
  if (!config.devices[i].presets) config.devices[i].presets = {};
672
- config.devices[i].presets[k] = cb.checked;
634
+ config.devices[i].presets[k] = isOn;
673
635
  } else {
674
- config.devices[i][k] = cb.checked;
636
+ config.devices[i][k] = isOn;
675
637
  }
676
- cb.closest('.toggle-item').classList.toggle('checked', cb.checked);
677
638
  save();
678
639
  });
679
640
  });
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.6",
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
  }