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 +7 -2
- package/dist/accessories/ZWaveAccessory.js +3 -3
- package/dist/platform/ZWaveUsbPlatform.d.ts +1 -0
- package/dist/platform/ZWaveUsbPlatform.js +34 -3
- package/dist/zwave/ZWaveController.d.ts +4 -0
- package/dist/zwave/ZWaveController.js +16 -1
- package/dist/zwave/interfaces.d.ts +8 -0
- package/homebridge-ui/public/index.html +33 -3
- package/homebridge-ui/server.js +4 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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);
|
package/homebridge-ui/server.js
CHANGED
|
@@ -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.
|
|
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": {
|