homebridge-zwave-usb 3.6.0 → 3.6.2

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/README.md CHANGED
@@ -107,7 +107,7 @@ npm install -g homebridge-zwave-usb
107
107
 
108
108
  When pairing a Security 2 (S2) device, you must enter a 5-digit PIN found on the device label or box.
109
109
 
110
- ### Method 1: Using HomeKit (Recommended)
110
+ ### Method 1: Using a Third-Party HomeKit App (Recommended)
111
111
  1. Use a third-party app like **Controller for HomeKit**, **Eve**, or **Home+**.
112
112
  2. Find the **"Z-Wave Controller"** accessory and the **"Z-Wave Manager"** service.
113
113
  3. Write the 5-digit PIN into the **"S2 PIN Entry"** field.
@@ -131,21 +131,26 @@ This plugin adds a management accessory to your Home app to handle network opera
131
131
  - **Heal Network**: A switch to trigger a background mesh network optimization.
132
132
  - **Prune Dead Nodes**: Automatically removes devices marked as "Dead" (failed) from the network. **Safe for battery devices**: This only removes nodes the controller has confirmed as failed (Status 3); it will never remove a device that is simply "Asleep" (Status 1).
133
133
 
134
+ Controller switch labels are exposed using a compatibility path for Home app visibility. Some HomeKit apps may still render these actions differently.
135
+
134
136
  ### 🛠️ Advanced Management
135
137
 
136
138
  The plugin provides additional tools for network maintenance:
137
139
 
138
140
  - **Dual S2 PIN Entry**: When a device requires a PIN, you can enter it via the Homebridge terminal (`echo "12345" > s2_pin.txt`) or via the `S2 PIN Entry` characteristic in third-party HomeKit apps.
139
- - **Automated Reconciliation**: Orphaned accessories are automatically removed from HomeKit 60 seconds after startup if the node is no longer present in the Z-Wave network.
141
+ - **Explicit Stale Accessory Cleanup**: The custom UI includes a maintenance action to remove stale cached accessories when a node has been removed from the Z-Wave network but Homebridge still has an old cached entry.
140
142
 
141
143
  ## 🛠️ Maintenance & Firmware Updates
142
144
 
143
145
  The plugin includes a dedicated **Maintenance** tab within the Homebridge custom UI (accessible via the plugin settings).
144
146
 
145
147
  - **Node Overview**: View a complete list of all Z-Wave nodes in your network, including their status and current firmware versions.
148
+ - **HomeKit Publication State**: See whether a node is already published to HomeKit, still cached and waiting for interview wake-up, or intentionally deferred until interview metadata is available.
149
+ - **Node Rename**: Rename the Z-Wave node from the plugin UI without recreating the HomeKit accessory.
146
150
  - **Official Updates**: Click "Check Update" to query the official Z-Wave JS Firmware Update Service. If a manufacturer-approved update is available, you can start the OTA transfer with a single click.
147
151
  - **Battery Device Support**: Firmware updates are supported for battery-powered devices. You will be prompted to manually wake the device after starting the process to initiate the transfer.
148
152
  - **Real-time Progress**: Monitor the progress of the firmware transfer via a live progress bar.
153
+ - **Clean Up Stale Accessories**: Remove stale cached Homebridge accessories explicitly from the maintenance UI instead of relying on automatic startup pruning.
149
154
 
150
155
  ## ❓ Troubleshooting
151
156
 
@@ -220,14 +220,14 @@ class ZWaveAccessory {
220
220
  }
221
221
  }
222
222
  getDesiredName() {
223
- return this.node.name || this.node.deviceConfig?.label || `Node ${this.node.nodeId}`;
223
+ return this.node.name || this.node.label || this.node.deviceConfig?.label || `Node ${this.node.nodeId}`;
224
224
  }
225
225
  getEffectiveName() {
226
226
  return this.platformAccessory.displayName || this.getDesiredName();
227
227
  }
228
228
  applyAccessoryMetadata(options = {}) {
229
- const manufacturer = this.node.deviceConfig?.manufacturer || 'Unknown';
230
- const model = this.node.deviceConfig?.label || `Node ${this.node.nodeId}`;
229
+ const manufacturer = this.node.manufacturer || this.node.deviceConfig?.manufacturer || 'Unknown';
230
+ const model = this.node.label || this.node.deviceConfig?.label || `Node ${this.node.nodeId}`;
231
231
  const serial = `Node ${this.node.nodeId}`;
232
232
  const name = this.getEffectiveName();
233
233
  const firmwareRevision = this.node.firmwareVersion || '1.0.0';
@@ -17,6 +17,7 @@ export declare class ZWaveUsbPlatform implements DynamicPlatformPlugin {
17
17
  */
18
18
  private discoveryInFlight;
19
19
  private firmwareProgress;
20
+ private refreshStates;
20
21
  constructor(log: Logger, config: PlatformConfig, api: API);
21
22
  private startIpcServer;
22
23
  private stopIpcServer;
@@ -31,6 +31,7 @@ class ZWaveUsbPlatform {
31
31
  */
32
32
  discoveryInFlight = new Set();
33
33
  firmwareProgress = new Map();
34
+ refreshStates = new Map();
34
35
  constructor(log, config, api) {
35
36
  this.log = log;
36
37
  this.config = config;
@@ -111,14 +112,15 @@ class ZWaveUsbPlatform {
111
112
  const nodes = Array.from(this.zwaveController?.nodes.values() || []).map((node) => ({
112
113
  nodeId: node.nodeId,
113
114
  name: node.name,
114
- label: node.deviceConfig?.label,
115
- manufacturer: node.deviceConfig?.manufacturer,
115
+ label: node.label || node.deviceConfig?.label,
116
+ manufacturer: node.manufacturer || node.deviceConfig?.manufacturer,
116
117
  status: node.status,
117
118
  ready: node.ready,
118
119
  firmwareVersion: node.firmwareVersion,
119
120
  isListening: node.isListening,
120
121
  isFrequentListening: node.isFrequentListening,
121
122
  firmwareProgress: this.firmwareProgress.get(node.nodeId),
123
+ refreshState: this.refreshStates.get(node.nodeId),
122
124
  homekitState: this.getHomeKitPublicationState(node),
123
125
  }));
124
126
  return sendJson(nodes);
@@ -153,6 +155,24 @@ class ZWaveUsbPlatform {
153
155
  });
154
156
  return;
155
157
  }
158
+ if (normalizedUrl.startsWith('/nodes/') &&
159
+ normalizedUrl.endsWith('/refresh-info') &&
160
+ method === 'POST') {
161
+ const parts = normalizedUrl.split('/');
162
+ const nodeId = parseInt(parts[2], 10);
163
+ this.zwaveController
164
+ ?.refreshNodeInfo(nodeId)
165
+ .then((result) => {
166
+ this.refreshStates.set(nodeId, result.requiresWakeUp ? 'waiting-wakeup' : 'refreshing');
167
+ sendJson({
168
+ success: true,
169
+ refreshState: this.refreshStates.get(nodeId),
170
+ ...result,
171
+ });
172
+ })
173
+ .catch((err) => sendJson({ error: err.message }, 500));
174
+ return;
175
+ }
156
176
  if (normalizedUrl === '/accessories/prune-stale' && method === 'POST') {
157
177
  try {
158
178
  const removed = this.pruneStaleAccessories();
@@ -308,11 +328,21 @@ class ZWaveUsbPlatform {
308
328
  this.syncNodeAccessory(node);
309
329
  }
310
330
  handleNodeReady(node) {
311
- const nodeName = node.name || node.deviceConfig?.label || `Node ${node.nodeId}`;
331
+ this.refreshStates.delete(node.nodeId);
332
+ const nodeName = node.name || node.label || node.deviceConfig?.label || `Node ${node.nodeId}`;
312
333
  this.log.info(`Node ${node.nodeId} (${nodeName}) ready`);
313
334
  this.syncNodeAccessory(node);
314
335
  }
315
336
  handleNodeUpdated(node) {
337
+ const refreshState = this.refreshStates.get(node.nodeId);
338
+ if (refreshState) {
339
+ if (node.ready) {
340
+ this.refreshStates.delete(node.nodeId);
341
+ }
342
+ else if (node.status !== 1) {
343
+ this.refreshStates.set(node.nodeId, 'refreshing');
344
+ }
345
+ }
316
346
  this.syncNodeAccessory(node);
317
347
  }
318
348
  syncNodeAccessory(node) {
@@ -432,6 +462,7 @@ class ZWaveUsbPlatform {
432
462
  return staleAccessories.length;
433
463
  }
434
464
  handleNodeRemoved(node) {
465
+ this.refreshStates.delete(node.nodeId);
435
466
  this.log.info(`Node ${node.nodeId} removed`);
436
467
  const homeId = this.zwaveController?.homeId;
437
468
  const liveAccessory = this.zwaveAccessories.get(node.nodeId);
@@ -61,6 +61,10 @@ export declare class ZWaveController extends EventEmitter implements IZWaveContr
61
61
  */
62
62
  setNodeName(nodeId: number, name: string): Promise<void>;
63
63
  getAvailableFirmwareUpdates(nodeId: number): Promise<unknown[]>;
64
+ refreshNodeInfo(nodeId: number): Promise<{
65
+ nodeId: number;
66
+ requiresWakeUp: boolean;
67
+ }>;
64
68
  beginFirmwareUpdate(nodeId: number, update: unknown): Promise<void>;
65
69
  abortFirmwareUpdate(nodeId: number): Promise<void>;
66
70
  }
@@ -155,7 +155,7 @@ class ZWaveController extends events_1.EventEmitter {
155
155
  */
156
156
  const statusMap = ['Unknown', 'Asleep', 'Awake', 'Dead', 'Alive'];
157
157
  const status = statusMap[node.status] || node.status.toString();
158
- const nodeName = node.name || node.deviceConfig?.label || `Node ${node.nodeId}`;
158
+ const nodeName = node.name || node.label || node.deviceConfig?.label || `Node ${node.nodeId}`;
159
159
  this.log.info(`Node ${node.nodeId} (${nodeName}) added to controller (Status: ${status}, Interview Stage: ${node.interviewStage})`);
160
160
  const onReady = () => {
161
161
  this.log.info(`Node ${node.nodeId} is ready (Interview Stage: ${node.interviewStage})`);
@@ -726,6 +726,21 @@ class ZWaveController extends events_1.EventEmitter {
726
726
  return [];
727
727
  }
728
728
  }
729
+ async refreshNodeInfo(nodeId) {
730
+ const node = this.nodes.get(nodeId);
731
+ if (!node) {
732
+ throw new Error(`Node ${nodeId} not found`);
733
+ }
734
+ const requiresWakeUp = !node.isListening && !node.isFrequentListening;
735
+ this.log.info(`Refreshing interview information for Node ${nodeId}${requiresWakeUp ? ' (manual wake-up likely required)' : ''}...`);
736
+ // Do not await refreshInfo() here. For sleepy nodes zwave-js may wait until
737
+ // the next wake-up before it begins the fresh interview, and the UI should be
738
+ // able to show "queued" state immediately instead of blocking on that wake-up.
739
+ void node.refreshInfo().catch((err) => {
740
+ this.log.error(`Failed to refresh node info for Node ${nodeId}:`, err);
741
+ });
742
+ return { nodeId, requiresWakeUp };
743
+ }
729
744
  async beginFirmwareUpdate(nodeId, update) {
730
745
  const node = this.nodes.get(nodeId);
731
746
  if (!node) {
@@ -21,6 +21,10 @@ export interface IZWaveController extends EventEmitter {
21
21
  stopHealing(): Promise<boolean>;
22
22
  removeFailedNode(nodeId: number): Promise<void>;
23
23
  setNodeName(nodeId: number, name: string): Promise<void>;
24
+ refreshNodeInfo(nodeId: number): Promise<{
25
+ nodeId: number;
26
+ requiresWakeUp: boolean;
27
+ }>;
24
28
  setS2Pin(pin: string): void;
25
29
  getAvailableFirmwareUpdates(nodeId: number): Promise<unknown[]>;
26
30
  beginFirmwareUpdate(nodeId: number, update: unknown): Promise<void>;
@@ -44,6 +48,8 @@ export interface IZWaveController extends EventEmitter {
44
48
  export interface IZWaveNode {
45
49
  nodeId: number;
46
50
  name?: string;
51
+ manufacturer?: string;
52
+ label?: string;
47
53
  deviceConfig?: {
48
54
  label?: string;
49
55
  manufacturer?: string;
@@ -52,6 +58,8 @@ export interface IZWaveNode {
52
58
  interviewStage: InterviewStage;
53
59
  status: number;
54
60
  firmwareVersion?: string;
61
+ isListening?: boolean | string;
62
+ isFrequentListening?: boolean | string;
55
63
  getValue(valueId: ValueID): unknown;
56
64
  setValue(valueId: ValueID, value: unknown): Promise<SetValueResult>;
57
65
  supportsCC(cc: number): boolean;
@@ -187,7 +187,7 @@
187
187
  </div>
188
188
  <div class="d-flex justify-content-between align-items-center mb-2">
189
189
  <div class="text-muted small">
190
- Nodes that have not completed interview yet are intentionally held back from HomeKit until their service metadata is stable.
190
+ Nodes that have not completed interview yet are intentionally held back from HomeKit until their service metadata is stable. Use Refresh Info to force a fresh interview when metadata looks incomplete.
191
191
  </div>
192
192
  <button class="btn btn-sm btn-outline-danger" id="cleanupStaleAccessories">
193
193
  Clean Up Stale Accessories
@@ -245,6 +245,7 @@
245
245
  let isPromptOpen = false;
246
246
  let activeNodeId = null;
247
247
  let activeUpdateData = null;
248
+ let activeMaintenanceAction = null;
248
249
  const uiHelpers = window.ZWaveUsbUiHelpers;
249
250
 
250
251
  const getInt = (id, def) => {
@@ -315,6 +316,10 @@
315
316
  'cached-pending': 'Cached in HomeKit, waiting for interview wake-up',
316
317
  'pending-interview': 'Not in HomeKit yet, waiting for interview metadata',
317
318
  };
319
+ const refreshStateMap = {
320
+ 'waiting-wakeup': 'Refresh queued, waiting for wake-up',
321
+ refreshing: 'Refresh in progress',
322
+ };
318
323
 
319
324
  const loadNodes = async () => {
320
325
  if (isPromptOpen) return;
@@ -336,6 +341,7 @@
336
341
  const progress = node.firmwareProgress;
337
342
  const nodeName = node.name || node.label || 'Node ' + node.nodeId;
338
343
  const homeKitState = homeKitStateMap[node.homekitState] || '';
344
+ const refreshState = refreshStateMap[node.refreshState] || '';
339
345
 
340
346
  let actionHtml = '';
341
347
  if (progress) {
@@ -351,6 +357,7 @@
351
357
  actionHtml = `
352
358
  <div class="btn-group">
353
359
  <button class="btn btn-sm btn-outline-secondary rename-node" data-nodeid="${node.nodeId}">Rename</button>
360
+ <button class="btn btn-sm btn-outline-secondary refresh-node-info" data-nodeid="${node.nodeId}" data-requires-wakeup="${!node.isListening && !node.isFrequentListening}">Refresh Info</button>
354
361
  <button class="btn btn-sm btn-outline-primary check-update" data-nodeid="${node.nodeId}">Check Update</button>
355
362
  </div>
356
363
  `;
@@ -361,6 +368,7 @@
361
368
  <td id="node-name-${node.nodeId}">${nodeName}</td>
362
369
  <td>
363
370
  <span class="badge badge-${status === 'Alive' || status === 'Awake' ? 'success' : 'secondary'}">${status}</span>
371
+ ${refreshState ? `<div class="small text-muted mt-1">${refreshState}</div>` : ''}
364
372
  ${homeKitState ? `<div class="small text-muted mt-1">${homeKitState}</div>` : ''}
365
373
  </td>
366
374
  <td>${node.firmwareVersion || 'Unknown'}</td>
@@ -390,6 +398,7 @@
390
398
  isPromptOpen = false;
391
399
  activeNodeId = null;
392
400
  activeUpdateData = null;
401
+ activeMaintenanceAction = null;
393
402
  };
394
403
 
395
404
  // Event Delegation for Table Actions (Rename & Firmware)
@@ -409,6 +418,17 @@
409
418
  return;
410
419
  }
411
420
 
421
+ if (target.classList.contains('refresh-node-info')) {
422
+ activeNodeId = nodeId;
423
+ activeMaintenanceAction = 'refresh-node-info';
424
+ const requiresWakeUp = target.getAttribute('data-requires-wakeup') === 'true';
425
+ document.getElementById('confirmMessage').textContent = requiresWakeUp
426
+ ? 'This resets the node interview state and requests fresh metadata. Keep the device awake while the refresh runs so it can answer interview commands.'
427
+ : 'This resets the node interview state and requests fresh metadata from the node. Continue?';
428
+ showModal('confirmModal');
429
+ return;
430
+ }
431
+
412
432
  // 2. Check Update Logic
413
433
  if (target.classList.contains('check-update')) {
414
434
  target.disabled = true;
@@ -469,11 +489,21 @@
469
489
  document.getElementById('executeConfirm').addEventListener('click', async () => {
470
490
  const nodeId = activeNodeId;
471
491
  const updateData = activeUpdateData;
492
+ const maintenanceAction = activeMaintenanceAction;
472
493
  hideModals();
473
494
 
474
495
  try {
475
- await window.homebridge.request('start-update', { nodeId, update: updateData });
476
- window.homebridge.toast.success('Firmware update started!');
496
+ if (maintenanceAction === 'refresh-node-info') {
497
+ const result = await window.homebridge.request('refresh-node-info', nodeId);
498
+ window.homebridge.toast.success(
499
+ result?.refreshState === 'waiting-wakeup'
500
+ ? `Refresh queued for Node ${nodeId}. Wake the device until the interview finishes.`
501
+ : `Refresh started for Node ${nodeId}.`,
502
+ );
503
+ } else {
504
+ await window.homebridge.request('start-update', { nodeId, update: updateData });
505
+ window.homebridge.toast.success('Firmware update started!');
506
+ }
477
507
  loadNodes();
478
508
  } catch (err) {
479
509
  window.homebridge.toast.error(err.message);
@@ -15,6 +15,10 @@ class UiServer extends HomebridgePluginUiServer {
15
15
  return await this.ipcRequest(`/nodes/${nodeId}/name`, 'POST', { name });
16
16
  });
17
17
 
18
+ this.onRequest('refresh-node-info', async (nodeId) => {
19
+ return await this.ipcRequest(`/nodes/${nodeId}/refresh-info`, 'POST');
20
+ });
21
+
18
22
  this.onRequest('cleanup-stale-accessories', async () => {
19
23
  return await this.ipcRequest('/accessories/prune-stale', 'POST');
20
24
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-zwave-usb",
3
3
  "displayName": "Homebridge Z-Wave USB",
4
- "version": "3.6.0",
4
+ "version": "3.6.2",
5
5
  "description": "A Homebridge dynamic platform plugin for Z-Wave USB controllers using zwave-js.",
6
6
  "license": "Polyform-Noncommercial-1.0.0",
7
7
  "funding": {