homebridge-hap-homematic 0.2.2 → 0.3.0

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.
@@ -14,7 +14,7 @@
14
14
  * (connection only).
15
15
  * - state.devicesFile: devices.json (the mappings), loaded and saved
16
16
  * through the /devices/get and /devices/put endpoints.
17
- * - state.view: top-level view (dashboard | channels | settings).
17
+ * - state.view: top-level view, shown as a tab (settings | channels).
18
18
  * - state.nav: optional sub-view stack pushed on top of the base
19
19
  * view. Pushing a sub-view (e.g. "edit channel") swaps the body
20
20
  * until the user pops back. No modals.
@@ -69,7 +69,7 @@ const DEFAULT_BLOCK = () => ({
69
69
  });
70
70
 
71
71
  const state = {
72
- view: 'dashboard',
72
+ view: 'settings',
73
73
  /** Optional drill-down sub-view (replaces base view body until popped). */
74
74
  nav: [],
75
75
  cfg: DEFAULT_BLOCK(),
@@ -87,6 +87,8 @@ const state = {
87
87
  filterType: { channels: 'all' }, // CCU channel type, e.g. SWITCH_VIRTUAL_RECEIVER, or 'all'
88
88
  sort: { channels: 'name' }, // name | device | address | type
89
89
  page: { channels: 1, picker: 1 },
90
+ /** false: hide channels that are not worth mapping (see hiddenChannels). */
91
+ showAll: false,
90
92
  },
91
93
  pluginVersion: 'unknown',
92
94
  };
@@ -102,6 +104,54 @@ function channelLookup() {
102
104
  return out;
103
105
  }
104
106
 
107
+ const GROUP_RE = /^(SWITCH|SHUTTER|BLIND|DIMMER)_(TRANSMITTER|VIRTUAL_RECEIVER)$/;
108
+
109
+ /**
110
+ * Addresses of channels that the lists hide unless "Show all channels"
111
+ * is on:
112
+ * - channels no service can handle (MAINTENANCE, week profiles, …);
113
+ * - in an HmIP actuator group (one *_TRANSMITTER followed by its
114
+ * *_VIRTUAL_RECEIVERs) every channel but one. The one kept is the
115
+ * transmitter if a service handles it, else the first receiver. The
116
+ * plugin pairs them itself (src/ccu/pairing.ts), so mapping any of
117
+ * them gives the same accessory;
118
+ * - the CCU's own virtual remote (HM-RCV-50 / HmIP-RCV-50, 50 keys).
119
+ * Channels already in devices.json are never hidden.
120
+ */
121
+ function hiddenChannels(device) {
122
+ const channels = sortBy(device.channels ?? [], (c) => c.index);
123
+ if (/RCV-50$/i.test(device.type ?? '')) return new Set(channels.map((c) => c.address));
124
+ const handled = new Set(state.services.channelServices.flatMap((s) => s.channelTypes));
125
+ const hidden = new Set();
126
+ for (let i = 0; i < channels.length; i++) {
127
+ const c = channels[i];
128
+ const m = GROUP_RE.exec(c.type ?? '');
129
+ if (!m || m[2] !== 'TRANSMITTER') {
130
+ // Receivers are handled with their transmitter below; a receiver
131
+ // without a transmitter before it stays visible.
132
+ const inGroup = m && channels.slice(0, i).some((p) => p.type === `${m[1]}_TRANSMITTER`);
133
+ if (!inGroup && !handled.has(c.type)) hidden.add(c.address);
134
+ continue;
135
+ }
136
+ const group = [c];
137
+ for (let j = i + 1; j < channels.length && channels[j].type === `${m[1]}_VIRTUAL_RECEIVER`; j++) group.push(channels[j]);
138
+ const keep = handled.has(c.type) ? c : (group[1] ?? c);
139
+ for (const g of group) if (g !== keep) hidden.add(g.address);
140
+ }
141
+ return hidden;
142
+ }
143
+
144
+ /** Union of hiddenChannels() over all discovered devices, minus mapped ones. */
145
+ function hiddenAddresses() {
146
+ if (state.ui.showAll) return new Set();
147
+ const mapped = new Set(configuredChannels().map((c) => c.address));
148
+ const out = new Set();
149
+ for (const d of state.discovered.devices ?? []) {
150
+ for (const a of hiddenChannels(d)) if (!mapped.has(a)) out.add(a);
151
+ }
152
+ return out;
153
+ }
154
+
105
155
  const deviceMode = (dev) => dev.mode ?? (dev.service ? 'device' : 'perChannel');
106
156
  const splitKey = (key) => { const i = key.indexOf(':'); return [key.slice(0, i), key.slice(i + 1)]; };
107
157
  // Same fallback as the plugin: HmIP serials are 14 hex digits.
@@ -214,7 +264,11 @@ function devicesErrorHTML() {
214
264
  function navigate(view) {
215
265
  state.view = view;
216
266
  state.nav = [];
217
- $('hgui-view-select').value = view;
267
+ document.querySelectorAll('#hgui-tabs [data-tab]').forEach((t) => {
268
+ const on = t.dataset.tab === view;
269
+ t.classList.toggle('active', on);
270
+ t.setAttribute('aria-selected', String(on));
271
+ });
218
272
  rerender();
219
273
  }
220
274
 
@@ -235,46 +289,10 @@ function rerender() {
235
289
  // ---------------------------------------------------------------- top-level views
236
290
 
237
291
  const VIEWS = {
238
- dashboard: viewDashboard,
239
- channels: viewChannels,
240
292
  settings: viewSettings,
293
+ channels: viewChannels,
241
294
  };
242
295
 
243
- // ---------------------------------------------------------------- dashboard
244
-
245
- function viewDashboard(host) {
246
- const card = (label, value, sub = '') => `
247
- <div class="col-sm-6 col-md-3 d-flex">
248
- <div class="hgui-card flex-fill">
249
- <div class="hgui-meta">${h(label)}</div>
250
- <div class="display-6">${h(value)}</div>
251
- <div class="hgui-meta">${h(sub)}</div>
252
- </div>
253
- </div>`;
254
- const discoveryStatus = state.discovered.devices.length
255
- ? `${state.discovered.devices.length} devices`
256
- : 'No discovery data yet — click "Discover" in the header to load from the CCU.';
257
- host.innerHTML = `
258
- <h4 class="my-3">Dashboard</h4>
259
- <div class="row g-3 mb-3">
260
- ${card('Channels', configuredChannels().length, 'in HomeKit')}
261
- ${card('Devices', state.discovered.devices.length, 'discovered on the CCU')}
262
- </div>
263
- <div class="hgui-card">
264
- <h6>Discovery</h6>
265
- <p class="hgui-meta mb-0">${h(discoveryStatus)}</p>
266
- </div>
267
- <div class="hgui-card">
268
- <h6>Quick links</h6>
269
- <div class="d-flex gap-2 flex-wrap">
270
- <button class="btn btn-outline-primary btn-sm" data-go="channels">Manage channels →</button>
271
- <button class="btn btn-outline-secondary btn-sm" data-go="settings">Settings →</button>
272
- </div>
273
- </div>
274
- `;
275
- host.querySelectorAll('[data-go]').forEach((b) => b.addEventListener('click', () => navigate(b.dataset.go)));
276
- }
277
-
278
296
  // ---------------------------------------------------------------- channels view
279
297
 
280
298
  function viewChannels(host) {
@@ -284,11 +302,26 @@ function viewChannels(host) {
284
302
  // search input never gets recreated, never loses focus.
285
303
  // Build the type filter option list from the discovered device tree
286
304
  // so the dropdown reflects what's actually on this CCU.
305
+ const hidden = hiddenAddresses();
287
306
  const channelTypes = new Set();
288
- for (const d of state.discovered.devices) for (const c of d.channels) if (c.type) channelTypes.add(c.type);
307
+ for (const d of state.discovered.devices) {
308
+ for (const c of d.channels) if (c.type && c.index !== 0 && !hidden.has(c.address)) channelTypes.add(c.type);
309
+ }
289
310
 
290
311
  host.innerHTML = `
291
- <h4 class="my-3">Channels</h4>
312
+ <div class="hgui-card mt-3">
313
+ <h6>Which channels go to HomeKit?</h6>
314
+ <ol class="hgui-meta mb-2 ps-3">
315
+ <li>Click <strong>Discover</strong> to load the devices from the CCU.</li>
316
+ <li>Switch the filter to <em>Not in HomeKit</em>, pick a channel and click <strong>+ Add</strong>.
317
+ Give it a HomeKit name and choose the service (switch, outlet, light, blind, …).</li>
318
+ <li>Restart the child bridge to apply the changes.</li>
319
+ </ol>
320
+ <p class="hgui-meta mb-0">Only the channels worth mapping are listed. An HmIP actuator shows one
321
+ channel per output (e.g. HmIP-BROLL channel 3, HmIP-PS channel 2, HmIP-DRSI4 channels 5 / 9 / 13 / 17);
322
+ the plugin reads the state there and sends commands to the matching receiver channel itself.
323
+ Choose the channel and service once: changing either later creates a new accessory in Home.</p>
324
+ </div>
292
325
  <div class="hgui-toolbar">
293
326
  <input type="search" class="form-control" id="hgui-search-channels"
294
327
  placeholder="Search by HomeKit name, CCU name, address, type…"
@@ -308,6 +341,10 @@ function viewChannels(host) {
308
341
  <option value="address">Sort: Address</option>
309
342
  <option value="type">Sort: Channel type</option>
310
343
  </select>
344
+ <div class="form-check mb-0">
345
+ <input class="form-check-input" type="checkbox" id="hgui-showall-channels" ${state.ui.showAll ? 'checked' : ''} />
346
+ <label class="form-check-label" for="hgui-showall-channels">Show all channels</label>
347
+ </div>
311
348
  <button class="btn btn-primary ms-auto" id="hgui-add-channel">+ Add channel</button>
312
349
  <span class="hgui-meta" id="hgui-channels-count"></span>
313
350
  </div>
@@ -338,6 +375,11 @@ function viewChannels(host) {
338
375
  state.ui.sort.channels = e.target.value;
339
376
  drawChannelRows();
340
377
  });
378
+ $('hgui-showall-channels').addEventListener('change', (e) => {
379
+ state.ui.showAll = e.target.checked;
380
+ state.ui.page.channels = 1;
381
+ rerender(); // the type filter list depends on it too
382
+ });
341
383
  $('hgui-add-channel').addEventListener('click', () => {
342
384
  if (state.devicesErrors) {
343
385
  toast('warning', 'Fix devices.json first', 'Add channel'); return;
@@ -366,6 +408,7 @@ function drawChannelRows() {
366
408
  rows = configured.map((c) => ({ ...c, _info: cl.get(c.address), isConfigured: true }));
367
409
  } else {
368
410
  // 'all' or 'unconfigured' both need discovered data.
411
+ const hidden = hiddenAddresses();
369
412
  rows = [];
370
413
  for (const d of state.discovered.devices) {
371
414
  // A whole-device mapping (e.g. irrigation) is one card, not N channels.
@@ -376,6 +419,7 @@ function drawChannelRows() {
376
419
  }
377
420
  for (const c of d.channels) {
378
421
  if (c.index === 0) continue; // MAINTENANCE: not mappable
422
+ if (hidden.has(c.address)) continue;
379
423
  const existing = configuredByAddr.get(c.address);
380
424
  const isConfigured = !!existing;
381
425
  if (filter === 'unconfigured' && isConfigured) continue;
@@ -508,8 +552,18 @@ function pagerHTML(kind, page, pageCount) {
508
552
 
509
553
  function viewSettings(host) {
510
554
  const m = state.cfg;
555
+ const stat = (label, value, sub) => `
556
+ <div class="hgui-card">
557
+ <div class="hgui-meta">${h(label)}</div>
558
+ <div class="hgui-stat-value">${h(value)}</div>
559
+ <div class="hgui-meta">${h(sub)}</div>
560
+ </div>`;
561
+ const discovered = state.discovered.devices.length;
511
562
  host.innerHTML = `
512
- <h4 class="my-3">Settings</h4>
563
+ <div class="hgui-stats mt-3">
564
+ ${stat('Channels', configuredChannels().length, 'in HomeKit')}
565
+ ${stat('Devices', discovered, discovered ? 'discovered on the CCU' : 'not discovered yet — click "Discover"')}
566
+ </div>
513
567
  <div class="hgui-card">
514
568
  <h6>CCU connection</h6>
515
569
  <div class="row g-2 mb-2">
@@ -533,9 +587,12 @@ function viewSettings(host) {
533
587
  <label class="form-label">Callback address (required)</label>
534
588
  <input class="form-control" id="cf-callback-host" list="cf-callback-list" placeholder="192.168.0.13" value="${h(m.eventServer.callbackHost ?? '')}" />
535
589
  <datalist id="cf-callback-list">${state.network.map((n) => `<option value="${h(n.address)}">${h(n.name)}</option>`).join('')}</datalist>
536
- <div class="hgui-hint">IP of this Homebridge host as seen from the CCU. The CCU sends its events to <code>http://&lt;address&gt;:&lt;port&gt;</code>.
537
- ${state.network.length ? `Local addresses: ${state.network.map((n) => `<a href="#" data-pick-ip="${h(n.address)}">${h(n.address)}</a> (${h(n.name)})`).join(', ')}.
538
- In Docker these are container addresses — use the host's IP instead.` : ''}</div>
590
+ <div class="hgui-hint">IP of this Homebridge host as seen from the CCU. The CCU sends its events to <code>http://&lt;address&gt;:&lt;port&gt;</code>.</div>
591
+ ${state.network.length ? `<div class="hgui-hint"><details>
592
+ <summary>Local addresses of this host (${state.network.length})</summary>
593
+ <div class="mt-1">${state.network.map((n) => `<a href="#" data-pick-ip="${h(n.address)}">${h(n.address)}</a> (${h(n.name)})`).join(', ')}</div>
594
+ <div class="mt-1">Click one to use it. In Docker these are container addresses — use the host's IP instead.</div>
595
+ </details></div>` : ''}
539
596
  </div>
540
597
  </div>
541
598
  </div>
@@ -634,7 +691,8 @@ function subviewHeader(host, title, breadcrumb) {
634
691
  // --- pickChannel: searchable picker
635
692
 
636
693
  function subPickChannel(host) {
637
- subviewHeader(host, 'Add channel — pick from CCU');
694
+ subviewHeader(host, 'Add channel — pick from CCU',
695
+ state.ui.showAll ? 'all channels' : 'channels worth mapping ("Show all channels" lists every one)');
638
696
  const body = $('hgui-sub-body');
639
697
  body.innerHTML = `
640
698
  <div class="hgui-toolbar">
@@ -658,9 +716,13 @@ function drawPicker() {
658
716
  const host = $('hgui-picker-host');
659
717
  const pagerHost = $('hgui-picker-pager');
660
718
  const configuredByAddr = new Map(configuredChannels().map((c) => [c.address, c]));
719
+ const hidden = hiddenAddresses();
661
720
  const all = [];
662
721
  for (const d of state.discovered.devices) {
663
- for (const c of d.channels) all.push({ device: d, channel: c });
722
+ for (const c of d.channels) {
723
+ if (c.index === 0 || hidden.has(c.address)) continue;
724
+ all.push({ device: d, channel: c });
725
+ }
664
726
  }
665
727
  const q = state.ui.search.picker.trim().toLowerCase();
666
728
  let rows = q
@@ -1001,7 +1063,8 @@ async function init() {
1001
1063
  }
1002
1064
 
1003
1065
  // Header listeners.
1004
- $('hgui-view-select').addEventListener('change', (e) => navigate(e.target.value));
1066
+ document.querySelectorAll('#hgui-tabs [data-tab]').forEach((t) =>
1067
+ t.addEventListener('click', () => navigate(t.dataset.tab)));
1005
1068
  $('hgui-discover-btn').addEventListener('click', onDiscover);
1006
1069
  $('hgui-test-btn').addEventListener('click', onTestConnection);
1007
1070
 
@@ -1010,15 +1073,13 @@ async function init() {
1010
1073
  if (e.key === 'Escape' && state.nav.length) popNav();
1011
1074
  });
1012
1075
 
1013
- // Initial tab: if the user hasn't configured a CCU yet, drop them
1014
- // straight into Settings. Otherwise show Dashboard and kick off a
1015
- // background Discover so the channels tab is
1016
- // populated by the time the user clicks them.
1076
+ // Initial tab: Settings until a CCU is configured, then Channels with
1077
+ // a background Discover so the list fills in on its own.
1017
1078
  const isConfigured = !!state.cfg.ccuIp && state.cfg.ccuIp.length > 0;
1018
1079
  if (!isConfigured) {
1019
1080
  navigate('settings');
1020
1081
  } else {
1021
- navigate('dashboard');
1082
+ navigate('channels');
1022
1083
  // Fire-and-forget; failures fall back to the empty state with a
1023
1084
  // hint already in place. No spinner — user can still navigate.
1024
1085
  onDiscover().catch(() => { /* errors already toasted by onDiscover */ });
@@ -90,7 +90,7 @@
90
90
  .hgui-empty { color: var(--bs-secondary-color); font-style: italic; padding: 2rem; text-align: center; border: 1px dashed var(--bs-border-color-translucent); border-radius: 8px; margin-top: 1rem; }
91
91
  .hgui-pager { display: flex; gap: 0.25rem; align-items: center; justify-content: center; padding: 0.75rem 0; flex-wrap: wrap; }
92
92
 
93
- /* Cards (used in dashboard / settings) --------------------------- */
93
+ /* Cards (used in settings / sub-views) --------------------------- */
94
94
  .hgui-card { background: var(--bs-tertiary-bg); color: var(--bs-body-color); border: 1px solid var(--bs-border-color-translucent); border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
95
95
  .hgui-card h6 { margin-bottom: 0.5rem; }
96
96
  .hgui-meta { color: var(--bs-secondary-color); font-size: 0.85rem; }
@@ -100,22 +100,37 @@
100
100
  @media (max-width: 700px) { .hgui-form-row { grid-template-columns: 1fr; } }
101
101
  .hgui-form-row label { padding-top: 0.5rem; font-weight: 500; color: var(--bs-body-color); }
102
102
  .hgui-form-row .hgui-hint { color: var(--bs-secondary-color); font-size: 0.825rem; margin-top: 0.25rem; }
103
+ .hgui-hint { color: var(--bs-secondary-color); font-size: 0.825rem; margin-top: 0.25rem; }
104
+ .hgui-hint details summary { cursor: pointer; color: var(--bs-link-color); }
105
+
106
+ /* Tabs: keep the host's tab look, make the active tab readable in both themes. */
107
+ #hgui-tabs .nav-link { color: var(--bs-secondary-color); }
108
+ #hgui-tabs .nav-link.active { color: var(--bs-body-color); background: var(--bs-body-bg); }
109
+
110
+ /* Status tiles at the top of Settings. */
111
+ .hgui-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; }
112
+ .hgui-stats .hgui-card { margin-bottom: 0; }
113
+ .hgui-stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; }
103
114
  </style>
104
115
 
105
116
  <div id="hgui-app">
106
117
 
107
- <!-- Header: view dropdown + persistent action buttons --------------- -->
118
+ <!-- Header: action buttons, then the view tabs ------------------------ -->
108
119
  <header class="hgui-header">
109
- <select id="hgui-view-select" class="form-select form-select-sm" style="min-width: 200px; max-width: 220px">
110
- <option value="dashboard">📊 Dashboard</option>
111
- <option value="channels">💡 Channels</option>
112
- <option value="settings">⚙️ Settings</option>
113
- </select>
114
120
  <button id="hgui-discover-btn" class="btn btn-primary btn-sm">Discover</button>
115
121
  <button id="hgui-test-btn" class="btn btn-outline-secondary btn-sm">Test CCU</button>
116
122
  <span id="hgui-test-result" class="small"></span>
117
123
  <span class="hgui-version ms-auto" id="hgui-version-banner"></span>
118
124
  </header>
125
+ <!-- Plain Bootstrap tab styling; switching is done in app.js (no Bootstrap JS). -->
126
+ <ul class="nav nav-tabs" id="hgui-tabs" role="tablist">
127
+ <li class="nav-item" role="presentation">
128
+ <button class="nav-link" type="button" role="tab" data-tab="settings">⚙️ Settings</button>
129
+ </li>
130
+ <li class="nav-item" role="presentation">
131
+ <button class="nav-link" type="button" role="tab" data-tab="channels">💡 Channels</button>
132
+ </li>
133
+ </ul>
119
134
 
120
135
  <main class="hgui-main">
121
136
  <div id="hgui-view-host"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-hap-homematic",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Homebridge plugin for HomeMatic / HomematicIP devices on a CCU / OpenCCU, using XML-RPC only. Hard fork of matthiaseinig/Homebridge-Homematic-HAP (homebridge-homematic-hap); not affiliated with thkl/hap-homematic.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -49,14 +49,14 @@
49
49
  "homematic-xmlrpc": "^2.0.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@eslint/js": "^9.39.1",
52
+ "@eslint/js": "^10.0.1",
53
53
  "@semantic-release/commit-analyzer": "^13.0.1",
54
54
  "@semantic-release/github": "^12.0.10",
55
55
  "@semantic-release/release-notes-generator": "^14.1.1",
56
56
  "@types/node": "^24.10.1",
57
57
  "@vitest/coverage-v8": "^5.1.0",
58
58
  "conventional-changelog-conventionalcommits": "^9.3.1",
59
- "esbuild": "^0.27.7",
59
+ "esbuild": "^0.28.2",
60
60
  "eslint": "^10.14.0",
61
61
  "homebridge": "^2.5.0",
62
62
  "nodemon": "^3.1.11",