homebridge-openwrt-control 0.3.9 → 0.4.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/CHANGELOG.md CHANGED
@@ -9,6 +9,26 @@ 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.0] - (25.09.2026)
13
+
14
+ ### Changes
15
+
16
+ - added: Home Assistant MQTT discovery (`HA Discovery` option in the MQTT section). The router appears as one device with native entities: switches for radios and SSIDs, WAN connectivity sensor, radio restart, reboot, network and Wi-Fi reload buttons, clients per SSID and total, channel and TX power per radio, WAN IP, firmware, last boot, load and memory usage
17
+ - added: optional Wi-Fi QR code image entity per access point SSID (`HA Wi-Fi QR Code` option), contains the Wi-Fi password
18
+ - added: MQTT and RESTful keys `Radio`, `RadioRestart` and `Ssid` with `{ "id": ..., "state": ... }`, the SSID is addressed by its UCI section
19
+ - added: MQTT availability topic (`<prefix>/Availability`, retained `online` / `offline` last will) when HA Discovery is enabled
20
+ - added: `iwinfo` read access in the ACL file, needed for clients, channel and TX power
21
+ - fix: a radio or SSID command sent during a poll was dropped, commands now wait for the running poll
22
+ - fix: the `Info` topic (MQTT and RESTful) and the wireless debug log contained the full UCI wireless config with the Wi-Fi passwords, secret options (`key`, passwords, RADIUS secrets) are now masked as `***`
23
+ - updated dependencies
24
+ - readme update
25
+
26
+ ## [0.3.10] - (24.09.2026)
27
+
28
+ ### Changes
29
+
30
+ - added: RESTFul request rate limit, 600 requests per minute per client and at most 1000 clients per minute, answered with `429` and `Retry-After`, one log warning per client and minute.
31
+
12
32
  ## [0.3.9] - (23.09.2026)
13
33
 
14
34
  ### Changes
package/README.md CHANGED
@@ -49,7 +49,7 @@
49
49
  * Radio `Enabled/Disabled`.
50
50
  * SSID `Enabled/Disabled`.
51
51
  * Siri, automations and schortcuts control all of available functions.
52
- * External integrations include: [REST](https://github.com/grzegorz914/homebridge-openwrt-control?tab=readme-ov-file#restful-integration) and [MQTT](https://github.com/grzegorz914/homebridge-openwrt-control?tab=readme-ov-file#mqtt-integration).
52
+ * External integrations include: [REST](https://github.com/grzegorz914/homebridge-openwrt-control?tab=readme-ov-file#restful-integration) and [MQTT](https://github.com/grzegorz914/homebridge-openwrt-control?tab=readme-ov-file#mqtt-integration), with [Home Assistant discovery](https://github.com/grzegorz914/homebridge-openwrt-control?tab=readme-ov-file#home-assistant-discovery).
53
53
 
54
54
  ## Configuration
55
55
 
@@ -107,6 +107,8 @@
107
107
  | `mqtt.clientId` | Here optional set the `Client Id` of MQTT Broker. |
108
108
  | `mqtt.prefix` | Here set the `Prefix` for `Topic` or leave empty. |
109
109
  | `mqtt.protocolVersion` | Here select the MQTT protocol version, `5.0` (default) or `3.1.1` for brokers that only support 3.1.1 (e.g. ioBroker MQTT adapter). |
110
+ | `mqtt.haDiscovery` | If enabled, the router is published to Home Assistant with MQTT discovery, see [Home Assistant Discovery](https://github.com/grzegorz914/homebridge-openwrt-control?tab=readme-ov-file#home-assistant-discovery). |
111
+ | `mqtt.haWifiQr` | If enabled, a QR code to join every access point SSID is published as an image entity. The QR code contains the Wi-Fi password and is sent to the MQTT broker. |
110
112
  | `mqtt.auth{}` | MQTT authorization object. |
111
113
  | `mqtt.auth.enable` | Here enable authorization for MQTT Broker. |
112
114
  | `mqtt.auth.user` | Here set the MQTT Broker `Username`. |
@@ -141,3 +143,27 @@ Subscribe using JSON `{ "SystemReboot": true }`
141
143
  | Subscribe | `Set` | `SystemReboot` | `true` | boolean | Reboot device |
142
144
  | | `Set` | `NetworkReload` | `true` | boolean | Network reload |
143
145
  | | `Set` | `WirelessReload` | `true` | boolean | Wireless Reload |
146
+ | | `Set` | `Radio` | `{ "id": "radio0", "state": true }` | object | Radio on/off, `id` is the radio name |
147
+ | | `Set` | `RadioRestart` | `{ "id": "radio0" }` | object | Radio restart |
148
+ | | `Set` | `Ssid` | `{ "id": "wifinet2", "state": true }` | object | SSID on/off, `id` is the UCI section of the wifi-iface |
149
+
150
+ ### Home Assistant Discovery
151
+
152
+ With `mqtt.haDiscovery` the router appears in Home Assistant as one device with native entities, no custom integration needed. Main entities:
153
+
154
+ | Entity | Type | Description |
155
+ | --- | --- | --- |
156
+ | `WAN` | binary_sensor (connectivity) | WAN interface up, `Internet` on routers without a `wan` interface |
157
+ | `Wi-Fi 2.4 GHz`, `Wi-Fi 5 GHz`... | switch | Radio on/off |
158
+ | `<SSID> <band>` | switch | SSID on/off |
159
+ | `Restart Wi-Fi <band>` | button (restart) | Radio restart |
160
+ | `Reboot`, `Reload network`, `Reload Wi-Fi` | button | System reboot, network and wireless reload |
161
+ | `Wi-Fi clients`, `<SSID> <band> clients` | sensor | Connected clients, total and per SSID |
162
+ | `Channel <band>`, `TX power <band>` | sensor (diagnostic) | Current radio channel and TX power in dBm |
163
+ | `WAN IP`, `Firmware`, `Last boot`, `Load`, `Memory usage` | sensor (diagnostic) | System information |
164
+ | `<SSID> <band> QR code` | image | QR code to join the network, only with `mqtt.haWifiQr` |
165
+
166
+ * Clients, channel and TX power need `iwinfo` on the router and the updated [ACL File](https://github.com/grzegorz914/homebridge-openwrt-control/blob/main/homebridge-acl.json), system information needs `system info`. Without them these entities are not created, the rest works.
167
+ * Switching an SSID or a radio restarts the radio, all networks of that radio disconnect for a few seconds. Do not switch off the network Home Assistant or the MQTT broker is connected through.
168
+ * SSID entities are identified by the UCI section of the wifi-iface, renaming the network keeps the entity. Entities of a removed SSID are removed from Home Assistant.
169
+ * Additional retained topics: `homeassistant/<component>/openwrt_<host>_<entity>/config` (discovery), `HA State` (JSON state of all entities), `Availability` (`online` / `offline` last will), `HA QR <section>` (SVG image).
@@ -557,6 +557,24 @@
557
557
  "functionBody": "return model.devices[arrayIndices].mqtt.enable == true;"
558
558
  }
559
559
  },
560
+ "haDiscovery": {
561
+ "title": "HA Discovery",
562
+ "type": "boolean",
563
+ "default": false,
564
+ "description": "Publish Home Assistant MQTT discovery messages so the router appears in Home Assistant with Wi-Fi switches for radios and SSIDs, WAN status, clients, system sensors and reboot / reload buttons. For clients, channel and TX power install the updated ACL file (iwinfo).",
565
+ "condition": {
566
+ "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;"
567
+ }
568
+ },
569
+ "haWifiQr": {
570
+ "title": "HA Wi-Fi QR Code",
571
+ "type": "boolean",
572
+ "default": false,
573
+ "description": "Publish a QR code image to join each access point SSID. The QR code contains the Wi-Fi password and is published to the MQTT broker.",
574
+ "condition": {
575
+ "functionBody": "return model.devices[arrayIndices].mqtt.enable === true && model.devices[arrayIndices].mqtt.haDiscovery === true;"
576
+ }
577
+ },
560
578
  "auth": {
561
579
  "title": "Authorization",
562
580
  "type": "object",
@@ -825,6 +843,8 @@
825
843
  "devices[].mqtt.clientId",
826
844
  "devices[].mqtt.prefix",
827
845
  "devices[].mqtt.protocolVersion",
846
+ "devices[].mqtt.haDiscovery",
847
+ "devices[].mqtt.haWifiQr",
828
848
  {
829
849
  "key": "devices[].mqtt.auth",
830
850
  "title": "Authorization",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "OpenWrt Control",
3
3
  "name": "homebridge-openwrt-control",
4
- "version": "0.3.9",
4
+ "version": "0.4.0",
5
5
  "description": "Homebridge plugin to control OpenWrt flashed devices.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -34,9 +34,10 @@
34
34
  "node": "^20 || ^22 || ^24 || ^25 || ^26"
35
35
  },
36
36
  "dependencies": {
37
- "mqtt": "^5.15.2",
38
37
  "axios": "^1.20.0",
39
- "express": "^5.2.1"
38
+ "express": "^5.2.1",
39
+ "mqtt": "^5.16.0",
40
+ "qrcode-generator": "^2.0.4"
40
41
  },
41
42
  "keywords": [
42
43
  "homebridge",
@@ -55,4 +56,4 @@
55
56
  "scripts": {
56
57
  "test": "echo \"Error: no test specified\" && exit 1"
57
58
  }
58
- }
59
+ }
@@ -0,0 +1,257 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import qrcode from 'qrcode-generator';
3
+
4
+ // SSIDs are UTF-8, the library encodes single bytes by default
5
+ qrcode.stringToBytes = (text) => [...Buffer.from(text, 'utf8')];
6
+
7
+ // Home Assistant MQTT discovery, native switch / binary_sensor / sensor / button / image entities.
8
+ // Commands go back to the existing <prefix>/Set topic as the JSON keys the plugin handles.
9
+ const BandLabel = { '2g': '2.4 GHz', '5g': '5 GHz', '6g': '6 GHz', '60g': '60 GHz' };
10
+
11
+ class HaDiscovery {
12
+ constructor(mqtt, config) {
13
+ // + and # are MQTT wildcards, Home Assistant would subscribe to a pattern instead of the device topic
14
+ if (/[+#]/.test(mqtt.config.prefix)) {
15
+ throw new Error(`MQTT prefix ${mqtt.config.prefix} must not contain + or #, rename the device or set a prefix`);
16
+ }
17
+
18
+ this.mqtt = mqtt;
19
+ this.baseId = `openwrt_${String(config.host).replace(/[^a-zA-Z0-9_-]/g, '_')}`;
20
+ this.stateTopic = `${mqtt.config.prefix}/HA State`;
21
+ this.commandTopic = `${mqtt.config.prefix}/Set`;
22
+ this.wifiQr = config.wifiQr === true;
23
+ this.device = {
24
+ identifiers: [this.baseId],
25
+ name: config.name,
26
+ manufacturer: config.manufacturer || 'OpenWrt',
27
+ ...(config.model ? { model: String(config.model) } : {}),
28
+ ...(config.swVersion ? { sw_version: String(config.swVersion) } : {}),
29
+ configuration_url: `http://${config.host}`
30
+ };
31
+ this.origin = { name: 'homebridge-openwrt-control', support_url: 'https://github.com/grzegorz914/homebridge-openwrt-control' };
32
+
33
+ // Topics published before a restart, entities that no longer exist are removed on the first sync
34
+ this.topicsFile = config.topicsFile;
35
+ this.knownTopics = new Set();
36
+ try {
37
+ for (const topic of JSON.parse(readFileSync(this.topicsFile, 'utf8'))) this.knownTopics.add(topic);
38
+ } catch {
39
+ // first start, nothing published yet
40
+ }
41
+
42
+ this.lastConfig = {};
43
+ this.lastState = '';
44
+ this.lastImage = {};
45
+ this.bootTime = null;
46
+
47
+ // Sensors stay once their data was seen, a disabled radio reports no clients or channel for a while
48
+ // and removing the entity would break the history and dashboards in Home Assistant
49
+ this.seen = new Set();
50
+ for (const topic of this.knownTopics) {
51
+ const suffix = topic.split('/').at(-2)?.slice(this.baseId.length + 1);
52
+ if (suffix) this.seen.add(suffix);
53
+ }
54
+ }
55
+
56
+ has(suffix, available) {
57
+ if (available) this.seen.add(suffix);
58
+ return this.seen.has(suffix);
59
+ }
60
+
61
+ // Publish all entities of the router, remove the ones that disappeared (SSID deleted, QR code disabled)
62
+ async sync(info) {
63
+ const { entities, images } = this.build(info);
64
+
65
+ const current = new Set();
66
+ for (const { component, suffix, entity } of entities) {
67
+ current.add(await this.publishEntity(component, suffix, entity));
68
+ }
69
+
70
+ for (const topic of this.knownTopics) {
71
+ if (current.has(topic)) continue;
72
+ await this.mqtt.publishRetained(topic, '');
73
+ delete this.lastConfig[topic];
74
+ }
75
+ for (const [topic, image] of Object.entries(images)) {
76
+ await this.updateImage(topic, image);
77
+ }
78
+ for (const topic of Object.keys(this.lastImage)) {
79
+ if (!(topic in images)) await this.updateImage(topic, null);
80
+ }
81
+
82
+ const changed = current.size !== this.knownTopics.size || [...current].some(topic => !this.knownTopics.has(topic));
83
+ this.knownTopics = current;
84
+ if (changed && this.topicsFile) {
85
+ try {
86
+ writeFileSync(this.topicsFile, JSON.stringify([...current], null, 2));
87
+ } catch {
88
+ // not critical, stale entities stay until removed in Home Assistant
89
+ }
90
+ }
91
+
92
+ await this.updateState(this.state(info));
93
+ }
94
+
95
+ // Publish (or republish when changed) one retained discovery message, returns its topic
96
+ async publishEntity(component, suffix, entity) {
97
+ const objectId = `${this.baseId}_${suffix}`;
98
+ const topic = `${this.mqtt.haPrefix}/${component}/${objectId}/config`;
99
+ const config = {
100
+ unique_id: objectId,
101
+ has_entity_name: true,
102
+ device: this.device,
103
+ origin: this.origin,
104
+ availability_topic: this.mqtt.availabilityTopic,
105
+ ...entity
106
+ };
107
+
108
+ const payload = JSON.stringify(config);
109
+ if (this.lastConfig[topic] !== payload) {
110
+ this.lastConfig[topic] = payload;
111
+ await this.mqtt.publishRetained(topic, payload);
112
+ }
113
+ return topic;
114
+ }
115
+
116
+ async updateState(state) {
117
+ const payload = JSON.stringify(state);
118
+ if (payload === this.lastState) return false;
119
+ this.lastState = payload;
120
+ await this.mqtt.publishRetained(this.stateTopic, payload);
121
+ return true;
122
+ }
123
+
124
+ // Retained image payload, an empty payload clears the topic
125
+ async updateImage(topic, image) {
126
+ if (this.lastImage[topic] === image) return false;
127
+ if (image === null) delete this.lastImage[topic];
128
+ else this.lastImage[topic] = image;
129
+ await this.mqtt.publishRetained(topic, image ?? '');
130
+ return true;
131
+ }
132
+
133
+ // ---------------------------------------------------------------- entities
134
+ build(info) {
135
+ const ext = info.extendedInfo ?? {};
136
+ const system = ext.system ?? {};
137
+ const entities = [];
138
+ const images = {};
139
+ const state = (path) => `{{ value_json${path} }}`;
140
+ const sensor = (suffix, entity) => entities.push({ component: 'sensor', suffix, entity: { state_topic: this.stateTopic, ...entity } });
141
+ const button = (suffix, payload, entity) => entities.push({ component: 'button', suffix, entity: { command_topic: this.commandTopic, payload_press: JSON.stringify(payload), ...entity } });
142
+ const toggle = (suffix, id, key, entity) => entities.push({
143
+ component: 'switch', suffix, entity: {
144
+ state_topic: this.stateTopic,
145
+ command_topic: this.commandTopic,
146
+ payload_on: JSON.stringify({ [key]: { id, state: true } }),
147
+ payload_off: JSON.stringify({ [key]: { id, state: false } }),
148
+ state_on: 'ON',
149
+ state_off: 'OFF',
150
+ ...entity
151
+ }
152
+ });
153
+
154
+ // Router
155
+ entities.push({ component: 'binary_sensor', suffix: 'wan', entity: { name: info.wan ? 'WAN' : 'Internet', device_class: 'connectivity', state_topic: this.stateTopic, value_template: `{{ 'ON' if value_json.wan else 'OFF' }}` } });
156
+ if (this.has('wan_ip', info.wan?.ipv4)) sensor('wan_ip', { name: 'WAN IP', icon: 'mdi:ip-network', entity_category: 'diagnostic', value_template: state('.wan_ip') });
157
+ if (info.systemInfo?.release?.version) sensor('firmware', { name: 'Firmware', icon: 'mdi:package-variant', entity_category: 'diagnostic', value_template: state('.firmware') });
158
+ if (this.has('boot', system.uptime != null)) sensor('boot', { name: 'Last boot', device_class: 'timestamp', entity_category: 'diagnostic', value_template: state('.boot') });
159
+ if (this.has('load', system.load != null)) sensor('load', { name: 'Load', icon: 'mdi:gauge', state_class: 'measurement', entity_category: 'diagnostic', value_template: state('.load') });
160
+ if (this.has('memory', system.memory != null)) sensor('memory', { name: 'Memory usage', icon: 'mdi:memory', unit_of_measurement: '%', state_class: 'measurement', entity_category: 'diagnostic', value_template: state('.memory') });
161
+ const clientsAvailable = this.has('clients', Object.values(ext.ifaces ?? {}).some(iface => iface.clients != null));
162
+ if (clientsAvailable) sensor('clients', { name: 'Wi-Fi clients', icon: 'mdi:account-multiple', state_class: 'measurement', value_template: state('.clients') });
163
+ button('reboot', { SystemReboot: true }, { name: 'Reboot', device_class: 'restart' });
164
+ button('network_reload', { NetworkReload: true }, { name: 'Reload network', icon: 'mdi:lan-pending', entity_category: 'config' });
165
+ button('wireless_reload', { WirelessReload: true }, { name: 'Reload Wi-Fi', icon: 'mdi:wifi-refresh', entity_category: 'config' });
166
+
167
+ // Radios
168
+ for (const radio of info.wirelessRadios ?? []) {
169
+ const id = radio.name;
170
+ const key = this.key(id);
171
+ const label = BandLabel[radio.band] ?? id;
172
+ const radioInfo = ext.radios?.[id] ?? {};
173
+ toggle(`radio_${key}`, id, 'Radio', { name: `Wi-Fi ${label}`, icon: 'mdi:wifi', value_template: `{{ 'ON' if value_json.radios['${id}'].on else 'OFF' }}` });
174
+ button(`radio_${key}_restart`, { RadioRestart: { id } }, { name: `Restart Wi-Fi ${label}`, device_class: 'restart', entity_category: 'config' });
175
+ if (this.has(`radio_${key}_channel`, radioInfo.channel != null)) sensor(`radio_${key}_channel`, { name: `Channel ${label}`, icon: 'mdi:access-point', entity_category: 'diagnostic', value_template: state(`.radios['${id}'].channel`) });
176
+ if (this.has(`radio_${key}_txpower`, radioInfo.txpower != null)) sensor(`radio_${key}_txpower`, { name: `TX power ${label}`, icon: 'mdi:signal', unit_of_measurement: 'dBm', state_class: 'measurement', entity_category: 'diagnostic', value_template: state(`.radios['${id}'].txpower`) });
177
+ }
178
+
179
+ // SSIDs, the id is the UCI section so renaming the network keeps the entity
180
+ const uci = info.wirelessInfo?.values ?? {};
181
+ for (const ssid of info.wirelessSsids ?? []) {
182
+ if (!ssid.section || !ssid.name) continue;
183
+ const id = ssid.section;
184
+ const key = this.key(id);
185
+ const label = `${ssid.name} ${BandLabel[ssid.band] ?? ssid.radio ?? ''}`.trim();
186
+ toggle(`ssid_${key}`, id, 'Ssid', { name: label, icon: 'mdi:wifi-lock', value_template: `{{ 'ON' if value_json.ssids['${id}'].on else 'OFF' }}` });
187
+ if (clientsAvailable && (ssid.mode ?? 'ap') === 'ap') sensor(`ssid_${key}_clients`, { name: `${label} clients`, icon: 'mdi:account-multiple', state_class: 'measurement', value_template: state(`.ssids['${id}'].clients`) });
188
+
189
+ // QR code to join the network, access points only, contains the password so it is opt in
190
+ if (this.wifiQr && (ssid.mode ?? 'ap') === 'ap') {
191
+ const imageTopic = `${this.mqtt.config.prefix}/HA QR ${id}`;
192
+ entities.push({ component: 'image', suffix: `ssid_${key}_qr`, entity: { name: `${label} QR code`, icon: 'mdi:qrcode', image_topic: imageTopic, content_type: 'image/svg+xml' } });
193
+ images[imageTopic] = this.wifiQrCode(ssid, uci[id] ?? {});
194
+ }
195
+ }
196
+
197
+ return { entities, images };
198
+ }
199
+
200
+ state(info) {
201
+ const ext = info.extendedInfo ?? {};
202
+ const system = ext.system ?? {};
203
+
204
+ // Boot time from the uptime, kept while it moves less than two minutes so the sensor does not change every poll
205
+ if (system.uptime != null) {
206
+ const boot = Date.now() - system.uptime * 1000;
207
+ if (this.bootTime === null || Math.abs(boot - this.bootTime) > 120_000) this.bootTime = Math.floor(boot / 60_000) * 60_000;
208
+ }
209
+
210
+ const radios = {};
211
+ for (const radio of info.wirelessRadios ?? []) {
212
+ const radioInfo = ext.radios?.[radio.name] ?? {};
213
+ radios[radio.name] = { on: !radio.disabled, channel: radioInfo.channel ?? null, txpower: radioInfo.txpower ?? null };
214
+ }
215
+
216
+ const ssids = {};
217
+ let clients = null;
218
+ for (const ssid of info.wirelessSsids ?? []) {
219
+ if (!ssid.section) continue;
220
+ const count = ext.ifaces?.[ssid.section]?.clients ?? null;
221
+ ssids[ssid.section] = { on: !ssid.disabled, clients: count };
222
+ if (count != null) clients = (clients ?? 0) + count;
223
+ }
224
+
225
+ return {
226
+ wan: info.wan ? info.wan.up : info.linkUp === true,
227
+ wan_ip: info.wan?.ipv4 ?? null,
228
+ firmware: info.systemInfo?.release?.version ?? null,
229
+ boot: system.uptime != null ? new Date(this.bootTime).toISOString() : null,
230
+ load: system.load ?? null,
231
+ memory: system.memory ?? null,
232
+ clients,
233
+ radios,
234
+ ssids
235
+ };
236
+ }
237
+
238
+ // WIFI:T:<type>;S:<ssid>;P:<password>;H:<hidden>;; as read by the phone cameras, as SVG
239
+ wifiQrCode(ssid, uci) {
240
+ const escape = (text) => String(text ?? '').replace(/([\\;,:"])/g, '\\$1');
241
+ const encryption = String(uci.encryption ?? 'none');
242
+ const type = encryption.startsWith('wep') ? 'WEP' : ['none', 'owe'].includes(encryption) || !uci.key ? 'nopass' : 'WPA';
243
+ const text = `WIFI:T:${type};S:${escape(ssid.name)};${type === 'nopass' ? '' : `P:${escape(uci.key)};`}${ssid.hidden ? 'H:true;' : ''};`;
244
+
245
+ const qr = qrcode(0, 'M');
246
+ qr.addData(text, 'Byte');
247
+ qr.make();
248
+ return qr.createSvgTag({ cellSize: 8, margin: 4, scalable: true });
249
+ }
250
+
251
+ // Object id part of a radio or UCI section name
252
+ key(id) {
253
+ return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
254
+ }
255
+ }
256
+
257
+ export default HaDiscovery;
package/src/mqtt.js CHANGED
@@ -12,12 +12,20 @@ class Mqtt extends EventEmitter {
12
12
  const isV5 = protocolVersion === 5;
13
13
  this.isV5 = isV5;
14
14
 
15
+ // Home Assistant discovery, availability is reported with a retained last will
16
+ this.haDiscovery = config.haDiscovery === true;
17
+ this.haPrefix = config.haPrefix || 'homeassistant';
18
+ this.availabilityTopic = `${config.prefix}/Availability`;
19
+
15
20
  const options = {
16
21
  clientId: config.clientId,
17
22
  username: config.user,
18
23
  password: config.passwd,
19
24
  protocolVersion,
20
25
  clean: !isV5,
26
+ ...(this.haDiscovery ? {
27
+ will: { topic: this.availabilityTopic, payload: 'offline', qos: 1, retain: true }
28
+ } : {}),
21
29
  ...(isV5 ? {
22
30
  properties: {
23
31
  sessionExpiryInterval: 60 * 60,
@@ -36,6 +44,7 @@ class Mqtt extends EventEmitter {
36
44
  .on('connect', async () => {
37
45
  hasConnected = true;
38
46
  this.emit('connected', `MQTT v${protocolVersion} connected.`);
47
+ if (this.haDiscovery) this.publishRetained(this.availabilityTopic, 'online').catch(() => { });
39
48
 
40
49
  try {
41
50
  await new Promise((resolve, reject) => {
@@ -89,6 +98,24 @@ class Mqtt extends EventEmitter {
89
98
  });
90
99
  }
91
100
 
101
+ publishRetained(fullTopic, payload) {
102
+ return new Promise((resolve, reject) => {
103
+ // + and # are wildcards, the client would never complete such a publish
104
+ if (/[+#]/.test(fullTopic)) {
105
+ return reject(new Error(`Invalid topic ${fullTopic}, the name or prefix must not contain + or #`));
106
+ }
107
+
108
+ this.mqttClient.publish(fullTopic, payload, { qos: 1, retain: true }, (error) => {
109
+ if (error) {
110
+ if (this.config.logWarn) this.emit('warn', `MQTT Publish error: ${error.message}`);
111
+ return reject(error);
112
+ }
113
+ if (this.config.logDebug) this.emit('debug', `MQTT Publish retained Topic: ${fullTopic}, Payload: ${payload}`);
114
+ resolve();
115
+ });
116
+ });
117
+ }
118
+
92
119
  publish(topic, message) {
93
120
  return new Promise((resolve, reject) => {
94
121
  const fullTopic = `${this.config.prefix}/${topic}`;
package/src/openwrt.js CHANGED
@@ -2,6 +2,18 @@ import EventEmitter from 'events';
2
2
  import axios from 'axios';
3
3
  import ImpulseGenerator from './impulsegenerator.js';
4
4
 
5
+ // Wi-Fi passwords and RADIUS secrets of the UCI wireless config, masked in everything published or logged
6
+ const SecretOption = /^key[1-4]?$|pass|secret|_pwd$|^wpa_psk/i;
7
+ export function redactWireless(wirelessInfo) {
8
+ if (!wirelessInfo?.values) return wirelessInfo;
9
+
10
+ const values = {};
11
+ for (const [section, data] of Object.entries(wirelessInfo.values)) {
12
+ values[section] = Object.fromEntries(Object.entries(data ?? {}).map(([option, value]) => [option, SecretOption.test(option) ? '***' : value]));
13
+ }
14
+ return { ...wirelessInfo, values };
15
+ }
16
+
5
17
  class OpenWrt extends EventEmitter {
6
18
  constructor(config) {
7
19
  super();
@@ -13,6 +25,11 @@ class OpenWrt extends EventEmitter {
13
25
 
14
26
  this.lock = false;
15
27
  this.sessionId = null;
28
+
29
+ // Home Assistant discovery needs more data (system, radio channel and power, clients), read at most every 30 s
30
+ this.haDiscovery = config.mqtt?.enable === true && config.mqtt?.haDiscovery === true;
31
+ this.extendedInfo = null;
32
+ this.extendedInfoAt = 0;
16
33
  this.sessionExpiresAt = 0;
17
34
 
18
35
  const baseUrl = `http://${config.host}/ubus`;
@@ -46,6 +63,28 @@ class OpenWrt extends EventEmitter {
46
63
  }
47
64
  }
48
65
 
66
+ // Commands wait for a running poll instead of being dropped, errors are reported like the poll errors
67
+ async runExclusive(fn) {
68
+ while (this.lock) await new Promise(resolve => setTimeout(resolve, 100));
69
+ this.lock = true;
70
+
71
+ try {
72
+ await fn();
73
+ } catch (error) {
74
+ this.emit('error', `Send error: ${error.message}`);
75
+ } finally {
76
+ this.lock = false;
77
+ }
78
+ }
79
+
80
+ // Read the state now, used after a command so Home Assistant does not wait for the next poll
81
+ async refresh() {
82
+ await this.handleWithLock(async () => {
83
+ this.extendedInfoAt = 0;
84
+ await this.connect();
85
+ });
86
+ }
87
+
49
88
  async login() {
50
89
  const now = Date.now();
51
90
  if (this.sessionId && now < this.sessionExpiresAt) {
@@ -85,7 +124,7 @@ class OpenWrt extends EventEmitter {
85
124
 
86
125
  async connect() {
87
126
  try {
88
- const openWrtInfo = { state: false, info: '', linkUp: false, systemInfo: {}, networkInfo: {}, wirelessInfo: {}, wirelessRadios: [], wirelessSsids: [], switchPorts: [] };
127
+ const openWrtInfo = { state: false, info: '', linkUp: false, wan: null, systemInfo: {}, networkInfo: {}, wirelessInfo: {}, wirelessRadios: [], wirelessSsids: [], switchPorts: [], extendedInfo: null };
89
128
 
90
129
  // System info
91
130
  const systemInfo = await this.ubusCall('system', 'board');
@@ -100,9 +139,13 @@ class OpenWrt extends EventEmitter {
100
139
  return ((Array.isArray(iface['ipv4-address']) && iface['ipv4-address'].length > 0) || (Array.isArray(iface['ipv6-address']) && iface['ipv6-address'].length > 0));
101
140
  });
102
141
 
142
+ // WAN interface, routers without an interface named wan (access points) have none
143
+ const wanIface = interfaces.find(iface => iface?.interface === 'wan');
144
+ const wan = wanIface ? { up: wanIface.up === true, ipv4: wanIface['ipv4-address']?.[0]?.address ?? null } : null;
145
+
103
146
  // Wireless info
104
147
  const wirelessInfo = await this.ubusCall('uci', 'get', { config: 'wireless' });
105
- if (this.logDebug) this.emit('debug', `Wireless status data: ${JSON.stringify(wirelessInfo, null, 2)}`);
148
+ if (this.logDebug) this.emit('debug', `Wireless status data: ${JSON.stringify(redactWireless(wirelessInfo), null, 2)}`);
106
149
 
107
150
  // Radios list
108
151
  const radios = Object.entries(wirelessInfo?.values || {}).filter(([, data]) => data['.type'] === 'wifi-device').map(([key, data]) => {
@@ -112,6 +155,7 @@ class OpenWrt extends EventEmitter {
112
155
 
113
156
  return {
114
157
  name,
158
+ section: data['.name'] || key,
115
159
  band,
116
160
  disabled
117
161
  };
@@ -132,6 +176,7 @@ class OpenWrt extends EventEmitter {
132
176
 
133
177
  return {
134
178
  radio,
179
+ section: data['.name'] || key,
135
180
  band,
136
181
  name,
137
182
  mode,
@@ -144,11 +189,13 @@ class OpenWrt extends EventEmitter {
144
189
  openWrtInfo.state = true;
145
190
  openWrtInfo.info = 'Connect Success';
146
191
  openWrtInfo.linkUp = linkUp;
192
+ openWrtInfo.wan = wan;
147
193
  openWrtInfo.systemInfo = systemInfo;
148
194
  openWrtInfo.networkInfo = networkInterfaces;
149
195
  openWrtInfo.wirelessInfo = wirelessInfo;
150
196
  openWrtInfo.wirelessRadios = radios;
151
197
  openWrtInfo.wirelessSsids = ssids;
198
+ if (this.haDiscovery) openWrtInfo.extendedInfo = await this.getExtendedInfo();
152
199
 
153
200
  this.emit('openWrtInfo', openWrtInfo);
154
201
 
@@ -158,12 +205,65 @@ class OpenWrt extends EventEmitter {
158
205
  }
159
206
  }
160
207
 
208
+ // System info, radio channel and power, clients per SSID. Every call is optional, a router without iwinfo or
209
+ // 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() {
211
+ const now = Date.now();
212
+ if (this.extendedInfo && now - this.extendedInfoAt < 30_000) return this.extendedInfo;
213
+
214
+ const optional = async (service, method, params) => {
215
+ try {
216
+ return await this.ubusCall(service, method, params);
217
+ } catch (error) {
218
+ if (this.logDebug) this.emit('debug', `Optional ubus call ${service} ${method} failed: ${error.message}`);
219
+ return null;
220
+ }
221
+ };
222
+
223
+ const info = { system: null, radios: {}, ifaces: {} };
224
+
225
+ const system = await optional('system', 'info');
226
+ if (system) {
227
+ const memory = system.memory ?? {};
228
+ const available = memory.available ?? ((memory.free ?? 0) + (memory.buffered ?? 0) + (memory.cached ?? 0));
229
+ info.system = {
230
+ uptime: system.uptime ?? null,
231
+ load: Array.isArray(system.load) ? Math.round(system.load[0] / 65536 * 100) / 100 : null,
232
+ memory: memory.total ? Math.round((1 - available / memory.total) * 100) : null
233
+ };
234
+ }
235
+
236
+ // Interface names of the SSID sections, from the runtime wireless status
237
+ const status = await optional('network.wireless', 'status');
238
+ for (const [radio, radioStatus] of Object.entries(status ?? {})) {
239
+ const radioInfo = { up: radioStatus?.up === true, channel: null, txpower: null };
240
+ 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
+ }
251
+ }
252
+ info.radios[radio] = radioInfo;
253
+ }
254
+
255
+ if (this.logDebug) this.emit('debug', `Extended info: ${JSON.stringify(info)}`);
256
+ this.extendedInfo = info;
257
+ this.extendedInfoAt = now;
258
+ return info;
259
+ }
260
+
161
261
  async send(type, radioName, ssidName, newSsidName = null, state, command, restart = false) {
162
262
  switch (type) {
163
263
  case 'router':
164
264
  break;
165
265
  case 'radio':
166
- await this.handleWithLock(async () => {
266
+ await this.runExclusive(async () => {
167
267
  if (this.logDebug) this.emit('debug', `${restart ? 'Restart' : state ? 'Enabling' : 'Disabling'} radio ${radioName}`);
168
268
 
169
269
  // Toggle radio
@@ -202,7 +302,7 @@ class OpenWrt extends EventEmitter {
202
302
  });
203
303
  break;
204
304
  case 'ssid':
205
- await this.handleWithLock(async () => {
305
+ await this.runExclusive(async () => {
206
306
  if (this.logDebug) this.emit('debug', `${state ? 'Enabling' : 'Disabling'} SSID ${ssidName} on ${radioName}`);
207
307
 
208
308
  // Get wireless config with UCI
package/src/restful.js CHANGED
@@ -23,6 +23,49 @@ class RestFul extends EventEmitter {
23
23
  try {
24
24
  const app = express();
25
25
  app.set('json spaces', 2);
26
+
27
+ // Request rate limit per client, protects Homebridge from request floods (CWE-770)
28
+ const rateLimitWindow = 60 * 1000;
29
+ const rateLimitMax = 600;
30
+ const rateLimitClients = 1000;
31
+ const clients = new Map();
32
+ let clientsLimitWarned = 0;
33
+ app.use((req, res, next) => {
34
+ const now = Date.now();
35
+
36
+ // Drop expired windows so the map cannot grow without limit
37
+ if (clients.size > 100) {
38
+ for (const [ip, client] of clients) {
39
+ if (now - client.start >= rateLimitWindow) clients.delete(ip);
40
+ }
41
+ }
42
+
43
+ let client = clients.get(req.ip);
44
+ if (!client || now - client.start >= rateLimitWindow) {
45
+ // Hard limit of tracked addresses, new addresses wait until older windows expire
46
+ if (!client && clients.size >= rateLimitClients) {
47
+ if (now - clientsLimitWarned >= rateLimitWindow) {
48
+ clientsLimitWarned = now;
49
+ if (this.logWarn) this.emit('warn', `RESTFul too many clients (${rateLimitClients}) in one minute, new clients are rejected until the window ends`);
50
+ }
51
+ res.set('Retry-After', String(Math.ceil(rateLimitWindow / 1000)));
52
+ return res.status(429).json({ error: 'RESTFul Too Many Requests' });
53
+ }
54
+ client = { count: 0, start: now, warned: false };
55
+ clients.set(req.ip, client);
56
+ }
57
+
58
+ if (++client.count > rateLimitMax) {
59
+ // Warn once per client and window, a flood must not flood the log too
60
+ if (!client.warned && this.logWarn) this.emit('warn', `RESTFul rate limit ${rateLimitMax} requests per minute exceeded by: ${req.ip}, further requests are rejected until the window ends`);
61
+ client.warned = true;
62
+ res.set('Retry-After', String(Math.ceil((client.start + rateLimitWindow - now) / 1000)));
63
+ return res.status(429).json({ error: 'RESTFul Too Many Requests' });
64
+ }
65
+
66
+ next();
67
+ });
68
+
26
69
  app.use(json());
27
70
 
28
71
  // Optional token auth, when a token is configured every route requires "Authorization: Bearer <token>"
package/src/router.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import EventEmitter from 'events';
2
+ import { join } from 'path';
2
3
  import RestFul from './restful.js';
3
4
  import Mqtt from './mqtt.js';
5
+ import HaDiscovery from './hadiscovery.js';
6
+ import { redactWireless } from './openwrt.js';
4
7
 
5
8
  let Accessory, Characteristic, Service, Categories, AccessoryUUID;
6
9
 
@@ -8,6 +11,7 @@ class Router extends EventEmitter {
8
11
  constructor(api, config, openWrt, openWrtInfo) {
9
12
  super();
10
13
 
14
+ this.storagePath = join(api.user.storagePath(), 'openWrt');
11
15
  Accessory = api.platformAccessory;
12
16
  Characteristic = api.hap.Characteristic;
13
17
  Service = api.hap.Service;
@@ -176,8 +180,11 @@ class Router extends EventEmitter {
176
180
  }
177
181
 
178
182
  // External integrations
179
- if (this.restFulConnected) this.restFul1.update('info', openWrtInfo);
180
- if (this.mqttConnected) await this.mqtt1.publish('Info', openWrtInfo);
183
+ // Wi-Fi passwords are masked, the full config stays internal (QR code)
184
+ const publicInfo = { ...openWrtInfo, wirelessInfo: redactWireless(openWrtInfo.wirelessInfo) };
185
+ if (this.restFulConnected) this.restFul1.update('info', publicInfo);
186
+ if (this.mqttConnected) await this.mqtt1.publish('Info', publicInfo);
187
+ await this.haSync();
181
188
  });
182
189
  }
183
190
 
@@ -210,12 +217,14 @@ class Router extends EventEmitter {
210
217
  protocolVersion: this.mqtt.protocolVersion,
211
218
  user: this.mqtt.auth?.user,
212
219
  passwd: this.mqtt.auth?.passwd,
220
+ haDiscovery: this.mqtt.haDiscovery,
213
221
  logWarn: this.logWarn,
214
222
  logDebug: this.logDebug
215
223
  })
216
224
  .on('connected', msg => {
217
225
  this.mqttConnected = true;
218
226
  this.emit('success', msg);
227
+ this.haSync();
219
228
  })
220
229
  .on('set', async (key, value) => {
221
230
  await this.setOverExternalIntegration('MQTT', key, value);
@@ -228,10 +237,66 @@ class Router extends EventEmitter {
228
237
  return true;
229
238
  }
230
239
 
240
+ // Home Assistant discovery, published on connect and refreshed with every poll
241
+ async haSync() {
242
+ if (!this.mqttConnected || !this.mqtt.haDiscovery) return;
243
+
244
+ try {
245
+ this.ha ??= new HaDiscovery(this.mqtt1, {
246
+ host: this.host,
247
+ name: this.name,
248
+ manufacturer: this.openWrtInfo.systemInfo.release?.distribution,
249
+ model: this.openWrtInfo.systemInfo.model,
250
+ swVersion: this.openWrtInfo.systemInfo.release?.version,
251
+ wifiQr: this.mqtt.haWifiQr,
252
+ topicsFile: join(this.storagePath, `${this.host.replace(/[^a-zA-Z0-9_-]/g, '_')}_haDiscovery.json`)
253
+ });
254
+
255
+ // Polls overlap with a slow broker, only one sync at a time
256
+ if (this.haSyncRunning) return;
257
+ this.haSyncRunning = true;
258
+ await this.ha.sync(this.openWrtInfo);
259
+ } catch (error) {
260
+ if (this.logWarn) this.emit('warn', `HA Discovery error: ${error.message ?? error}`);
261
+ } finally {
262
+ this.haSyncRunning = false;
263
+ }
264
+ }
265
+
266
+ // Radio or SSID command from Home Assistant: {"id": "<radio or UCI section>", "state": true}
267
+ async setWireless(integration, type, value) {
268
+ const id = value?.id;
269
+ switch (type) {
270
+ case 'Radio':
271
+ case 'RadioRestart': {
272
+ const radio = this.openWrtInfo.wirelessRadios.find(r => r.name === id);
273
+ if (!radio) break;
274
+ const restart = type === 'RadioRestart';
275
+ await this.openWrt.send('radio', radio.name, null, null, value.state === true, null, restart); //{type, radioName, ssidName, newSsidName, state, command, restart}
276
+ await this.openWrt.refresh();
277
+ return true;
278
+ }
279
+ case 'Ssid': {
280
+ const ssid = this.openWrtInfo.wirelessSsids.find(s => s.section === id);
281
+ if (!ssid) break;
282
+ await this.openWrt.send('ssid', ssid.radio, ssid.name, null, value.state === true, null, false); //{type, radioName, ssidName, newSsidName, state, command, restart}
283
+ await this.openWrt.refresh();
284
+ return true;
285
+ }
286
+ }
287
+
288
+ this.emit('warn', `${integration} ${type} ${id} not found`);
289
+ return false;
290
+ }
291
+
231
292
  async setOverExternalIntegration(integration, key, value) {
232
293
  if (!value) return false;
233
294
 
234
295
  switch (key) {
296
+ case 'Radio':
297
+ case 'RadioRestart':
298
+ case 'Ssid':
299
+ return this.setWireless(integration, key, value);
235
300
  case 'SystemReboot':
236
301
  return this.openWrt.send('externalIntegration', null, null, null, null, 0);
237
302
  case 'NetworkReload':