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 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "OpenWrt Control",
3
3
  "name": "homebridge-openwrt-control",
4
- "version": "0.4.2",
4
+ "version": "0.4.4",
5
5
  "description": "Homebridge plugin to control OpenWrt flashed devices.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
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: 5000,
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
- async ubusCall(service, method, params = {}) {
112
- const session = await this.login();
113
- const response = await this.axiosInstance.post('', {
114
- jsonrpc: '2.0',
115
- id: 2,
116
- method: 'call',
117
- params: [session, service, method, params]
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
- if (response.data?.error) throw new Error(response.data.error.message || 'Ubus call error');
160
+ // The router had forgotten the session, what it refused before may be allowed again
161
+ if (renewed) this.aclDenied.clear();
121
162
 
122
- return response.data.result[1];
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
- async getExtendedInfo() {
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, from the runtime wireless status
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 (!iface?.section || !iface?.ifname) continue;
242
- const clients = await optional('iwinfo', 'assoclist', { device: iface.ifname });
243
- info.ifaces[iface.section] = { ifname: iface.ifname, clients: Array.isArray(clients?.results) ? clients.results.length : null };
244
-
245
- // Channel and power of the radio from its first interface
246
- if (radioInfo.channel === null) {
247
- const ifaceInfo = await optional('iwinfo', 'info', { device: iface.ifname });
248
- radioInfo.channel = ifaceInfo?.channel ?? null;
249
- radioInfo.txpower = ifaceInfo?.txpower ?? null;
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)}`);