homebridge-ac-freedom 2.3.0 → 2.3.1

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.1
4
+
5
+ - **Config UI overhaul** — complete redesign of the custom Homebridge UI
6
+ - Cloud credentials (Email, Password, Region, Fetch) all on one row
7
+ - **Cloud Devices** section appears only after a successful Fetch
8
+ - **Local Devices** section with full-width Add Device button
9
+ - Device cards: Name + Connection, IP + MAC, Temperature Step + Poll Interval, and Features all wrapped in consistent fieldset style
10
+ - Connection toggle: **Cloud** / **Hybrid** (Hybrid reveals IP + MAC fields)
11
+ - Features row: Fan · Sleep · Display · Health · Clean · Eco · Comf. Wind (all 7 in one line, no icons)
12
+ - Poll Interval changed from free input to fixed dropdown: 5 / 10 / 20 / 30 / 60 s
13
+ - New device defaults: only **Fan** and **Sleep** enabled out of the box
14
+ - Section headers centered, no underline borders
15
+ - Badge (Cloud / Hybrid / Local) moved to the right of the card header next to Remove
16
+
17
+ > **Upgrading from v2.2?** HomeKit accessories and automations are preserved (UUID is name-based). However, the new UI uses platform-level cloud credentials — open the plugin settings, enter your email/password and press **Fetch** to rediscover your devices.
18
+
3
19
  ## 2.3.0
4
20
 
5
21
  - **New architecture: Hybrid + Local Only connection modes**
@@ -16,7 +16,6 @@
16
16
  --input-bg: #ffffff;
17
17
  --bg: #ffffff;
18
18
  --danger: #dc2626;
19
- --danger-hover: #b91c1c;
20
19
  }
21
20
  @media (prefers-color-scheme: dark) {
22
21
  :root {
@@ -36,7 +35,6 @@
36
35
  --input-bg: rgba(255,255,255,0.08);
37
36
  --bg: transparent;
38
37
  --danger: #f87171;
39
- --danger-hover: #ef4444;
40
38
  }
41
39
  }
42
40
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -46,9 +44,8 @@
46
44
  }
47
45
 
48
46
  /* ── Form elements ── */
49
- .fg { margin-bottom: 14px; }
47
+ .fg { margin-bottom: 12px; }
50
48
  label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 4px; color: var(--text); }
51
- .hint { font-size: 11px; color: var(--text-muted); margin-bottom: 5px; line-height: 1.4; }
52
49
  input[type="text"], input[type="number"], input[type="email"], input[type="password"], select {
53
50
  width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--border);
54
51
  border-radius: 6px; font-size: 13px; background: var(--input-bg);
@@ -63,13 +60,13 @@
63
60
  font-size: 12px; font-weight: 600; transition: opacity .15s, background .15s;
64
61
  }
65
62
  .btn:hover { opacity: .85; }
63
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
66
64
  .btn-primary { background: var(--accent); color: #fff; }
67
- .btn-outline { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
68
- .btn-outline:hover { background: var(--accent); color: #fff; opacity: 1; }
69
- .btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
70
- .btn-danger:hover { background: var(--danger); color: #fff; opacity: 1; }
65
+ .btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
66
+ .btn-danger:hover { background: var(--danger); color: #fff; opacity: 1; }
67
+ .btn-local { background: transparent; color: var(--local); border: 1px solid var(--local); }
68
+ .btn-local:hover { background: var(--local); color: #fff; opacity: 1; }
71
69
  .btn-sm { padding: 4px 10px; font-size: 11px; }
72
- .btn:disabled { opacity: .5; cursor: not-allowed; }
73
70
 
74
71
  /* ── Cloud credentials card ── */
75
72
  .creds-card {
@@ -81,141 +78,142 @@
81
78
  font-size: 13px; font-weight: 700; color: var(--accent);
82
79
  border-bottom: 1px solid var(--accent-border);
83
80
  }
84
- .creds-body { padding: 16px 18px 18px; }
81
+ .creds-body { padding: 14px 18px 16px; }
82
+ .fetch-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
83
+ .fetch-status { font-size: 12px; font-weight: 500; color: var(--text-muted); }
84
+ .fetch-status.ok { color: var(--local); }
85
+ .fetch-status.err { color: var(--danger); }
85
86
 
86
- /* ── Fetch row ── */
87
- .fetch-row {
88
- display: flex; align-items: center; gap: 10px; margin-top: 14px;
89
- flex-wrap: wrap;
90
- }
91
- .fetch-status {
92
- font-size: 12px; font-weight: 500; color: var(--text-muted);
87
+ /* ── Section header ── */
88
+ .section-hd {
89
+ font-size: 12px; font-weight: 700; text-transform: uppercase;
90
+ letter-spacing: .8px; color: var(--text-muted);
91
+ margin: 16px 0 10px; padding-bottom: 6px;
92
+ border-bottom: 1px solid var(--border);
93
93
  }
94
- .fetch-status.ok { color: var(--local); }
95
- .fetch-status.err { color: var(--danger); }
94
+ .section-hd.local-hd { color: var(--local); border-bottom-color: var(--local-border); }
95
+
96
+ /* ── Grid layouts ── */
97
+ .row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
98
+ .row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
96
99
 
97
100
  /* ── Device card ── */
98
101
  .device-card {
99
102
  border: 1.5px solid var(--accent-border); border-radius: 12px;
100
- margin-bottom: 16px; background: var(--accent-light); overflow: hidden;
103
+ margin-bottom: 12px; background: var(--accent-light); overflow: hidden;
101
104
  }
102
105
  .device-card.hybrid-card { border-color: var(--hybrid-border); background: var(--hybrid-light); }
103
- .device-card.local-card { border-color: var(--local-border); background: var(--local-light); }
106
+ .device-card.local-card { border-color: var(--local-border); background: var(--local-light); }
104
107
 
105
108
  .device-header {
106
109
  display: flex; justify-content: space-between; align-items: center;
107
- padding: 13px 18px; cursor: pointer; user-select: none;
110
+ padding: 11px 16px; cursor: pointer; user-select: none;
108
111
  }
109
112
  .device-header:hover { opacity: .88; }
110
- .device-header-left { display: flex; align-items: center; gap: 9px; flex: 1; min-width: 0; }
113
+ .device-header-left { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
111
114
  .chevron { font-size: 11px; color: var(--text-muted); transition: transform .2s; flex-shrink: 0; }
112
115
  .chevron.open { transform: rotate(90deg); }
113
- .dev-name { font-size: 15px; font-weight: 700; color: var(--accent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
116
+ .dev-name { font-size: 14px; font-weight: 700; color: var(--accent);
117
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
114
118
  .device-card.hybrid-card .dev-name { color: var(--hybrid); }
115
119
  .device-card.local-card .dev-name { color: var(--local); }
116
120
 
117
121
  .conn-badge {
118
122
  font-size: 10px; font-weight: 700; letter-spacing: .5px;
119
- padding: 2px 8px; border-radius: 20px; flex-shrink: 0; text-transform: uppercase;
123
+ padding: 2px 7px; border-radius: 20px; flex-shrink: 0; text-transform: uppercase;
120
124
  }
125
+ .conn-badge.cloud { background: var(--accent-light); color: var(--accent); border: 1px solid var(--accent-border); }
121
126
  .conn-badge.hybrid { background: var(--hybrid-light); color: var(--hybrid); border: 1px solid var(--hybrid-border); }
122
- .conn-badge.local { background: var(--local-light); color: var(--local); border: 1px solid var(--local-border); }
127
+ .conn-badge.local { background: var(--local-light); color: var(--local); border: 1px solid var(--local-border); }
123
128
  .dev-meta { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
124
129
 
125
- .device-body { padding: 16px 18px 20px; border-top: 1px solid var(--accent-border); }
130
+ .device-body {
131
+ padding: 14px 16px 18px;
132
+ border-top: 1px solid var(--accent-border);
133
+ }
126
134
  .device-card.hybrid-card .device-body { border-color: var(--hybrid-border); }
127
- .device-card.local-card .device-body { border-color: var(--local-border); }
135
+ .device-card.local-card .device-body { border-color: var(--local-border); }
128
136
  .device-body.collapsed { display: none; }
129
137
 
130
- /* ── Grid layouts ── */
131
- .row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
132
- .row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
133
-
134
- /* ── Section divider ── */
135
- .section-hd {
136
- font-size: 12px; font-weight: 700; text-transform: uppercase;
137
- letter-spacing: .8px; color: var(--text-muted);
138
- margin: 18px 0 10px; padding-bottom: 6px;
139
- border-bottom: 1px solid var(--border);
140
- }
141
-
142
138
  /* ── Connection toggle (segmented) ── */
143
139
  .conn-toggle {
144
140
  display: flex; border: 1px solid var(--border); border-radius: 8px;
145
141
  overflow: hidden; height: 36px;
146
142
  }
147
143
  .conn-btn {
148
- flex: 1; padding: 0 12px; border: none;
149
- background: transparent; cursor: pointer;
144
+ flex: 1; padding: 0 12px; border: none; background: transparent; cursor: pointer;
150
145
  font-size: 13px; font-weight: 600; color: var(--text-muted);
151
- display: flex; align-items: center; justify-content: center; gap: 6px;
146
+ display: flex; align-items: center; justify-content: center; gap: 5px;
152
147
  transition: background .15s, color .15s;
153
148
  }
154
149
  .conn-btn + .conn-btn { border-left: 1px solid var(--border); }
155
- .conn-btn .icon { font-size: 14px; line-height: 1; }
150
+ .conn-btn.active-cloud { background: var(--accent-light); color: var(--accent); }
156
151
  .conn-btn.active-hybrid { background: var(--hybrid-light); color: var(--hybrid); }
157
- .conn-btn.active-local { background: var(--local-light); color: var(--local); }
158
-
159
- /* ── Fieldset ── */
160
- .fieldset {
161
- border: 1px solid var(--border); border-radius: 8px;
162
- padding: 12px 14px; margin-top: 4px;
163
- }
164
152
 
165
- /* ── Toggle grid (checkboxes) ── */
166
- .toggle-rows { display: flex; flex-direction: column; gap: 8px; }
167
- .toggle-row { display: flex; gap: 8px; }
153
+ /* ── Features toggle row ── */
154
+ .toggle-row { display: flex; gap: 6px; }
168
155
  .toggle-item {
169
- flex: 1;
170
- display: flex; align-items: center; justify-content: center; gap: 7px;
171
- padding: 8px 10px; border-radius: 8px;
156
+ flex: 1; min-width: 0;
157
+ display: flex; align-items: center; justify-content: center; gap: 5px;
158
+ padding: 7px 4px; border-radius: 7px;
172
159
  border: 1px solid var(--border); background: var(--card-bg);
173
- cursor: pointer; font-size: 12px; font-weight: 500; color: var(--text);
174
- transition: border-color .15s, background .15s;
160
+ cursor: pointer; font-size: 11px; font-weight: 500; color: var(--text);
161
+ white-space: nowrap; transition: border-color .15s, background .15s;
175
162
  }
176
163
  .toggle-item:hover { border-color: var(--accent); }
177
164
  .toggle-item input[type="checkbox"] {
178
- width: 15px; height: 15px; accent-color: var(--accent); flex-shrink: 0; cursor: pointer;
165
+ width: 13px; height: 13px; accent-color: var(--accent); flex-shrink: 0; cursor: pointer;
179
166
  }
180
167
  .toggle-item.checked { border-color: var(--accent-border); background: var(--accent-light); }
181
168
 
182
- /* ── Actions row ── */
183
- .actions { margin-top: 18px; display: flex; gap: 10px; }
169
+ /* ── Fieldset ── */
170
+ .fieldset {
171
+ border: 1px solid var(--border); border-radius: 8px;
172
+ padding: 12px 12px 10px;
173
+ }
174
+
175
+ /* ── Actions ── */
176
+ .actions { margin-top: 14px; }
184
177
  </style>
185
178
 
186
179
  <div id="app"></div>
187
180
 
188
181
  <script>
189
182
  (async () => {
190
- /* ── Translations ──────────────────────────────────────────── */
183
+ /* ── Error safety net ─────────────────────────────────────── */
184
+ window.addEventListener('error', ev => {
185
+ const app = document.getElementById('app');
186
+ if (app && !app.innerHTML.trim()) {
187
+ app.innerHTML = `<div style="color:#dc2626;padding:16px;font-size:12px">
188
+ UI error: ${ev.message}<br>Check browser console.
189
+ </div>`;
190
+ }
191
+ });
192
+
193
+ /* ── i18n ─────────────────────────────────────────────────── */
191
194
  let t = {};
192
195
  async function loadTranslations() {
193
196
  let lang = 'en';
194
197
  try { lang = await homebridge.i18nCurrentLang(); } catch {}
195
- const tryLangs = [lang, lang.split('-')[0], 'en'];
196
- for (const l of tryLangs) {
198
+ for (const l of [lang, lang.split('-')[0], 'en']) {
197
199
  try {
198
- const res = await fetch(`./i18n/${l}.json`);
199
- if (res.ok) { t = await res.json(); return; }
200
+ const r = await fetch(`./i18n/${l}.json`);
201
+ if (r.ok) { t = await r.json(); return; }
200
202
  } catch {}
201
203
  }
202
- try { const res = await fetch('./i18n/en.json'); t = await res.json(); } catch {}
203
204
  }
204
205
  await loadTranslations();
205
206
 
206
- /* ── Config ────────────────────────────────────────────────── */
207
+ /* ── Config ───────────────────────────────────────────────── */
207
208
  let config = {};
208
- try {
209
- const cfgs = await homebridge.getPluginConfig();
210
- config = cfgs[0] || {};
211
- } catch {}
209
+ try { const c = await homebridge.getPluginConfig(); config = c[0] || {}; } catch {}
212
210
  if (!config.devices) config.devices = [];
213
211
  if (!config.cloud) config.cloud = {};
214
212
 
215
213
  const expanded = new Set();
216
214
  const app = document.getElementById('app');
217
215
 
218
- /* ── Helpers ───────────────────────────────────────────────── */
216
+ /* ── Helpers ──────────────────────────────────────────────── */
219
217
  function esc(s) {
220
218
  const d = document.createElement('div');
221
219
  d.textContent = String(s ?? '');
@@ -224,76 +222,69 @@
224
222
  function bool(v, def = true) { return v === undefined ? def : !!v; }
225
223
  async function save() { await homebridge.updatePluginConfig([config]); }
226
224
 
227
- /* ── Render ────────────────────────────────────────────────── */
228
- function render() {
229
- app.innerHTML = `
230
- <!-- Cloud credentials card -->
231
- <div class="creds-card">
232
- <div class="creds-header">☁️ ${t.cloudCredentials||'Cloud Credentials'}</div>
233
- <div class="creds-body">
234
- <div class="hint">${t.emailHint||'AC Freedom app account email'} · ${t.passwordHint||'AC Freedom app password'}</div>
235
- <div class="row-3" style="margin-top:8px">
236
- <div class="fg" style="margin-bottom:0">
237
- <label>${t.email||'Email'}</label>
238
- <input type="email" id="cloud-email" value="${esc(config.cloud.email||'')}" placeholder="user@example.com">
239
- </div>
240
- <div class="fg" style="margin-bottom:0">
241
- <label>${t.password||'Password'}</label>
242
- <input type="password" id="cloud-password" value="${esc(config.cloud.password||'')}">
243
- </div>
244
- <div class="fg" style="margin-bottom:0">
245
- <label>${t.region||'Region'}</label>
246
- <select id="cloud-region">
247
- ${['eu','usa','cn','rus'].map(r =>
248
- `<option value="${r}" ${(config.cloud.region||'eu')===r?'selected':''}>${
249
- {eu:t.regionEu||'Europe',usa:t.regionUsa||'USA',cn:t.regionCn||'China',rus:t.regionRus||'Russia'}[r]
250
- }</option>`
251
- ).join('')}
252
- </select>
253
- </div>
225
+ function featToggles(d, i) {
226
+ const presets = d.presets || {};
227
+ const feats = [
228
+ { key: 'showFan', label: t.fan||'Fan', val: bool(d.showFan) },
229
+ { key: 'sleep', label: t.sleep||'Sleep', val: bool(presets.sleep), preset: true },
230
+ { key: 'showDisplay', label: t.display||'Display', val: bool(d.showDisplay) },
231
+ { key: 'health', label: t.health||'Health', val: bool(presets.health), preset: true },
232
+ { key: 'clean', label: t.clean||'Clean', val: bool(presets.clean), preset: true },
233
+ { key: 'eco', label: t.eco||'Eco', val: bool(presets.eco), preset: true },
234
+ { key: 'showComfWind', label: t.comfWind||'Comf. Wind', val: bool(d.showComfWind) },
235
+ ];
236
+ return `
237
+ <div class="toggle-row">
238
+ ${feats.map(f => `
239
+ <label class="toggle-item ${f.val ? 'checked' : ''}">
240
+ <input type="checkbox" class="feat-cb"
241
+ data-i="${i}" data-k="${f.key}" data-preset="${f.preset||false}"
242
+ ${f.val ? 'checked' : ''}>
243
+ ${f.label}
244
+ </label>
245
+ `).join('')}
246
+ </div>
247
+ `;
248
+ }
249
+
250
+ function settingsRow(d, i) {
251
+ const step = d.tempStep !== undefined ? d.tempStep : 0.5;
252
+ const poll = d.pollInterval || 30;
253
+ return `
254
+ <div class="fieldset" style="margin-bottom:12px">
255
+ <div class="row-2">
256
+ <div class="fg" style="margin-bottom:0">
257
+ <label>${t.tempStep||'Temperature Step'}</label>
258
+ <select class="d-field" data-i="${i}" data-f="tempStep">
259
+ <option value="0.5" ${step==0.5?'selected':''}>0.5 °C</option>
260
+ <option value="1" ${step==1 ?'selected':''}>1.0 °C</option>
261
+ </select>
254
262
  </div>
255
- <div class="fetch-row">
256
- <button class="btn btn-primary" id="btn-fetch">🔍 ${t.fetchDevices||'Fetch Devices'}</button>
257
- <span class="fetch-status" id="fetch-status"></span>
263
+ <div class="fg" style="margin-bottom:0">
264
+ <label>${t.pollInterval||'Poll Interval'}</label>
265
+ <select class="d-field" data-i="${i}" data-f="pollInterval">
266
+ <option value="5" ${poll==5 ?'selected':''}>5 s</option>
267
+ <option value="10" ${poll==10?'selected':''}>10 s</option>
268
+ <option value="20" ${poll==20?'selected':''}>20 s</option>
269
+ <option value="30" ${poll==30?'selected':''}>30 s</option>
270
+ <option value="60" ${poll==60?'selected':''}>60 s</option>
271
+ </select>
258
272
  </div>
259
273
  </div>
260
- </div>
261
-
262
- <!-- Devices -->
263
- <div class="section-hd" style="margin-top:4px">${t.devices||'Devices'}</div>
264
- <div id="device-list">
265
- ${config.devices.map((d, i) => renderDevice(d, i)).join('')}
266
- </div>
267
- <div class="actions">
268
- <button class="btn btn-outline" id="btn-add">+ ${t.addDevice||'Add Device'}</button>
269
- </div>
270
- `;
271
- bindEvents();
274
+ </div>`;
272
275
  }
273
276
 
274
- function renderDevice(d, i) {
275
- const isOpen = expanded.has(i);
276
- const name = d.name || `Device #${i + 1}`;
277
- const conn = d.connection === 'cloud' ? 'hybrid' : (d.connection || 'hybrid');
278
- const isHybrid = conn === 'hybrid';
279
- const local = d.local || {};
280
- const presets = d.presets || {};
281
- const poll = d.pollInterval || 30;
282
- const step = d.tempStep !== undefined ? d.tempStep : 0.5;
283
- const cardClass= isHybrid ? 'hybrid-card' : 'local-card';
284
- const badgeLabel = isHybrid ? (t.hybrid||'Hybrid') : (t.localOnly||'Local Only');
285
-
286
- const featRow1 = [
287
- { key: 'showFan', label: `🌀 ${t.fan||'Fan'}`, val: bool(d.showFan) },
288
- { key: 'sleep', label: `😴 ${t.sleep||'Sleep'}`, val: bool(presets.sleep), preset: true },
289
- { key: 'showDisplay', label: `💡 ${t.display||'Display'}`, val: bool(d.showDisplay) },
290
- ];
291
- const featRow2 = [
292
- { key: 'health', label: `🌿 ${t.health||'Health'}`, val: bool(presets.health), preset: true },
293
- { key: 'clean', label: `✨ ${t.clean||'Clean'}`, val: bool(presets.clean), preset: true },
294
- { key: 'eco', label: `🍃 ${t.eco||'Eco'}`, val: bool(presets.eco), preset: true },
295
- { key: 'showComfWind',label: `💨 ${t.comfWind||'Comf. Wind'}`, val: bool(d.showComfWind) },
296
- ];
277
+ /* ── Render cloud device card ─────────────────────────────── */
278
+ function renderCloudDevice(d, i) {
279
+ const isOpen = expanded.has(i);
280
+ const name = d.name || `Device #${i + 1}`;
281
+ const conn = (d.connection === 'local' || !d.connection) ? 'cloud' : d.connection;
282
+ const isHybrid = conn === 'hybrid';
283
+ const local = d.local || {};
284
+ const step = d.tempStep !== undefined ? d.tempStep : 0.5;
285
+ const poll = d.pollInterval || 30;
286
+ const cardClass = isHybrid ? 'hybrid-card' : '';
287
+ const badge = isHybrid ? (t.hybrid||'Hybrid') : (t.cloud||'Cloud');
297
288
 
298
289
  return `
299
290
  <div class="device-card ${cardClass}" id="dev-card-${i}">
@@ -301,126 +292,184 @@
301
292
  <div class="device-header-left">
302
293
  <span class="chevron ${isOpen ? 'open' : ''}">&#9654;</span>
303
294
  <span class="dev-name">${esc(name)}</span>
304
- <span class="conn-badge ${conn}">${badgeLabel}</span>
305
- ${!isOpen ? `<span class="dev-meta">${poll}s · ${step}°C</span>` : ''}
306
295
  </div>
307
- <div style="flex-shrink:0; margin-left:8px">
296
+ <div style="display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:8px">
297
+ <span class="conn-badge ${conn}">${badge}</span>
308
298
  <button class="btn btn-danger btn-sm btn-remove" data-i="${i}">${t.remove||'Remove'}</button>
309
299
  </div>
310
300
  </div>
311
301
 
312
302
  <div class="device-body ${isOpen ? '' : 'collapsed'}">
313
-
314
303
  <!-- Name + Connection -->
315
- <div class="row-2">
316
- <div class="fg">
317
- <label>${t.deviceName||'Device Name'}</label>
318
- <input type="text" class="d-field" data-i="${i}" data-f="name" value="${esc(d.name||'')}">
319
- </div>
320
- <div class="fg">
321
- <label>${t.connection||'Connection'}</label>
322
- <div class="conn-toggle">
323
- <button class="conn-btn ${isHybrid ? 'active-hybrid' : ''} conn-pick" data-i="${i}" data-v="hybrid">
324
- <span class="icon">🔀</span>${t.hybrid||'Hybrid'}
325
- </button>
326
- <button class="conn-btn ${!isHybrid ? 'active-local' : ''} conn-pick" data-i="${i}" data-v="local">
327
- <span class="icon">📡</span>${t.localOnly||'Local Only'}
328
- </button>
304
+ <div class="fieldset" style="margin-bottom:12px">
305
+ <div class="row-2">
306
+ <div class="fg" style="margin-bottom:0">
307
+ <label>${t.deviceName||'Device Name'}</label>
308
+ <input type="text" class="d-field" data-i="${i}" data-f="name"
309
+ value="${esc(d.name||'')}">
310
+ </div>
311
+ <div class="fg" style="margin-bottom:0">
312
+ <label>${t.connection||'Connection'}</label>
313
+ <div class="conn-toggle">
314
+ <button class="conn-btn ${!isHybrid ? 'active-cloud' : ''} conn-pick"
315
+ data-i="${i}" data-v="cloud">
316
+ ☁️ ${t.cloud||'Cloud'}
317
+ </button>
318
+ <button class="conn-btn ${isHybrid ? 'active-hybrid' : ''} conn-pick"
319
+ data-i="${i}" data-v="hybrid">
320
+ 🔀 ${t.hybrid||'Hybrid'}
321
+ </button>
322
+ </div>
329
323
  </div>
330
324
  </div>
331
325
  </div>
332
326
 
333
- <!-- Hybrid: endpoint ID + optional local override -->
327
+ <!-- IP + MAC (Hybrid only) -->
334
328
  <div id="hybrid-sec-${i}" style="${isHybrid ? '' : 'display:none'}">
335
- <div class="section-hd">🔀 ${t.hybrid||'Hybrid'}</div>
336
- <div class="fieldset">
337
- <div class="fg">
338
- <label>${t.endpointId||'Device ID'} <span style="font-weight:400;color:var(--text-muted)">(${t.deviceIdOptional||'optional'})</span></label>
339
- <div class="hint">${t.endpointIdHint||'Auto-filled via Fetch Devices. Leave empty to use the first discovered device.'}</div>
340
- <input type="text" class="h-field" data-i="${i}" data-f="endpointId" value="${esc(d.endpointId||'')}" placeholder="auto-detect">
341
- </div>
342
- <div class="section-hd" style="margin-top:4px">📡 ${t.localOverride||'Local Override (optional)'}</div>
343
- <div class="hint" style="margin-bottom:10px">${t.localOverrideHint||'Add IP + MAC for direct local connection. Local is preferred over cloud when available.'}</div>
329
+ <div class="fieldset" style="margin-bottom:12px">
344
330
  <div class="row-2">
345
331
  <div class="fg" style="margin-bottom:0">
346
332
  <label>${t.ip||'IP Address'}</label>
347
- <div class="hint">${t.ipHint||"AC unit's local IP (e.g. 192.168.1.100)"}</div>
348
- <input type="text" class="l-field" data-i="${i}" data-f="ip" value="${esc(local.ip||'')}" placeholder="192.168.1.100">
333
+ <input type="text" class="l-field" data-i="${i}" data-f="ip"
334
+ value="${esc(local.ip||'')}" placeholder="192.168.1.100">
349
335
  </div>
350
336
  <div class="fg" style="margin-bottom:0">
351
337
  <label>${t.mac||'MAC Address'}</label>
352
- <div class="hint">${t.macHint||'Format: AA:BB:CC:DD:EE:FF'}</div>
353
- <input type="text" class="l-field" data-i="${i}" data-f="mac" value="${esc(local.mac||'')}" placeholder="AA:BB:CC:DD:EE:FF">
338
+ <input type="text" class="l-field" data-i="${i}" data-f="mac"
339
+ value="${esc(local.mac||'')}" placeholder="AA:BB:CC:DD:EE:FF">
354
340
  </div>
355
341
  </div>
356
342
  </div>
357
343
  </div>
358
344
 
359
- <!-- Local Only: required IP + MAC -->
360
- <div id="local-sec-${i}" style="${!isHybrid ? '' : 'display:none'}">
361
- <div class="section-hd">📡 ${t.localSettings||'Local Settings'}</div>
362
- <div class="fieldset">
363
- <div class="row-2">
364
- <div class="fg" style="margin-bottom:0">
365
- <label>${t.ip||'IP Address'}</label>
366
- <div class="hint">${t.ipHint||"AC unit's local IP (e.g. 192.168.1.100)"}</div>
367
- <input type="text" class="l-field" data-i="${i}" data-f="ip" value="${esc(local.ip||'')}" placeholder="192.168.1.100">
368
- </div>
369
- <div class="fg" style="margin-bottom:0">
370
- <label>${t.mac||'MAC Address'}</label>
371
- <div class="hint">${t.macHint||'Format: AA:BB:CC:DD:EE:FF'}</div>
372
- <input type="text" class="l-field" data-i="${i}" data-f="mac" value="${esc(local.mac||'')}" placeholder="AA:BB:CC:DD:EE:FF">
373
- </div>
374
- </div>
375
- </div>
345
+ ${settingsRow(d, i)}
346
+ ${featToggles(d, i)}
347
+ </div>
348
+ </div>`;
349
+ }
350
+
351
+ /* ── Render local-only device card ───────────────────────── */
352
+ function renderLocalDevice(d, i) {
353
+ const isOpen = expanded.has(i);
354
+ const name = d.name || `Local #${i + 1}`;
355
+ const local = d.local || {};
356
+ const step = d.tempStep !== undefined ? d.tempStep : 0.5;
357
+ const poll = d.pollInterval || 30;
358
+
359
+ return `
360
+ <div class="device-card" id="dev-card-${i}">
361
+ <div class="device-header device-toggle" data-i="${i}">
362
+ <div class="device-header-left">
363
+ <span class="chevron ${isOpen ? 'open' : ''}">&#9654;</span>
364
+ <span class="dev-name">${esc(name)}</span>
365
+ </div>
366
+ <div style="display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:8px">
367
+ <span class="conn-badge cloud">${t.local||'Local'}</span>
368
+ <button class="btn btn-danger btn-sm btn-remove" data-i="${i}">${t.remove||'Remove'}</button>
376
369
  </div>
370
+ </div>
377
371
 
378
- <!-- Features -->
379
- <div class="section-hd">${t.features||'Features'}</div>
380
- <div class="hint" style="margin-bottom:8px">💡 ${t.featuresHint||'For a clean HomeKit appearance, enabling only Fan and Sleep is recommended.'}</div>
381
- <div class="toggle-rows">
382
- <div class="toggle-row">
383
- ${featRow1.map(f => `
384
- <label class="toggle-item ${f.val ? 'checked' : ''}">
385
- <input type="checkbox" class="feat-cb" data-i="${i}" data-k="${f.key}" data-preset="${f.preset||false}" ${f.val ? 'checked' : ''}>
386
- ${f.label}
387
- </label>
388
- `).join('')}
372
+ <div class="device-body ${isOpen ? '' : 'collapsed'}">
373
+ <div class="fieldset" style="margin-bottom:12px">
374
+ <div class="fg" style="margin-bottom:0">
375
+ <label>${t.deviceName||'Device Name'}</label>
376
+ <input type="text" class="d-field" data-i="${i}" data-f="name"
377
+ value="${esc(d.name||'')}">
389
378
  </div>
390
- <div class="toggle-row">
391
- ${featRow2.map(f => `
392
- <label class="toggle-item ${f.val ? 'checked' : ''}">
393
- <input type="checkbox" class="feat-cb" data-i="${i}" data-k="${f.key}" data-preset="${f.preset||false}" ${f.val ? 'checked' : ''}>
394
- ${f.label}
395
- </label>
396
- `).join('')}
379
+ </div>
380
+ <div class="fieldset" style="margin-bottom:12px">
381
+ <div class="row-2">
382
+ <div class="fg" style="margin-bottom:0">
383
+ <label>${t.ip||'IP Address'}</label>
384
+ <input type="text" class="l-field" data-i="${i}" data-f="ip"
385
+ value="${esc(local.ip||'')}" placeholder="192.168.1.100">
386
+ </div>
387
+ <div class="fg" style="margin-bottom:0">
388
+ <label>${t.mac||'MAC Address'}</label>
389
+ <input type="text" class="l-field" data-i="${i}" data-f="mac"
390
+ value="${esc(local.mac||'')}" placeholder="AA:BB:CC:DD:EE:FF">
391
+ </div>
397
392
  </div>
398
393
  </div>
399
394
 
400
- <!-- Settings -->
401
- <div class="section-hd">${t.settings||'Settings'}</div>
402
- <div class="row-2">
395
+ ${settingsRow(d, i)}
396
+ ${featToggles(d, i)}
397
+ </div>
398
+ </div>`;
399
+ }
400
+
401
+ /* ── Main render ──────────────────────────────────────────── */
402
+ function render() {
403
+ const cloudDevs = config.devices
404
+ .map((d, i) => ({ d, i }))
405
+ .filter(({ d }) => d.connection !== 'local');
406
+ const localDevs = config.devices
407
+ .map((d, i) => ({ d, i }))
408
+ .filter(({ d }) => d.connection === 'local');
409
+
410
+ app.innerHTML = `
411
+ <!-- Cloud Credentials -->
412
+ <div class="creds-card">
413
+ <div class="creds-header">☁️ ${t.cloudCredentials||'Cloud Credentials'}</div>
414
+ <div class="creds-body">
415
+ <div style="display:grid;grid-template-columns:1fr 1fr 140px 100px;gap:8px;align-items:flex-end">
403
416
  <div class="fg" style="margin-bottom:0">
404
- <label>${t.tempStep||'Temperature Step'}</label>
405
- <div class="hint">${t.tempStepHint||'Minimum temperature adjustment increment'}</div>
406
- <select class="d-field" data-i="${i}" data-f="tempStep">
407
- <option value="0.5" ${step==0.5?'selected':''}>0.5 °C</option>
408
- <option value="1" ${step==1 ?'selected':''}>1.0 °C</option>
417
+ <label>${t.email||'Email'}</label>
418
+ <input type="email" id="cloud-email"
419
+ value="${esc(config.cloud.email||'')}" placeholder="user@example.com">
420
+ </div>
421
+ <div class="fg" style="margin-bottom:0">
422
+ <label>${t.password||'Password'}</label>
423
+ <input type="password" id="cloud-password"
424
+ value="${esc(config.cloud.password||'')}">
425
+ </div>
426
+ <div class="fg" style="margin-bottom:0">
427
+ <label>${t.region||'Region'}</label>
428
+ <select id="cloud-region">
429
+ ${['eu','usa','cn','rus'].map(r =>
430
+ `<option value="${r}" ${(config.cloud.region||'eu')===r?'selected':''}>
431
+ ${{eu:'Europe',usa:'USA',cn:'China',rus:'Russia'}[r]}
432
+ </option>`
433
+ ).join('')}
409
434
  </select>
410
435
  </div>
411
436
  <div class="fg" style="margin-bottom:0">
412
- <label>${t.pollInterval||'Poll Interval (seconds)'}</label>
413
- <div class="hint">${t.pollIntervalHint||'How often to fetch the AC state (5–300)'}</div>
414
- <input type="number" class="d-field" data-i="${i}" data-f="pollInterval" min="5" max="300" value="${poll}">
437
+ <button class="btn btn-primary" id="btn-fetch" style="height:36px;width:100%;white-space:nowrap">
438
+ 🔍 ${t.fetch||'Fetch'}
439
+ </button>
415
440
  </div>
416
441
  </div>
417
-
442
+ <span class="fetch-status" id="fetch-status" style="display:block;margin-top:6px"></span>
418
443
  </div>
419
444
  </div>
445
+
446
+ <!-- Cloud Devices -->
447
+ ${cloudDevs.length ? `
448
+ <div class="section-hd" style="margin-top:8px;text-align:center;border-bottom:none;padding-bottom:0">
449
+ ${t.cloudDevices||'Cloud Devices'}
450
+ </div>` : ''}
451
+ <div id="cloud-device-list">
452
+ ${cloudDevs.map(({ d, i }) => renderCloudDevice(d, i)).join('')}
453
+ </div>
454
+
455
+ <!-- Local Devices -->
456
+ <div class="section-hd" style="margin-top:20px;text-align:center;border-bottom:none;padding-bottom:0">
457
+ ${t.localDevices||'Local Devices'}
458
+ </div>
459
+ <div id="local-device-list">
460
+ ${localDevs.map(({ d, i }) => renderLocalDevice(d, i)).join('')}
461
+ </div>
462
+ <div class="fieldset" style="margin-top:10px">
463
+ <button class="btn btn-primary" id="btn-add-local"
464
+ style="width:100%;display:flex;align-items:center;justify-content:center;gap:6px;height:36px">
465
+ + ${t.addDevice||'Add Device'}
466
+ </button>
467
+ </div>
420
468
  `;
469
+ bindEvents();
421
470
  }
422
471
 
423
- /* ── Event binding ─────────────────────────────────────────── */
472
+ /* ── Event binding ────────────────────────────────────────── */
424
473
  function bindEvents() {
425
474
  /* Collapse toggle */
426
475
  document.querySelectorAll('.device-toggle').forEach(el => {
@@ -432,7 +481,7 @@
432
481
  });
433
482
  });
434
483
 
435
- /* Cloud credential fields */
484
+ /* Cloud credentials */
436
485
  document.getElementById('cloud-email').addEventListener('change', e => {
437
486
  config.cloud.email = e.target.value; save();
438
487
  });
@@ -443,7 +492,7 @@
443
492
  config.cloud.region = e.target.value; save();
444
493
  });
445
494
 
446
- /* Fetch Devices button */
495
+ /* Fetch Devices */
447
496
  document.getElementById('btn-fetch').addEventListener('click', async () => {
448
497
  const btn = document.getElementById('btn-fetch');
449
498
  const status = document.getElementById('fetch-status');
@@ -453,7 +502,7 @@
453
502
 
454
503
  if (!email || !password) {
455
504
  status.className = 'fetch-status err';
456
- status.textContent = '⚠️ ' + (t.emailHint||'Enter email and password first');
505
+ status.textContent = '⚠️ Enter email and password first';
457
506
  return;
458
507
  }
459
508
 
@@ -463,28 +512,27 @@
463
512
  status.textContent = '';
464
513
 
465
514
  try {
466
- const res = await homebridge.request('/fetchDevices', { email, password, region });
515
+ const res = await homebridge.request('/fetchDevices', { email, password, region });
467
516
  const fetched = res.devices || [];
468
517
 
469
- // Save credentials
470
518
  config.cloud.email = email;
471
519
  config.cloud.password = password;
472
520
  config.cloud.region = region;
473
521
 
474
- // Merge fetched devices into config:
475
- // - new devices get added as hybrid
476
- // - existing devices (matched by endpointId) are kept as-is
477
- const existingIds = new Set(config.devices.map(d => d.endpointId).filter(Boolean));
522
+ // Add new devices (matched by endpointId); preserve existing
523
+ const existingIds = new Set(
524
+ config.devices.map(d => d.endpointId).filter(Boolean)
525
+ );
478
526
  let added = 0;
479
527
  for (const fd of fetched) {
480
528
  if (!existingIds.has(fd.endpointId)) {
481
529
  config.devices.push({
482
- name: fd.name,
483
- connection: 'hybrid',
530
+ name: fd.name,
531
+ connection: 'cloud',
484
532
  endpointId: fd.endpointId,
485
- local: { ip: '', mac: '' },
486
- presets: { sleep: true, health: true, eco: true, clean: true },
487
- showFan: true, showComfWind: true, showDisplay: true,
533
+ local: { ip: '', mac: '' },
534
+ presets: { sleep: true, health: false, eco: false, clean: false },
535
+ showFan: true, showComfWind: false, showDisplay: false,
488
536
  tempStep: 0.5, pollInterval: 30,
489
537
  });
490
538
  expanded.add(config.devices.length - 1);
@@ -493,8 +541,9 @@
493
541
  }
494
542
 
495
543
  status.className = 'fetch-status ok';
496
- status.textContent = `✓ ${fetched.length} ${t.devicesFound||'devices found'}` +
497
- (added ? ` (+${added} ${t.addDevice||'added'})` : '');
544
+ status.textContent =
545
+ `✓ ${fetched.length} ${t.devicesFound||'devices found'}` +
546
+ (added ? ` (+${added})` : '');
498
547
  save();
499
548
  render();
500
549
  } catch (err) {
@@ -502,19 +551,18 @@
502
551
  status.textContent = `✗ ${t.fetchFailed||'Fetch failed'}: ${err.message || err}`;
503
552
  } finally {
504
553
  const b = document.getElementById('btn-fetch');
505
- if (b) { b.disabled = false; b.textContent = `🔍 ${t.fetchDevices||'Fetch Devices'}`; }
554
+ if (b) { b.disabled = false; b.textContent = `🔍 ${t.fetch||'Fetch'}`; }
506
555
  }
507
556
  });
508
557
 
509
- /* Add device (manual) */
510
- document.getElementById('btn-add').addEventListener('click', () => {
558
+ /* Add Local Device */
559
+ document.getElementById('btn-add-local').addEventListener('click', () => {
511
560
  const i = config.devices.length;
512
561
  config.devices.push({
513
- name: '', connection: 'hybrid',
514
- endpointId: '',
562
+ name: '', connection: 'local',
515
563
  local: { ip: '', mac: '' },
516
- presets: { sleep: true, health: true, eco: true, clean: true },
517
- showFan: true, showComfWind: true, showDisplay: true,
564
+ presets: { sleep: true, health: false, eco: false, clean: false },
565
+ showFan: true, showComfWind: false, showDisplay: false,
518
566
  tempStep: 0.5, pollInterval: 30,
519
567
  });
520
568
  expanded.add(i);
@@ -528,13 +576,16 @@
528
576
  const i = +btn.dataset.i;
529
577
  config.devices.splice(i, 1);
530
578
  const newExp = new Set();
531
- expanded.forEach(v => { if (v < i) newExp.add(v); else if (v > i) newExp.add(v - 1); });
579
+ expanded.forEach(v => {
580
+ if (v < i) newExp.add(v);
581
+ else if (v > i) newExp.add(v - 1);
582
+ });
532
583
  expanded.clear(); newExp.forEach(v => expanded.add(v));
533
584
  render(); save();
534
585
  });
535
586
  });
536
587
 
537
- /* Connection toggle */
588
+ /* Connection toggle (cloud devices only: cloud | hybrid) */
538
589
  document.querySelectorAll('.conn-pick').forEach(btn => {
539
590
  btn.addEventListener('click', e => {
540
591
  e.stopPropagation();
@@ -544,11 +595,13 @@
544
595
  });
545
596
  });
546
597
 
547
- /* Device-level string/number fields */
598
+ /* Device-level fields (name, tempStep, pollInterval) */
548
599
  document.querySelectorAll('.d-field').forEach(el => {
549
600
  el.addEventListener('change', () => {
550
601
  const i = +el.dataset.i, f = el.dataset.f;
551
- config.devices[i][f] = el.type === 'number' ? (parseFloat(el.value) || 0) : el.value;
602
+ config.devices[i][f] = (f === 'tempStep') ? (parseFloat(el.value) || 0.5)
603
+ : (f === 'pollInterval') ? (parseInt(el.value) || 30)
604
+ : el.value;
552
605
  save();
553
606
  });
554
607
  el.addEventListener('input', () => {
@@ -559,16 +612,7 @@
559
612
  });
560
613
  });
561
614
 
562
- /* Hybrid endpoint ID field */
563
- document.querySelectorAll('.h-field').forEach(el => {
564
- el.addEventListener('change', () => {
565
- const i = +el.dataset.i, f = el.dataset.f;
566
- config.devices[i][f] = el.value;
567
- save();
568
- });
569
- });
570
-
571
- /* Local fields */
615
+ /* Local IP / MAC fields */
572
616
  document.querySelectorAll('.l-field').forEach(el => {
573
617
  el.addEventListener('change', () => {
574
618
  const i = +el.dataset.i, f = el.dataset.f;
@@ -582,7 +626,8 @@
582
626
  document.querySelectorAll('.feat-cb').forEach(cb => {
583
627
  cb.closest('.toggle-item').classList.toggle('checked', cb.checked);
584
628
  cb.addEventListener('change', () => {
585
- const i = +cb.dataset.i, k = cb.dataset.k, isPreset = cb.dataset.preset === 'true';
629
+ const i = +cb.dataset.i, k = cb.dataset.k;
630
+ const isPreset = cb.dataset.preset === 'true';
586
631
  if (isPreset) {
587
632
  if (!config.devices[i].presets) config.devices[i].presets = {};
588
633
  config.devices[i].presets[k] = cb.checked;
@@ -4,14 +4,25 @@
4
4
  * Provides a /fetchDevices endpoint so the config UI can log in to the
5
5
  * AUX Cloud API and return the list of discovered devices without
6
6
  * restarting Homebridge.
7
+ *
8
+ * Uses @homebridge/plugin-ui-utils (required by homebridge-config-ui-x v5+).
7
9
  */
8
10
 
9
11
  'use strict';
10
12
 
11
- const { AuxCloudAPI } = require('../src/cloud-api');
13
+ const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
14
+
15
+ class AcFreedomUiServer extends HomebridgePluginUiServer {
16
+ constructor() {
17
+ super();
18
+ this.onRequest('/fetchDevices', this.fetchDevices.bind(this));
19
+ this.ready();
20
+ }
21
+
22
+ async fetchDevices(payload) {
23
+ // Lazy-require so a load-time error never blocks the UI
24
+ const { AuxCloudAPI } = require('../src/cloud-api');
12
25
 
13
- module.exports = (homebridge) => {
14
- homebridge.onRequest('/fetchDevices', async (payload) => {
15
26
  const { email, password, region } = payload || {};
16
27
 
17
28
  if (!email || !password) {
@@ -35,5 +46,7 @@ module.exports = (homebridge) => {
35
46
  name: d.friendlyName || 'AUX AC',
36
47
  })),
37
48
  };
38
- });
39
- };
49
+ }
50
+ }
51
+
52
+ (() => { return new AcFreedomUiServer(); })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-ac-freedom",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
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",
@@ -33,6 +33,9 @@
33
33
  "type": "git",
34
34
  "url": "https://github.com/azadaydinli/homebridge-ac-freedom.git"
35
35
  },
36
+ "dependencies": {
37
+ "@homebridge/plugin-ui-utils": "^2.0.0"
38
+ },
36
39
  "engines": {
37
40
  "homebridge": ">=1.6.0 || >=2.0.0",
38
41
  "node": ">=18.0.0"