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 +20 -0
- package/README.md +27 -1
- package/config.schema.json +20 -0
- package/package.json +5 -4
- package/src/hadiscovery.js +257 -0
- package/src/mqtt.js +27 -0
- package/src/openwrt.js +104 -4
- package/src/restful.js +43 -0
- package/src/router.js +67 -2
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).
|
package/config.schema.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
180
|
-
|
|
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':
|