homebridge-openwrt-control 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/openwrt.js +103 -30
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
- After update to v0.1.0 the plugin need to be reconfigured!
|
|
11
11
|
|
|
12
|
+
## [0.4.4] - (15.10.2026)
|
|
13
|
+
|
|
14
|
+
### Changes
|
|
15
|
+
|
|
16
|
+
- fixed: after a restart of the router the plugin could report `Access denied` for up to 4 minutes. A call that was refused twice in a row (the router was still starting) was remembered as denied by the ACL and the plugin did not log in again. Only optional calls (`iwinfo`) are remembered now, and only when the session was created for that call, a required call is read again on the next poll
|
|
17
|
+
- changed: what the ACL refused is forgotten after the session was renewed, so a changed ACL on the router is picked up without a restart of the plugin
|
|
18
|
+
|
|
19
|
+
## [0.4.3] - (15.10.2026)
|
|
20
|
+
|
|
21
|
+
### Changes
|
|
22
|
+
|
|
23
|
+
- fixed: Home Assistant discovery on current OpenWrt, `network.wireless status` is refused over HTTP with invalid argument, so channel, TX power and clients per SSID were always empty. The interfaces are now matched to the SSIDs with `iwinfo` by SSID and band, `network.wireless status` is still used first where it works
|
|
24
|
+
- fixed: after a router restart the cached session was unknown to the router and every call failed with `Access denied` until the session expired (up to 4 minutes), the plugin now logs in again once
|
|
25
|
+
- changed: the request timeout is 10 s (was 5 s) and a read is repeated once after a network error (timeout, reset, host unreachable), commands that change the router are never repeated
|
|
26
|
+
- changed: a call that the ACL of the user denies even with a fresh session is remembered, the plugin does not log in again on every poll for it
|
|
27
|
+
|
|
12
28
|
## [0.4.2] - (29.09.2026)
|
|
13
29
|
|
|
14
30
|
### Changes
|
package/package.json
CHANGED
package/src/openwrt.js
CHANGED
|
@@ -14,6 +14,10 @@ export function redactWireless(wirelessInfo) {
|
|
|
14
14
|
return { ...wirelessInfo, values };
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Small routers answer slowly now and then (5 s was too short for some), network errors that may pass on a second try
|
|
18
|
+
const RequestTimeout = 10_000;
|
|
19
|
+
const TransientErrors = new Set(['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE']);
|
|
20
|
+
|
|
17
21
|
class OpenWrt extends EventEmitter {
|
|
18
22
|
constructor(config) {
|
|
19
23
|
super();
|
|
@@ -32,10 +36,13 @@ class OpenWrt extends EventEmitter {
|
|
|
32
36
|
this.extendedInfoAt = 0;
|
|
33
37
|
this.sessionExpiresAt = 0;
|
|
34
38
|
|
|
39
|
+
// Calls the ACL of the user denies even with a fresh session, skipped instead of logging in again every time
|
|
40
|
+
this.aclDenied = new Set();
|
|
41
|
+
|
|
35
42
|
const baseUrl = `http://${config.host}/ubus`;
|
|
36
43
|
this.axiosInstance = axios.create({
|
|
37
44
|
baseURL: baseUrl,
|
|
38
|
-
timeout:
|
|
45
|
+
timeout: RequestTimeout,
|
|
39
46
|
headers: {
|
|
40
47
|
"Content-Type": "application/json"
|
|
41
48
|
}
|
|
@@ -108,18 +115,53 @@ class OpenWrt extends EventEmitter {
|
|
|
108
115
|
return this.sessionId;
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// retry is only for reads. One more try after a network hiccup, and one new login when the router no longer knows
|
|
119
|
+
// the cached session (a restarted router answers every call with Access denied until the cached session expires).
|
|
120
|
+
// optional marks a call a router may refuse by ACL, only such a call is remembered as denied, a required call is
|
|
121
|
+
// never given up on so a router that is still starting is read again on the next poll
|
|
122
|
+
async ubusCall(service, method, params = {}, retry = false, optional = false) {
|
|
123
|
+
const key = `${service}.${method}`;
|
|
124
|
+
let renewed = false;
|
|
125
|
+
|
|
126
|
+
for (let attempt = 0; ; attempt++) {
|
|
127
|
+
const cached = this.sessionId !== null && Date.now() < this.sessionExpiresAt;
|
|
128
|
+
const session = await this.login();
|
|
129
|
+
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await this.axiosInstance.post('', {
|
|
133
|
+
jsonrpc: '2.0',
|
|
134
|
+
id: 2,
|
|
135
|
+
method: 'call',
|
|
136
|
+
params: [session, service, method, params]
|
|
137
|
+
});
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (retry && attempt === 0 && TransientErrors.has(error.code)) continue;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const error = response.data?.error;
|
|
144
|
+
if (error) {
|
|
145
|
+
const denied = error.code === -32002 || /access denied/i.test(error.message ?? '');
|
|
146
|
+
|
|
147
|
+
// A cached session may be one the router forgot, log in again once. A call the ACL is known to refuse is skipped
|
|
148
|
+
if (denied && retry && attempt === 0 && cached && !(optional && this.aclDenied.has(key))) {
|
|
149
|
+
this.sessionId = null;
|
|
150
|
+
this.sessionExpiresAt = 0;
|
|
151
|
+
renewed = true;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Refused with a session that was just created is the ACL, not asked again with a new login on every poll
|
|
156
|
+
if (denied && optional && !cached) this.aclDenied.add(key);
|
|
157
|
+
throw new Error(error.message || 'Ubus call error');
|
|
158
|
+
}
|
|
119
159
|
|
|
120
|
-
|
|
160
|
+
// The router had forgotten the session, what it refused before may be allowed again
|
|
161
|
+
if (renewed) this.aclDenied.clear();
|
|
121
162
|
|
|
122
|
-
|
|
163
|
+
return response.data.result[1];
|
|
164
|
+
}
|
|
123
165
|
}
|
|
124
166
|
|
|
125
167
|
async connect() {
|
|
@@ -127,11 +169,11 @@ class OpenWrt extends EventEmitter {
|
|
|
127
169
|
const openWrtInfo = { state: false, info: '', linkUp: false, wan: null, systemInfo: {}, networkInfo: {}, wirelessInfo: {}, wirelessRadios: [], wirelessSsids: [], switchPorts: [], extendedInfo: null };
|
|
128
170
|
|
|
129
171
|
// System info
|
|
130
|
-
const systemInfo = await this.ubusCall('system', 'board');
|
|
172
|
+
const systemInfo = await this.ubusCall('system', 'board', {}, true);
|
|
131
173
|
if (this.logDebug) this.emit('debug', `System info data: ${JSON.stringify(systemInfo, null, 2)}`);
|
|
132
174
|
|
|
133
175
|
// Link status
|
|
134
|
-
const networkInterfaces = await this.ubusCall('network.interface', 'dump');
|
|
176
|
+
const networkInterfaces = await this.ubusCall('network.interface', 'dump', {}, true);
|
|
135
177
|
if (this.logDebug) this.emit('debug', `Network interfaces data: ${JSON.stringify(networkInterfaces, null, 2)}`);
|
|
136
178
|
const interfaces = networkInterfaces?.interface || [];
|
|
137
179
|
const linkUp = interfaces.some(iface => {
|
|
@@ -144,7 +186,7 @@ class OpenWrt extends EventEmitter {
|
|
|
144
186
|
const wan = wanIface ? { up: wanIface.up === true, ipv4: wanIface['ipv4-address']?.[0]?.address ?? null } : null;
|
|
145
187
|
|
|
146
188
|
// Wireless info
|
|
147
|
-
const wirelessInfo = await this.ubusCall('uci', 'get', { config: 'wireless' });
|
|
189
|
+
const wirelessInfo = await this.ubusCall('uci', 'get', { config: 'wireless' }, true);
|
|
148
190
|
if (this.logDebug) this.emit('debug', `Wireless status data: ${JSON.stringify(redactWireless(wirelessInfo), null, 2)}`);
|
|
149
191
|
|
|
150
192
|
// Radios list
|
|
@@ -195,7 +237,7 @@ class OpenWrt extends EventEmitter {
|
|
|
195
237
|
openWrtInfo.wirelessInfo = wirelessInfo;
|
|
196
238
|
openWrtInfo.wirelessRadios = radios;
|
|
197
239
|
openWrtInfo.wirelessSsids = ssids;
|
|
198
|
-
if (this.haDiscovery) openWrtInfo.extendedInfo = await this.getExtendedInfo();
|
|
240
|
+
if (this.haDiscovery) openWrtInfo.extendedInfo = await this.getExtendedInfo(ssids);
|
|
199
241
|
|
|
200
242
|
this.emit('openWrtInfo', openWrtInfo);
|
|
201
243
|
|
|
@@ -207,13 +249,38 @@ class OpenWrt extends EventEmitter {
|
|
|
207
249
|
|
|
208
250
|
// System info, radio channel and power, clients per SSID. Every call is optional, a router without iwinfo or
|
|
209
251
|
// without the ACL for it still reports the rest. Cached for 30 s, the calls per interface are not needed every poll
|
|
210
|
-
|
|
252
|
+
// Radio interfaces (phy0-ap0) matched to the SSID sections, by SSID and band. An SSID that two interfaces of one
|
|
253
|
+
// band share is assigned in the order of the interfaces, so every section gets one interface
|
|
254
|
+
async matchInterfaces(optional, ssids) {
|
|
255
|
+
const bandOf = (frequency) => frequency < 3000 ? '2' : frequency < 5925 ? '5' : '6';
|
|
256
|
+
const devices = await optional('iwinfo', 'devices');
|
|
257
|
+
const used = new Set();
|
|
258
|
+
const matches = [];
|
|
259
|
+
|
|
260
|
+
for (const ifname of devices?.devices ?? []) {
|
|
261
|
+
const details = await optional('iwinfo', 'info', { device: ifname });
|
|
262
|
+
if (!details?.ssid) continue;
|
|
263
|
+
|
|
264
|
+
const candidates = ssids.filter(ssid => ssid.section && ssid.name === details.ssid && !used.has(ssid.section));
|
|
265
|
+
const band = bandOf(details.frequency);
|
|
266
|
+
const sameBand = candidates.filter(ssid => String(ssid.band ?? '').replace(/\D.*$/, '') === band);
|
|
267
|
+
const ssid = sameBand[0] ?? candidates.find(candidate => !candidate.band) ?? null;
|
|
268
|
+
if (!ssid) continue;
|
|
269
|
+
|
|
270
|
+
used.add(ssid.section);
|
|
271
|
+
matches.push({ radio: ssid.radio, section: ssid.section, ifname, up: true, details });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return matches;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async getExtendedInfo(ssids = []) {
|
|
211
278
|
const now = Date.now();
|
|
212
279
|
if (this.extendedInfo && now - this.extendedInfoAt < 30_000) return this.extendedInfo;
|
|
213
280
|
|
|
214
281
|
const optional = async (service, method, params) => {
|
|
215
282
|
try {
|
|
216
|
-
return await this.ubusCall(service, method, params);
|
|
283
|
+
return await this.ubusCall(service, method, params, true, true);
|
|
217
284
|
} catch (error) {
|
|
218
285
|
if (this.logDebug) this.emit('debug', `Optional ubus call ${service} ${method} failed: ${error.message}`);
|
|
219
286
|
return null;
|
|
@@ -233,23 +300,29 @@ class OpenWrt extends EventEmitter {
|
|
|
233
300
|
};
|
|
234
301
|
}
|
|
235
302
|
|
|
236
|
-
// Interface names of the SSID sections
|
|
303
|
+
// Interface names of the SSID sections from the runtime wireless status. Current OpenWrt answers it with
|
|
304
|
+
// invalid argument over HTTP, then the interfaces are matched to the SSID sections by SSID and band
|
|
237
305
|
const status = await optional('network.wireless', 'status');
|
|
306
|
+
const found = [];
|
|
238
307
|
for (const [radio, radioStatus] of Object.entries(status ?? {})) {
|
|
239
|
-
const radioInfo = { up: radioStatus?.up === true, channel: null, txpower: null };
|
|
240
308
|
for (const iface of radioStatus?.interfaces ?? []) {
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
309
|
+
if (iface?.section && iface?.ifname) found.push({ radio, section: iface.section, ifname: iface.ifname, up: radioStatus?.up === true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (found.length === 0) found.push(...await this.matchInterfaces(optional, ssids));
|
|
313
|
+
|
|
314
|
+
for (const { radio, section, ifname, up, details } of found) {
|
|
315
|
+
const radioInfo = info.radios[radio] ?? (info.radios[radio] = { up, channel: null, txpower: null });
|
|
316
|
+
|
|
317
|
+
const clients = await optional('iwinfo', 'assoclist', { device: ifname });
|
|
318
|
+
info.ifaces[section] = { ifname, clients: Array.isArray(clients?.results) ? clients.results.length : null };
|
|
319
|
+
|
|
320
|
+
// Channel and power of the radio from its first interface
|
|
321
|
+
if (radioInfo.channel === null) {
|
|
322
|
+
const ifaceInfo = details ?? await optional('iwinfo', 'info', { device: ifname });
|
|
323
|
+
radioInfo.channel = ifaceInfo?.channel ?? null;
|
|
324
|
+
radioInfo.txpower = ifaceInfo?.txpower ?? null;
|
|
251
325
|
}
|
|
252
|
-
info.radios[radio] = radioInfo;
|
|
253
326
|
}
|
|
254
327
|
|
|
255
328
|
if (this.logDebug) this.emit('debug', `Extended info: ${JSON.stringify(info)}`);
|