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 +15 -0
- package/package.json +1 -1
- package/src/hadiscovery.js +7 -0
- package/src/mqtt.js +5 -0
- package/src/openwrt.js +93 -30
- package/src/router.js +5 -0
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
package/src/hadiscovery.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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) => {
|