homebridge-zwave-usb 2.1.0 → 2.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.
- package/README.md +10 -0
- package/dist/platform/ZWaveUsbPlatform.js +28 -0
- package/dist/zwave/ZWaveController.d.ts +5 -0
- package/dist/zwave/ZWaveController.js +12 -0
- package/dist/zwave/interfaces.d.ts +1 -0
- package/homebridge-ui/public/index.html +439 -323
- package/homebridge-ui/server.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ A high-performance, production-grade [Homebridge](https://homebridge.io) integra
|
|
|
15
15
|
- **Reactive Architecture**: Real-time status updates and automatic hardware recovery (hot-plugging support).
|
|
16
16
|
- **Advanced Metadata Repair**: Automatically cleans up obsolete HomeKit services/characteristics during network updates.
|
|
17
17
|
- **Log Piping**: Internal Z-Wave JS driver logs are piped directly into the Homebridge terminal for seamless debugging.
|
|
18
|
+
- **OTA Firmware Updates**: Check for and install official manufacturer firmware updates directly from the Homebridge UI.
|
|
18
19
|
|
|
19
20
|
---
|
|
20
21
|
|
|
@@ -136,6 +137,15 @@ The plugin provides additional tools for network maintenance:
|
|
|
136
137
|
- **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.
|
|
137
138
|
- **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.
|
|
138
139
|
|
|
140
|
+
## 🛠️ Maintenance & Firmware Updates
|
|
141
|
+
|
|
142
|
+
The plugin includes a dedicated **Maintenance** tab within the Homebridge custom UI (accessible via the plugin settings).
|
|
143
|
+
|
|
144
|
+
- **Node Overview**: View a complete list of all Z-Wave nodes in your network, including their status and current firmware versions.
|
|
145
|
+
- **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.
|
|
146
|
+
- **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.
|
|
147
|
+
- **Real-time Progress**: Monitor the progress of the firmware transfer via a live progress bar.
|
|
148
|
+
|
|
139
149
|
## ❓ Troubleshooting
|
|
140
150
|
|
|
141
151
|
- **Device showing "No Response"?** Check the Homebridge logs. If the device is battery-powered, it may need to be "woken up" (usually by pressing a physical button on the device) to complete the interview.
|
|
@@ -125,6 +125,34 @@ class ZWaveUsbPlatform {
|
|
|
125
125
|
}));
|
|
126
126
|
return sendJson(nodes);
|
|
127
127
|
}
|
|
128
|
+
if (normalizedUrl.startsWith('/nodes/') && normalizedUrl.endsWith('/name') && method === 'POST') {
|
|
129
|
+
let body = '';
|
|
130
|
+
req.on('data', (chunk) => (body += chunk));
|
|
131
|
+
req.on('end', () => {
|
|
132
|
+
try {
|
|
133
|
+
const parts = normalizedUrl.split('/');
|
|
134
|
+
const nodeId = parseInt(parts[2], 10);
|
|
135
|
+
const { name } = JSON.parse(body);
|
|
136
|
+
if (!this.zwaveController) {
|
|
137
|
+
throw new Error('Controller not initialized');
|
|
138
|
+
}
|
|
139
|
+
this.zwaveController.setNodeName(nodeId, name);
|
|
140
|
+
// Update Homebridge Accessory Name
|
|
141
|
+
const accessory = this.zwaveAccessories.get(nodeId);
|
|
142
|
+
if (accessory) {
|
|
143
|
+
this.log.info(`Renaming HomeKit accessory for Node ${nodeId} to: ${name}`);
|
|
144
|
+
accessory.platformAccessory.displayName = name;
|
|
145
|
+
this.api.updatePlatformAccessories([accessory.platformAccessory]);
|
|
146
|
+
}
|
|
147
|
+
return sendJson({ success: true });
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
this.log.error(`Failed to rename node: ${err}`);
|
|
151
|
+
return sendJson({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
128
156
|
if (normalizedUrl.startsWith('/firmware/updates/') && method === 'GET') {
|
|
129
157
|
const nodeId = parseInt(normalizedUrl.split('/').pop() || '0', 10);
|
|
130
158
|
this.zwaveController?.getAvailableFirmwareUpdates(nodeId)
|
|
@@ -54,6 +54,11 @@ export declare class ZWaveController extends EventEmitter implements IZWaveContr
|
|
|
54
54
|
* that can no longer be physically excluded.
|
|
55
55
|
*/
|
|
56
56
|
removeFailedNode(nodeId: number): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Updates the user-defined name for a node in the Z-Wave network.
|
|
59
|
+
* This name is persisted by Z-Wave JS in its cache files.
|
|
60
|
+
*/
|
|
61
|
+
setNodeName(nodeId: number, name: string): void;
|
|
57
62
|
getAvailableFirmwareUpdates(nodeId: number): Promise<unknown[]>;
|
|
58
63
|
beginFirmwareUpdate(nodeId: number, update: unknown): Promise<void>;
|
|
59
64
|
abortFirmwareUpdate(nodeId: number): Promise<void>;
|
|
@@ -670,6 +670,18 @@ class ZWaveController extends events_1.EventEmitter {
|
|
|
670
670
|
throw err;
|
|
671
671
|
}
|
|
672
672
|
}
|
|
673
|
+
/**
|
|
674
|
+
* Updates the user-defined name for a node in the Z-Wave network.
|
|
675
|
+
* This name is persisted by Z-Wave JS in its cache files.
|
|
676
|
+
*/
|
|
677
|
+
setNodeName(nodeId, name) {
|
|
678
|
+
const node = this.nodes.get(nodeId);
|
|
679
|
+
if (!node) {
|
|
680
|
+
throw new Error(`Node ${nodeId} not found`);
|
|
681
|
+
}
|
|
682
|
+
this.log.info(`Updating name for Node ${nodeId} to: "${name}"`);
|
|
683
|
+
node.name = name;
|
|
684
|
+
}
|
|
673
685
|
async getAvailableFirmwareUpdates(nodeId) {
|
|
674
686
|
if (nodeId === 1) {
|
|
675
687
|
this.log.debug('Node 1 is the controller; skipping firmware update check.');
|
|
@@ -20,6 +20,7 @@ export interface IZWaveController extends EventEmitter {
|
|
|
20
20
|
startHealing(): Promise<boolean>;
|
|
21
21
|
stopHealing(): Promise<boolean>;
|
|
22
22
|
removeFailedNode(nodeId: number): Promise<void>;
|
|
23
|
+
setNodeName(nodeId: number, name: string): void;
|
|
23
24
|
setS2Pin(pin: string): void;
|
|
24
25
|
getAvailableFirmwareUpdates(nodeId: number): Promise<unknown[]>;
|
|
25
26
|
beginFirmwareUpdate(nodeId: number, update: unknown): Promise<void>;
|
|
@@ -1,363 +1,479 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
<
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
</div>
|
|
19
|
-
<div class="form-group">
|
|
20
|
-
<label for="serialPort">Serial Port Path</label>
|
|
21
|
-
<input type="text" class="form-control" id="serialPort" placeholder="/dev/ttyACM0" required />
|
|
22
|
-
<small class="form-text text-muted">
|
|
23
|
-
WARNING: Do not use /dev/ttyUSB0. Use /dev/serial/by-id/...
|
|
24
|
-
</small>
|
|
25
|
-
</div>
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Z-Wave USB Plugin</title>
|
|
6
|
+
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
|
7
|
+
<style>
|
|
8
|
+
/* Homebridge Theme Compatibility */
|
|
9
|
+
:root {
|
|
10
|
+
--primary-color: #007bff;
|
|
11
|
+
--secondary-color: #6c757d;
|
|
12
|
+
--success-color: #28a745;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
background-color: transparent !important;
|
|
17
|
+
}
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
.card {
|
|
20
|
+
margin-bottom: 20px;
|
|
21
|
+
background-color: var(--hb-background-color, #fff);
|
|
22
|
+
color: var(--hb-text-color, #212529);
|
|
23
|
+
border: 1px solid rgba(0,0,0,.125);
|
|
24
|
+
}
|
|
33
25
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</div>
|
|
38
|
-
<div class="form-group">
|
|
39
|
-
<label for="S2_Unauthenticated">S2 Unauthenticated</label>
|
|
40
|
-
<input type="text" class="form-control" id="S2_Unauthenticated" maxlength="32" />
|
|
41
|
-
</div>
|
|
42
|
-
<div class="form-group">
|
|
43
|
-
<label for="S2_Authenticated">S2 Authenticated</label>
|
|
44
|
-
<input type="text" class="form-control" id="S2_Authenticated" maxlength="32" />
|
|
45
|
-
</div>
|
|
46
|
-
<div class="form-group">
|
|
47
|
-
<label for="S2_AccessControl">S2 Access Control</label>
|
|
48
|
-
<input type="text" class="form-control" id="S2_AccessControl" maxlength="32" />
|
|
49
|
-
</div>
|
|
26
|
+
.table {
|
|
27
|
+
color: inherit;
|
|
28
|
+
}
|
|
50
29
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<label for="S2_Authenticated_LR">S2 Authenticated (LR)</label>
|
|
56
|
-
<input type="text" class="form-control" id="S2_Authenticated_LR" maxlength="32" />
|
|
57
|
-
</div>
|
|
58
|
-
<div class="form-group">
|
|
59
|
-
<label for="S2_AccessControl_LR">S2 Access Control (LR)</label>
|
|
60
|
-
<input type="text" class="form-control" id="S2_AccessControl_LR" maxlength="32" />
|
|
61
|
-
</div>
|
|
62
|
-
</fieldset>
|
|
30
|
+
.table thead th {
|
|
31
|
+
border-bottom: 2px solid var(--hb-border-color, #dee2e6);
|
|
32
|
+
color: var(--hb-text-color, #212529);
|
|
33
|
+
}
|
|
63
34
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
</div>
|
|
35
|
+
.table td {
|
|
36
|
+
border-top: 1px solid var(--hb-border-color, #dee2e6);
|
|
37
|
+
vertical-align: middle;
|
|
38
|
+
color: var(--hb-text-color, #212529);
|
|
39
|
+
}
|
|
70
40
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
41
|
+
.badge {
|
|
42
|
+
padding: 0.4em 0.6em;
|
|
43
|
+
border-radius: 0.25rem;
|
|
44
|
+
font-weight: 700;
|
|
45
|
+
color: #fff !important; /* Force white text for badges */
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.badge-success { background-color: var(--success-color); }
|
|
49
|
+
.badge-secondary { background-color: var(--secondary-color); }
|
|
50
|
+
.badge-light { background-color: #f8f9fa; color: #212529 !important; }
|
|
51
|
+
|
|
52
|
+
.nav-tabs { margin-bottom: 20px; border-bottom: 1px solid var(--hb-border-color, #dee2e6); }
|
|
53
|
+
.nav-link { color: var(--hb-text-color, #007bff); }
|
|
54
|
+
.nav-link.active {
|
|
55
|
+
background-color: var(--hb-background-color, #fff) !important;
|
|
56
|
+
color: var(--hb-text-color, #495057) !important;
|
|
57
|
+
border-color: var(--hb-border-color, #dee2e6) var(--hb-border-color, #dee2e6) transparent !important;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fieldset { border: 1px solid var(--hb-border-color, #ddd); padding: 15px; border-radius: 4px; margin-bottom: 15px; }
|
|
61
|
+
legend { width: auto; padding: 0 10px; font-size: 1.1rem; font-weight: bold; color: var(--hb-text-color, inherit); }
|
|
62
|
+
|
|
63
|
+
.text-muted { color: #6c757d !important; }
|
|
64
|
+
.form-control {
|
|
65
|
+
background-color: var(--hb-background-color, #fff);
|
|
66
|
+
color: var(--hb-text-color, #495057);
|
|
67
|
+
border: 1px solid var(--hb-border-color, #ced4da);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.btn-group .btn { margin-right: 2px; }
|
|
71
|
+
</style>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<div class="container-fluid p-3">
|
|
75
|
+
<ul class="nav nav-tabs mb-3" id="mainTabs" role="tablist">
|
|
76
|
+
<li class="nav-item">
|
|
77
|
+
<a class="nav-link active" id="config-tab" href="#config" role="tab">Configuration</a>
|
|
78
|
+
</li>
|
|
79
|
+
<li class="nav-item">
|
|
80
|
+
<a class="nav-link" id="maintenance-tab" href="#maintenance" role="tab">Maintenance</a>
|
|
81
|
+
</li>
|
|
82
|
+
</ul>
|
|
83
|
+
|
|
84
|
+
<div class="tab-content" id="mainTabsContent">
|
|
85
|
+
<!-- Configuration Tab -->
|
|
86
|
+
<div class="tab-pane fade show active" id="config" role="tabpanel">
|
|
87
|
+
<div class="card card-body">
|
|
88
|
+
<form id="configForm">
|
|
89
|
+
<div class="form-group">
|
|
90
|
+
<label for="name">Name</label>
|
|
91
|
+
<input type="text" class="form-control" id="name" placeholder="Homebridge Z-Wave USB" />
|
|
92
|
+
</div>
|
|
93
|
+
<div class="form-group">
|
|
94
|
+
<label for="serialPort">Serial Port Path</label>
|
|
95
|
+
<input type="text" class="form-control" id="serialPort" placeholder="/dev/ttyACM0" required />
|
|
96
|
+
<small class="form-text text-muted">
|
|
97
|
+
WARNING: Do not use /dev/ttyUSB0. Use /dev/serial/by-id/...
|
|
98
|
+
</small>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<fieldset>
|
|
102
|
+
<legend>Z-Wave Security Keys</legend>
|
|
103
|
+
<p class="text-muted small">Required for secure devices (Locks, Sensors).</p>
|
|
104
|
+
<button type="button" class="btn btn-outline-primary btn-sm mb-3" id="generateKeys">
|
|
105
|
+
Auto-Generate Keys
|
|
106
|
+
</button>
|
|
107
|
+
|
|
108
|
+
<div class="form-group">
|
|
109
|
+
<label for="S0_Legacy">S0 Legacy</label>
|
|
110
|
+
<input type="text" class="form-control" id="S0_Legacy" maxlength="32" />
|
|
111
|
+
</div>
|
|
112
|
+
<div class="form-group">
|
|
113
|
+
<label for="S2_Unauthenticated">S2 Unauthenticated</label>
|
|
114
|
+
<input type="text" class="form-control" id="S2_Unauthenticated" maxlength="32" />
|
|
115
|
+
</div>
|
|
116
|
+
<div class="form-group">
|
|
117
|
+
<label for="S2_Authenticated">S2 Authenticated</label>
|
|
118
|
+
<input type="text" class="form-control" id="S2_Authenticated" maxlength="32" />
|
|
119
|
+
</div>
|
|
120
|
+
<div class="form-group">
|
|
121
|
+
<label for="S2_AccessControl">S2 Access Control</label>
|
|
122
|
+
<input type="text" class="form-control" id="S2_AccessControl" maxlength="32" />
|
|
123
|
+
</div>
|
|
79
124
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
125
|
+
<p class="text-muted small mt-3">
|
|
126
|
+
Optional: Long Range specific keys (defaults to Classic S2 if empty)
|
|
127
|
+
</p>
|
|
128
|
+
<div class="form-group">
|
|
129
|
+
<label for="S2_Authenticated_LR">S2 Authenticated (LR)</label>
|
|
130
|
+
<input type="text" class="form-control" id="S2_Authenticated_LR" maxlength="32" />
|
|
131
|
+
</div>
|
|
132
|
+
<div class="form-group">
|
|
133
|
+
<label for="S2_AccessControl_LR">S2 Access Control (LR)</label>
|
|
134
|
+
<input type="text" class="form-control" id="S2_AccessControl_LR" maxlength="32" />
|
|
135
|
+
</div>
|
|
136
|
+
</fieldset>
|
|
137
|
+
|
|
138
|
+
<fieldset>
|
|
139
|
+
<legend>Advanced</legend>
|
|
140
|
+
<div class="form-group">
|
|
141
|
+
<label for="inclusionTimeoutSeconds">Inclusion Timeout (Seconds)</label>
|
|
142
|
+
<input type="number" class="form-control" id="inclusionTimeoutSeconds" placeholder="60" />
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div class="form-check mt-3">
|
|
146
|
+
<input type="checkbox" class="form-check-input" id="debug" />
|
|
147
|
+
<label class="form-check-label" for="debug">Enable Verbose Driver Logging</label>
|
|
148
|
+
</div>
|
|
149
|
+
</fieldset>
|
|
150
|
+
</form>
|
|
151
|
+
</div>
|
|
89
152
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
</
|
|
101
|
-
<
|
|
102
|
-
<
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
153
|
+
|
|
154
|
+
<!-- Maintenance Tab -->
|
|
155
|
+
<div class="tab-pane fade" id="maintenance" role="tabpanel">
|
|
156
|
+
<div class="card card-body">
|
|
157
|
+
<h5>Z-Wave Node Maintenance</h5>
|
|
158
|
+
<p class="text-muted small">
|
|
159
|
+
View and manage your Z-Wave nodes. Check for official firmware updates via Z-Wave JS Service.
|
|
160
|
+
</p>
|
|
161
|
+
<div class="alert alert-warning py-2 small" role="alert">
|
|
162
|
+
<strong>Beta Feature:</strong> Z-Wave Firmware Updates are provided as-is. Use at your own risk. Feedback is welcome!
|
|
163
|
+
</div>
|
|
164
|
+
<div class="table-responsive">
|
|
165
|
+
<table class="table table-sm" id="nodeTable">
|
|
166
|
+
<thead>
|
|
167
|
+
<tr>
|
|
168
|
+
<th>ID</th>
|
|
169
|
+
<th>Name</th>
|
|
170
|
+
<th>Status</th>
|
|
171
|
+
<th>Firmware</th>
|
|
172
|
+
<th>Action</th>
|
|
173
|
+
</tr>
|
|
174
|
+
</thead>
|
|
175
|
+
<tbody id="nodeTableBody">
|
|
176
|
+
<tr>
|
|
177
|
+
<td colspan="5" class="text-center">Loading nodes...</td>
|
|
178
|
+
</tr>
|
|
179
|
+
</tbody>
|
|
180
|
+
</table>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
107
183
|
</div>
|
|
108
184
|
</div>
|
|
109
185
|
</div>
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
|
-
<script>
|
|
113
|
-
(async () => {
|
|
114
|
-
let currentConfig = {};
|
|
115
|
-
|
|
116
|
-
const getInt = (id, def) => {
|
|
117
|
-
const el = document.getElementById(id);
|
|
118
|
-
if (!el) return def;
|
|
119
|
-
const val = parseInt(el.value, 10);
|
|
120
|
-
return isNaN(val) ? def : val;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const getConfig = () => {
|
|
124
|
-
const securityKeys = {
|
|
125
|
-
S0_Legacy: document.getElementById('S0_Legacy').value,
|
|
126
|
-
S2_Unauthenticated: document.getElementById('S2_Unauthenticated').value,
|
|
127
|
-
S2_Authenticated: document.getElementById('S2_Authenticated').value,
|
|
128
|
-
S2_AccessControl: document.getElementById('S2_AccessControl').value,
|
|
129
|
-
S2_Authenticated_LR: document.getElementById('S2_Authenticated_LR').value,
|
|
130
|
-
S2_AccessControl_LR: document.getElementById('S2_AccessControl_LR').value,
|
|
131
|
-
};
|
|
132
186
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
serialPort: document.getElementById('serialPort').value,
|
|
138
|
-
inclusionTimeoutSeconds: getInt('inclusionTimeoutSeconds', 60),
|
|
139
|
-
debug: document.getElementById('debug').checked,
|
|
140
|
-
};
|
|
187
|
+
<script>
|
|
188
|
+
(async () => {
|
|
189
|
+
let currentConfig = {};
|
|
190
|
+
let isPromptOpen = false;
|
|
141
191
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
192
|
+
const getInt = (id, def) => {
|
|
193
|
+
const el = document.getElementById(id);
|
|
194
|
+
if (!el) return def;
|
|
195
|
+
const val = parseInt(el.value, 10);
|
|
196
|
+
return isNaN(val) ? def : val;
|
|
197
|
+
};
|
|
147
198
|
|
|
148
|
-
|
|
149
|
-
|
|
199
|
+
const getConfig = () => {
|
|
200
|
+
const securityKeys = {
|
|
201
|
+
S0_Legacy: document.getElementById('S0_Legacy').value,
|
|
202
|
+
S2_Unauthenticated: document.getElementById('S2_Unauthenticated').value,
|
|
203
|
+
S2_Authenticated: document.getElementById('S2_Authenticated').value,
|
|
204
|
+
S2_AccessControl: document.getElementById('S2_AccessControl').value,
|
|
205
|
+
S2_Authenticated_LR: document.getElementById('S2_Authenticated_LR').value,
|
|
206
|
+
S2_AccessControl_LR: document.getElementById('S2_AccessControl_LR').value,
|
|
207
|
+
};
|
|
150
208
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
209
|
+
const newConfig = {
|
|
210
|
+
...currentConfig,
|
|
211
|
+
name: document.getElementById('name').value,
|
|
212
|
+
platform: 'ZWaveUSB',
|
|
213
|
+
serialPort: document.getElementById('serialPort').value,
|
|
214
|
+
inclusionTimeoutSeconds: getInt('inclusionTimeoutSeconds', 60),
|
|
215
|
+
debug: document.getElementById('debug').checked,
|
|
216
|
+
};
|
|
157
217
|
|
|
158
|
-
|
|
218
|
+
if (Object.values(securityKeys).some((k) => k && k.length > 0)) {
|
|
219
|
+
newConfig.securityKeys = securityKeys;
|
|
220
|
+
} else {
|
|
221
|
+
delete newConfig.securityKeys;
|
|
222
|
+
}
|
|
159
223
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const nodes = await window.homebridge.request('get-nodes');
|
|
163
|
-
const tbody = document.getElementById('nodeTableBody');
|
|
164
|
-
tbody.innerHTML = '';
|
|
224
|
+
return newConfig;
|
|
225
|
+
};
|
|
165
226
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
227
|
+
const updateConfig = async () => {
|
|
228
|
+
if (window.homebridge) {
|
|
229
|
+
const newConfig = getConfig();
|
|
230
|
+
await window.homebridge.updatePluginConfig([newConfig]);
|
|
169
231
|
}
|
|
232
|
+
};
|
|
170
233
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
234
|
+
const statusMap = ['Unknown', 'Asleep', 'Awake', 'Dead', 'Alive'];
|
|
235
|
+
|
|
236
|
+
const loadNodes = async () => {
|
|
237
|
+
if (isPromptOpen) return;
|
|
238
|
+
try {
|
|
239
|
+
const nodes = await window.homebridge.request('get-nodes');
|
|
240
|
+
const tbody = document.getElementById('nodeTableBody');
|
|
241
|
+
if (!tbody) return;
|
|
242
|
+
|
|
243
|
+
tbody.innerHTML = '';
|
|
244
|
+
|
|
245
|
+
if (!Array.isArray(nodes)) {
|
|
246
|
+
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-danger">Plugin not responding. Is it running?</td></tr>';
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
nodes.forEach((node) => {
|
|
251
|
+
const tr = document.createElement('tr');
|
|
252
|
+
const status = statusMap[node.status] || 'Unknown';
|
|
253
|
+
const progress = node.firmwareProgress;
|
|
254
|
+
const nodeName = node.name || node.label || 'Node ' + node.nodeId;
|
|
255
|
+
|
|
256
|
+
let actionHtml = '';
|
|
257
|
+
if (progress) {
|
|
258
|
+
const pct = Math.round((progress.sent / progress.total) * 100);
|
|
259
|
+
actionHtml = `
|
|
260
|
+
<div class="progress" style="height: 20px;">
|
|
261
|
+
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: ${pct}%">${pct}%</div>
|
|
262
|
+
</div>
|
|
263
|
+
`;
|
|
264
|
+
} else if (node.nodeId === 1) {
|
|
265
|
+
actionHtml = `<span class="badge badge-light">Controller</span>`;
|
|
266
|
+
} else {
|
|
267
|
+
actionHtml = `
|
|
268
|
+
<div class="btn-group">
|
|
269
|
+
<button class="btn btn-sm btn-outline-secondary rename-node" data-nodeid="${node.nodeId}">Rename</button>
|
|
270
|
+
<button class="btn btn-sm btn-outline-primary check-update" data-nodeid="${node.nodeId}">Check Update</button>
|
|
271
|
+
</div>
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
tr.innerHTML = `
|
|
276
|
+
<td>${node.nodeId}</td>
|
|
277
|
+
<td id="node-name-${node.nodeId}">${nodeName}</td>
|
|
278
|
+
<td><span class="badge badge-${status === 'Alive' || status === 'Awake' ? 'success' : 'secondary'}">${status}</span></td>
|
|
279
|
+
<td>${node.firmwareVersion || 'Unknown'}</td>
|
|
280
|
+
<td id="node-action-${node.nodeId}">${actionHtml}</td>
|
|
183
281
|
`;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
282
|
+
tbody.appendChild(tr);
|
|
283
|
+
});
|
|
284
|
+
} catch (err) {
|
|
285
|
+
const tbody = document.getElementById('nodeTableBody');
|
|
286
|
+
if (tbody) {
|
|
287
|
+
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
|
|
188
288
|
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
189
291
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<td>${node.firmwareVersion || 'Unknown'}</td>
|
|
195
|
-
<td id="node-action-${node.nodeId}">${actionHtml}</td>
|
|
196
|
-
`;
|
|
197
|
-
tbody.appendChild(tr);
|
|
198
|
-
});
|
|
292
|
+
// Event Delegation for Table Actions (Rename & Firmware)
|
|
293
|
+
document.getElementById('nodeTableBody').addEventListener('click', async (e) => {
|
|
294
|
+
const target = e.target.closest('button');
|
|
295
|
+
if (!target) return;
|
|
199
296
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
297
|
+
const nodeId = target.getAttribute('data-nodeid');
|
|
298
|
+
if (!nodeId) return;
|
|
299
|
+
|
|
300
|
+
// 1. Rename Logic
|
|
301
|
+
if (target.classList.contains('rename-node')) {
|
|
302
|
+
const nameCell = document.getElementById(`node-name-${nodeId}`);
|
|
303
|
+
const currentName = nameCell ? nameCell.textContent : '';
|
|
304
|
+
|
|
305
|
+
isPromptOpen = true;
|
|
306
|
+
const newName = prompt(`Enter new name for Node ${nodeId}:`, currentName);
|
|
307
|
+
isPromptOpen = false;
|
|
308
|
+
|
|
309
|
+
if (newName !== null && newName !== currentName && newName.trim() !== '') {
|
|
209
310
|
try {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const actionCell = document.getElementById(`node-action-${nodeId}`);
|
|
214
|
-
actionCell.innerHTML = `
|
|
215
|
-
<button class="btn btn-sm btn-success start-update" data-nodeid="${nodeId}" data-update='${JSON.stringify(update)}'>
|
|
216
|
-
Update to v${update.version}
|
|
217
|
-
</button>
|
|
218
|
-
<div class="small text-muted mt-1">Found update!</div>
|
|
219
|
-
`;
|
|
220
|
-
|
|
221
|
-
actionCell.querySelector('.start-update').addEventListener('click', async (e) => {
|
|
222
|
-
const isBattery = !node.isListening && !node.isFrequentListening;
|
|
223
|
-
const warning = isBattery
|
|
224
|
-
? 'WARNING: This is a battery-powered device. You MUST manually wake it up (usually by pressing a button on the device) immediately after clicking OK to receive the update. Proceed?'
|
|
225
|
-
: 'WARNING: Firmware updates carry risk. Do not unplug the bridge or device during the process. Proceed?';
|
|
226
|
-
|
|
227
|
-
if (!confirm(warning)) return;
|
|
228
|
-
|
|
229
|
-
const target = e.currentTarget;
|
|
230
|
-
const u = JSON.parse(target.getAttribute('data-update'));
|
|
231
|
-
target.disabled = true;
|
|
232
|
-
target.innerHTML = 'Starting...';
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
await window.homebridge.request('start-update', { nodeId, update: u });
|
|
236
|
-
window.homebridge.toast.success('Firmware update started!');
|
|
237
|
-
loadNodes();
|
|
238
|
-
} catch (err) {
|
|
239
|
-
window.homebridge.toast.error(err.message);
|
|
240
|
-
loadNodes();
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
} else {
|
|
244
|
-
window.homebridge.toast.info('No updates available for this device.');
|
|
245
|
-
e.target.disabled = false;
|
|
246
|
-
e.target.innerHTML = 'Check Update';
|
|
247
|
-
}
|
|
311
|
+
await window.homebridge.request('rename-node', { nodeId, name: newName.trim() });
|
|
312
|
+
window.homebridge.toast.success(`Node ${nodeId} renamed to ${newName}`);
|
|
313
|
+
loadNodes();
|
|
248
314
|
} catch (err) {
|
|
249
315
|
window.homebridge.toast.error(err.message);
|
|
250
|
-
e.target.disabled = false;
|
|
251
|
-
e.target.innerHTML = 'Check Update';
|
|
252
316
|
}
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
} catch (err) {
|
|
256
|
-
const tbody = document.getElementById('nodeTableBody');
|
|
257
|
-
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
const init = async () => {
|
|
262
|
-
if (!window.homebridge) return;
|
|
263
|
-
|
|
264
|
-
// Load Config
|
|
265
|
-
const pluginConfig = await window.homebridge.getPluginConfig();
|
|
266
|
-
currentConfig = pluginConfig[0] || { platform: 'ZWaveUSB' };
|
|
267
|
-
const config = currentConfig;
|
|
268
|
-
|
|
269
|
-
// Populate Form
|
|
270
|
-
document.getElementById('name').value = config.name || 'Homebridge Z-Wave USB';
|
|
271
|
-
document.getElementById('serialPort').value = config.serialPort || '';
|
|
272
|
-
|
|
273
|
-
// Keys
|
|
274
|
-
const keys = config.securityKeys || {};
|
|
275
|
-
document.getElementById('S0_Legacy').value = keys.S0_Legacy || '';
|
|
276
|
-
document.getElementById('S2_Unauthenticated').value = keys.S2_Unauthenticated || '';
|
|
277
|
-
document.getElementById('S2_Authenticated').value = keys.S2_Authenticated || '';
|
|
278
|
-
document.getElementById('S2_AccessControl').value = keys.S2_AccessControl || '';
|
|
279
|
-
document.getElementById('S2_Authenticated_LR').value = keys.S2_Authenticated_LR || '';
|
|
280
|
-
document.getElementById('S2_AccessControl_LR').value = keys.S2_AccessControl_LR || '';
|
|
281
|
-
|
|
282
|
-
// Advanced
|
|
283
|
-
document.getElementById('inclusionTimeoutSeconds').value =
|
|
284
|
-
config.inclusionTimeoutSeconds || 60;
|
|
285
|
-
document.getElementById('debug').checked = config.debug || false;
|
|
286
|
-
|
|
287
|
-
// Auto-save on change
|
|
288
|
-
document.getElementById('configForm').addEventListener('input', updateConfig);
|
|
289
|
-
document.getElementById('configForm').addEventListener('change', updateConfig);
|
|
290
|
-
|
|
291
|
-
// Generate Keys
|
|
292
|
-
document.getElementById('generateKeys').addEventListener('click', () => {
|
|
293
|
-
if (document.getElementById('S0_Legacy').value && !confirm('Overwrite existing keys?'))
|
|
317
|
+
}
|
|
294
318
|
return;
|
|
319
|
+
}
|
|
295
320
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
321
|
+
// 2. Check Update Logic
|
|
322
|
+
if (target.classList.contains('check-update')) {
|
|
323
|
+
target.disabled = true;
|
|
324
|
+
target.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const updates = await window.homebridge.request('check-firmware', nodeId);
|
|
328
|
+
if (updates && updates.length > 0) {
|
|
329
|
+
const update = updates[0];
|
|
330
|
+
const actionCell = document.getElementById(`node-action-${nodeId}`);
|
|
331
|
+
actionCell.innerHTML = `
|
|
332
|
+
<button class="btn btn-sm btn-success start-update" data-nodeid="${nodeId}" data-update='${JSON.stringify(update)}'>
|
|
333
|
+
Update to v${update.version}
|
|
334
|
+
</button>
|
|
335
|
+
<div class="small text-muted mt-1">Found update!</div>
|
|
336
|
+
`;
|
|
337
|
+
} else {
|
|
338
|
+
window.homebridge.toast.info('No updates available for this device.');
|
|
339
|
+
target.disabled = false;
|
|
340
|
+
target.innerHTML = 'Check Update';
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
window.homebridge.toast.error(err.message);
|
|
344
|
+
target.disabled = false;
|
|
345
|
+
target.innerHTML = 'Check Update';
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
303
349
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
document.getElementById('S2_Authenticated_LR').value = gen();
|
|
309
|
-
document.getElementById('S2_AccessControl_LR').value = gen();
|
|
350
|
+
// 3. Start Update Logic
|
|
351
|
+
if (target.classList.contains('start-update')) {
|
|
352
|
+
const updateData = JSON.parse(target.getAttribute('data-update'));
|
|
353
|
+
const warning = 'WARNING: Firmware updates carry risk. Do not unplug the bridge or device during the process. Proceed?';
|
|
310
354
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
355
|
+
isPromptOpen = true;
|
|
356
|
+
const confirmed = confirm(warning);
|
|
357
|
+
isPromptOpen = false;
|
|
358
|
+
|
|
359
|
+
if (!confirmed) return;
|
|
314
360
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
await updateConfig();
|
|
318
|
-
});
|
|
361
|
+
target.disabled = true;
|
|
362
|
+
target.innerHTML = 'Starting...';
|
|
319
363
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
364
|
+
try {
|
|
365
|
+
await window.homebridge.request('start-update', { nodeId, update: updateData });
|
|
366
|
+
window.homebridge.toast.success('Firmware update started!');
|
|
367
|
+
loadNodes();
|
|
368
|
+
} catch (err) {
|
|
369
|
+
window.homebridge.toast.error(err.message);
|
|
370
|
+
loadNodes();
|
|
371
|
+
}
|
|
325
372
|
}
|
|
326
|
-
}
|
|
373
|
+
});
|
|
327
374
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
375
|
+
const init = async () => {
|
|
376
|
+
if (!window.homebridge) return;
|
|
377
|
+
|
|
378
|
+
// Load Config
|
|
379
|
+
const pluginConfig = await window.homebridge.getPluginConfig();
|
|
380
|
+
currentConfig = pluginConfig[0] || { platform: 'ZWaveUSB' };
|
|
381
|
+
const config = currentConfig;
|
|
382
|
+
|
|
383
|
+
// Populate Form
|
|
384
|
+
document.getElementById('name').value = config.name || 'Homebridge Z-Wave USB';
|
|
385
|
+
document.getElementById('serialPort').value = config.serialPort || '';
|
|
386
|
+
|
|
387
|
+
// Keys
|
|
388
|
+
const keys = config.securityKeys || {};
|
|
389
|
+
document.getElementById('S0_Legacy').value = keys.S0_Legacy || '';
|
|
390
|
+
document.getElementById('S2_Unauthenticated').value = keys.S2_Unauthenticated || '';
|
|
391
|
+
document.getElementById('S2_Authenticated').value = keys.S2_Authenticated || '';
|
|
392
|
+
document.getElementById('S2_AccessControl').value = keys.S2_AccessControl || '';
|
|
393
|
+
document.getElementById('S2_Authenticated_LR').value = keys.S2_Authenticated_LR || '';
|
|
394
|
+
document.getElementById('S2_AccessControl_LR').value = keys.S2_AccessControl_LR || '';
|
|
395
|
+
|
|
396
|
+
// Advanced
|
|
397
|
+
document.getElementById('inclusionTimeoutSeconds').value =
|
|
398
|
+
config.inclusionTimeoutSeconds || 60;
|
|
399
|
+
document.getElementById('debug').checked = config.debug || false;
|
|
400
|
+
|
|
401
|
+
// Auto-save on change
|
|
402
|
+
document.getElementById('configForm').addEventListener('input', updateConfig);
|
|
403
|
+
document.getElementById('configForm').addEventListener('change', updateConfig);
|
|
404
|
+
|
|
405
|
+
// Generate Keys
|
|
406
|
+
document.getElementById('generateKeys').addEventListener('click', () => {
|
|
407
|
+
if (document.getElementById('S0_Legacy').value && !confirm('Overwrite existing keys?'))
|
|
408
|
+
return;
|
|
409
|
+
|
|
410
|
+
const gen = () => {
|
|
411
|
+
const arr = new Uint8Array(16);
|
|
412
|
+
window.crypto.getRandomValues(arr);
|
|
413
|
+
return Array.from(arr, (b) => b.toString(16).padStart(2, '0'))
|
|
414
|
+
.join('')
|
|
415
|
+
.toUpperCase();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
document.getElementById('S0_Legacy').value = gen();
|
|
419
|
+
document.getElementById('S2_Unauthenticated').value = gen();
|
|
420
|
+
document.getElementById('S2_Authenticated').value = gen();
|
|
421
|
+
document.getElementById('S2_AccessControl').value = gen();
|
|
422
|
+
document.getElementById('S2_Authenticated_LR').value = gen();
|
|
423
|
+
document.getElementById('S2_AccessControl_LR').value = gen();
|
|
424
|
+
|
|
425
|
+
updateConfig();
|
|
426
|
+
window.homebridge.toast.success('Keys generated and configuration updated!');
|
|
427
|
+
});
|
|
337
428
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const targetPane = document.getElementById(targetId);
|
|
343
|
-
if (targetPane) {
|
|
344
|
-
targetPane.classList.add('show', 'active');
|
|
345
|
-
}
|
|
429
|
+
// Save event listener as a backup
|
|
430
|
+
window.homebridge.addEventListener('submitting', async () => {
|
|
431
|
+
await updateConfig();
|
|
432
|
+
});
|
|
346
433
|
|
|
347
|
-
|
|
434
|
+
// Initialize nodes
|
|
435
|
+
loadNodes();
|
|
436
|
+
setInterval(() => {
|
|
437
|
+
if (document.getElementById('maintenance').classList.contains('active')) {
|
|
348
438
|
loadNodes();
|
|
349
439
|
}
|
|
440
|
+
}, 5000);
|
|
441
|
+
|
|
442
|
+
// Manual tab switching logic for environments without full Bootstrap JS
|
|
443
|
+
document.querySelectorAll('#mainTabs .nav-link').forEach((tab) => {
|
|
444
|
+
tab.addEventListener('click', (e) => {
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
const targetId = tab.getAttribute('href').replace('#', '');
|
|
447
|
+
|
|
448
|
+
// Update Tab Active State
|
|
449
|
+
document.querySelectorAll('#mainTabs .nav-link').forEach((t) => t.classList.remove('active'));
|
|
450
|
+
tab.classList.add('active');
|
|
451
|
+
|
|
452
|
+
// Update Content Active State
|
|
453
|
+
document.querySelectorAll('.tab-pane').forEach((pane) => {
|
|
454
|
+
pane.classList.remove('show', 'active');
|
|
455
|
+
});
|
|
456
|
+
const targetPane = document.getElementById(targetId);
|
|
457
|
+
if (targetPane) {
|
|
458
|
+
targetPane.classList.add('show', 'active');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (targetId === 'maintenance') {
|
|
462
|
+
loadNodes();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
350
465
|
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
</
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Try to initialize
|
|
469
|
+
if (window.homebridge && window.homebridge.getPluginConfig) {
|
|
470
|
+
init();
|
|
471
|
+
} else if (window.homebridge) {
|
|
472
|
+
window.homebridge.addEventListener('ready', init);
|
|
473
|
+
} else {
|
|
474
|
+
window.addEventListener('homebridgeReady', init);
|
|
475
|
+
}
|
|
476
|
+
})();
|
|
477
|
+
</script>
|
|
478
|
+
</body>
|
|
479
|
+
</html>
|
package/homebridge-ui/server.js
CHANGED
|
@@ -11,6 +11,10 @@ class UiServer extends HomebridgePluginUiServer {
|
|
|
11
11
|
return await this.ipcRequest('/nodes', 'GET');
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
this.onRequest('rename-node', async ({ nodeId, name }) => {
|
|
15
|
+
return await this.ipcRequest(`/nodes/${nodeId}/name`, 'POST', { name });
|
|
16
|
+
});
|
|
17
|
+
|
|
14
18
|
this.onRequest('check-firmware', async (nodeId) => {
|
|
15
19
|
return await this.ipcRequest(`/firmware/updates/${nodeId}`, 'GET');
|
|
16
20
|
});
|
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": "2.
|
|
4
|
+
"version": "2.3.0",
|
|
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": {
|