homebridge-openwrt-control 0.4.1 → 0.4.3

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,21 @@ 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.3] - (15.10.2026)
13
+
14
+ ### Changes
15
+
16
+ - 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
17
+ - 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
18
+ - 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
19
+ - 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
20
+
21
+ ## [0.4.2] - (29.09.2026)
22
+
23
+ ### Changes
24
+
25
+ - fixed: Home Assistant discovery, the config and the state are published again after a restart of the broker, a broker without persistence loses the retained messages
26
+
12
27
  ## [0.4.1] - (25.09.2026)
13
28
 
14
29
  ### 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.1",
4
+ "version": "0.4.3",
5
5
  "description": "Homebridge plugin to control OpenWrt flashed devices.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -53,6 +53,13 @@ class HaDiscovery {
53
53
  }
54
54
  }
55
55
 
56
+ // The broker may have lost the retained messages (restart without persistence), the next sync sends everything again
57
+ reset() {
58
+ this.lastConfig = {};
59
+ this.lastState = '';
60
+ this.lastImage = {};
61
+ }
62
+
56
63
  has(suffix, available) {
57
64
  if (available) this.seen.add(suffix);
58
65
  return this.seen.has(suffix);
package/src/mqtt.js CHANGED
@@ -36,6 +36,7 @@ class Mqtt extends EventEmitter {
36
36
  } : {})
37
37
  };
38
38
 
39
+ this.connected = false;
39
40
  const startTime = Date.now();
40
41
  let hasConnected = false;
41
42
  let warnedStalled = false;
@@ -45,6 +46,9 @@ class Mqtt extends EventEmitter {
45
46
  hasConnected = true;
46
47
  this.emit('connected', `MQTT v${protocolVersion} connected.`);
47
48
  if (this.haDiscovery) this.publishRetained(this.availabilityTopic, 'online').catch(() => { });
49
+ // Every connect, also when the broker was not running at start or restarted, devices publish again
50
+ this.connected = true;
51
+ this.emit('online');
48
52
 
49
53
  try {
50
54
  await new Promise((resolve, reject) => {
@@ -90,6 +94,7 @@ class Mqtt extends EventEmitter {
90
94
  if (config.logDebug) this.emit('debug', 'MQTT Reconnecting...');
91
95
  })
92
96
  .on('close', () => {
97
+ this.connected = false;
93
98
  if (!hasConnected && !warnedStalled && Date.now() - startTime > 30000) {
94
99
  warnedStalled = true;
95
100
  this.emit('warn', `MQTT has not connected after 30s of retries using protocol v${protocolVersion}. Check broker address, credentials, and whether the broker supports this MQTT protocol version.`);
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,43 @@ 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
+ async ubusCall(service, method, params = {}, retry = false) {
121
+ const key = `${service}.${method}`;
122
+
123
+ for (let attempt = 0; ; attempt++) {
124
+ const session = await this.login();
125
+
126
+ let response;
127
+ try {
128
+ response = await this.axiosInstance.post('', {
129
+ jsonrpc: '2.0',
130
+ id: 2,
131
+ method: 'call',
132
+ params: [session, service, method, params]
133
+ });
134
+ } catch (error) {
135
+ if (retry && attempt === 0 && TransientErrors.has(error.code)) continue;
136
+ throw error;
137
+ }
138
+
139
+ const error = response.data?.error;
140
+ if (error) {
141
+ const denied = error.code === -32002 || /access denied/i.test(error.message ?? '');
142
+ if (denied && retry && attempt === 0 && !this.aclDenied.has(key)) {
143
+ this.sessionId = null;
144
+ this.sessionExpiresAt = 0;
145
+ continue;
146
+ }
119
147
 
120
- if (response.data?.error) throw new Error(response.data.error.message || 'Ubus call error');
148
+ // Denied with a fresh session is the ACL, do not log in again for it on every poll
149
+ if (denied && retry) this.aclDenied.add(key);
150
+ throw new Error(error.message || 'Ubus call error');
151
+ }
121
152
 
122
- return response.data.result[1];
153
+ return response.data.result[1];
154
+ }
123
155
  }
124
156
 
125
157
  async connect() {
@@ -127,11 +159,11 @@ class OpenWrt extends EventEmitter {
127
159
  const openWrtInfo = { state: false, info: '', linkUp: false, wan: null, systemInfo: {}, networkInfo: {}, wirelessInfo: {}, wirelessRadios: [], wirelessSsids: [], switchPorts: [], extendedInfo: null };
128
160
 
129
161
  // System info
130
- const systemInfo = await this.ubusCall('system', 'board');
162
+ const systemInfo = await this.ubusCall('system', 'board', {}, true);
131
163
  if (this.logDebug) this.emit('debug', `System info data: ${JSON.stringify(systemInfo, null, 2)}`);
132
164
 
133
165
  // Link status
134
- const networkInterfaces = await this.ubusCall('network.interface', 'dump');
166
+ const networkInterfaces = await this.ubusCall('network.interface', 'dump', {}, true);
135
167
  if (this.logDebug) this.emit('debug', `Network interfaces data: ${JSON.stringify(networkInterfaces, null, 2)}`);
136
168
  const interfaces = networkInterfaces?.interface || [];
137
169
  const linkUp = interfaces.some(iface => {
@@ -144,7 +176,7 @@ class OpenWrt extends EventEmitter {
144
176
  const wan = wanIface ? { up: wanIface.up === true, ipv4: wanIface['ipv4-address']?.[0]?.address ?? null } : null;
145
177
 
146
178
  // Wireless info
147
- const wirelessInfo = await this.ubusCall('uci', 'get', { config: 'wireless' });
179
+ const wirelessInfo = await this.ubusCall('uci', 'get', { config: 'wireless' }, true);
148
180
  if (this.logDebug) this.emit('debug', `Wireless status data: ${JSON.stringify(redactWireless(wirelessInfo), null, 2)}`);
149
181
 
150
182
  // Radios list
@@ -195,7 +227,7 @@ class OpenWrt extends EventEmitter {
195
227
  openWrtInfo.wirelessInfo = wirelessInfo;
196
228
  openWrtInfo.wirelessRadios = radios;
197
229
  openWrtInfo.wirelessSsids = ssids;
198
- if (this.haDiscovery) openWrtInfo.extendedInfo = await this.getExtendedInfo();
230
+ if (this.haDiscovery) openWrtInfo.extendedInfo = await this.getExtendedInfo(ssids);
199
231
 
200
232
  this.emit('openWrtInfo', openWrtInfo);
201
233
 
@@ -207,13 +239,38 @@ class OpenWrt extends EventEmitter {
207
239
 
208
240
  // System info, radio channel and power, clients per SSID. Every call is optional, a router without iwinfo or
209
241
  // 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() {
242
+ // Radio interfaces (phy0-ap0) matched to the SSID sections, by SSID and band. An SSID that two interfaces of one
243
+ // band share is assigned in the order of the interfaces, so every section gets one interface
244
+ async matchInterfaces(optional, ssids) {
245
+ const bandOf = (frequency) => frequency < 3000 ? '2' : frequency < 5925 ? '5' : '6';
246
+ const devices = await optional('iwinfo', 'devices');
247
+ const used = new Set();
248
+ const matches = [];
249
+
250
+ for (const ifname of devices?.devices ?? []) {
251
+ const details = await optional('iwinfo', 'info', { device: ifname });
252
+ if (!details?.ssid) continue;
253
+
254
+ const candidates = ssids.filter(ssid => ssid.section && ssid.name === details.ssid && !used.has(ssid.section));
255
+ const band = bandOf(details.frequency);
256
+ const sameBand = candidates.filter(ssid => String(ssid.band ?? '').replace(/\D.*$/, '') === band);
257
+ const ssid = sameBand[0] ?? candidates.find(candidate => !candidate.band) ?? null;
258
+ if (!ssid) continue;
259
+
260
+ used.add(ssid.section);
261
+ matches.push({ radio: ssid.radio, section: ssid.section, ifname, up: true, details });
262
+ }
263
+
264
+ return matches;
265
+ }
266
+
267
+ async getExtendedInfo(ssids = []) {
211
268
  const now = Date.now();
212
269
  if (this.extendedInfo && now - this.extendedInfoAt < 30_000) return this.extendedInfo;
213
270
 
214
271
  const optional = async (service, method, params) => {
215
272
  try {
216
- return await this.ubusCall(service, method, params);
273
+ return await this.ubusCall(service, method, params, true);
217
274
  } catch (error) {
218
275
  if (this.logDebug) this.emit('debug', `Optional ubus call ${service} ${method} failed: ${error.message}`);
219
276
  return null;
@@ -233,23 +290,29 @@ class OpenWrt extends EventEmitter {
233
290
  };
234
291
  }
235
292
 
236
- // Interface names of the SSID sections, from the runtime wireless status
293
+ // Interface names of the SSID sections from the runtime wireless status. Current OpenWrt answers it with
294
+ // invalid argument over HTTP, then the interfaces are matched to the SSID sections by SSID and band
237
295
  const status = await optional('network.wireless', 'status');
296
+ const found = [];
238
297
  for (const [radio, radioStatus] of Object.entries(status ?? {})) {
239
- const radioInfo = { up: radioStatus?.up === true, channel: null, txpower: null };
240
298
  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
- }
299
+ if (iface?.section && iface?.ifname) found.push({ radio, section: iface.section, ifname: iface.ifname, up: radioStatus?.up === true });
300
+ }
301
+ }
302
+ if (found.length === 0) found.push(...await this.matchInterfaces(optional, ssids));
303
+
304
+ for (const { radio, section, ifname, up, details } of found) {
305
+ const radioInfo = info.radios[radio] ?? (info.radios[radio] = { up, channel: null, txpower: null });
306
+
307
+ const clients = await optional('iwinfo', 'assoclist', { device: ifname });
308
+ info.ifaces[section] = { ifname, clients: Array.isArray(clients?.results) ? clients.results.length : null };
309
+
310
+ // Channel and power of the radio from its first interface
311
+ if (radioInfo.channel === null) {
312
+ const ifaceInfo = details ?? await optional('iwinfo', 'info', { device: ifname });
313
+ radioInfo.channel = ifaceInfo?.channel ?? null;
314
+ radioInfo.txpower = ifaceInfo?.txpower ?? null;
251
315
  }
252
- info.radios[radio] = radioInfo;
253
316
  }
254
317
 
255
318
  if (this.logDebug) this.emit('debug', `Extended info: ${JSON.stringify(info)}`);
package/src/router.js CHANGED
@@ -224,6 +224,11 @@ class Router extends EventEmitter {
224
224
  .on('connected', msg => {
225
225
  this.mqttConnected = true;
226
226
  this.emit('success', msg);
227
+ })
228
+ .on('online', () => {
229
+ // Every connect, the broker may have lost the retained messages after a restart
230
+ this.mqttConnected = true;
231
+ this.ha?.reset();
227
232
  this.haSync();
228
233
  })
229
234
  .on('set', async (key, value) => {