homebridge-openwrt-control 0.0.1-beta.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.
@@ -0,0 +1,247 @@
1
+ import EventEmitter from 'events';
2
+ let Accessory, Characteristic, Service, Categories, AccessoryUUID;
3
+
4
+ class AccessPointDevice extends EventEmitter {
5
+ constructor(api, config, openWrt, openWrtInfo) {
6
+ super();
7
+
8
+ Accessory = api.platformAccessory;
9
+ Characteristic = api.hap.Characteristic;
10
+ Service = api.hap.Service;
11
+ Categories = api.hap.Categories;
12
+ AccessoryUUID = api.hap.uuid;
13
+
14
+ //config
15
+ this.name = config.name;
16
+ this.accessPoint = config.accessPoint;
17
+ this.logInfo = config.log?.info || false;
18
+ this.logDebug = config.log?.debug || false;
19
+
20
+ //external integration
21
+ this.restFul = config.restFul || {};
22
+ this.restFulConnected = false;
23
+ this.mqtt = config.mqtt || {};
24
+ this.mqttConnected = false;
25
+
26
+ //openwrt
27
+ this.openWrt = openWrt;
28
+ this.openWrtInfo = openWrtInfo;
29
+ this.ssids = openWrtInfo.ssids;
30
+ };
31
+
32
+ async externalIntegrations() {
33
+ //RESTFul server
34
+ const restFulEnabled = this.restFul.enable || false;
35
+ if (restFulEnabled) {
36
+ try {
37
+ this.restFul1 = new RestFul({
38
+ port: this.restFul.port || 3000,
39
+ logWarn: this.logWarn,
40
+ logDebug: this.logDebug
41
+ })
42
+ .on('connected', (message) => {
43
+ this.emit('success', message);
44
+ this.restFulConnected = true;
45
+ })
46
+ .on('set', async (key, value) => {
47
+ try {
48
+ await this.setOverExternalIntegration('RESTFul', key, value);
49
+ } catch (error) {
50
+ this.emit('warn', `RESTFul set error: ${error}`);
51
+ };
52
+ })
53
+ .on('debug', (debug) => this.emit('debug', debug))
54
+ .on('warn', (warn) => this.emit('warn', warn))
55
+ .on('error', (error) => this.emit('error', error));
56
+ } catch (error) {
57
+ this.emit('warn', `RESTFul integration start error: ${error}`);
58
+ };
59
+ }
60
+
61
+ //mqtt client
62
+ const mqttEnabled = this.mqtt.enable || false;
63
+ if (mqttEnabled) {
64
+ try {
65
+ this.mqtt1 = new Mqtt({
66
+ host: this.mqtt.host,
67
+ port: this.mqtt.port || 1883,
68
+ clientId: this.mqtt.clientId ? `${this.savedInfo.manufacturer}_${this.mqtt.clientId}_${Math.random().toString(16).slice(3)}` : `${this.savedInfo.manufacturer}_${Math.random().toString(16).slice(3)}`,
69
+ prefix: this.mqtt.prefix ? `${this.savedInfo.manufacturer}/${this.mqtt.prefix}/${this.name}` : `${this.savedInfo.manufacturer}/${this.name}`,
70
+ user: this.mqtt.auth?.user,
71
+ passwd: this.mqtt.auth?.passwd,
72
+ logWarn: this.logWarn,
73
+ logDebug: this.logDebug
74
+ })
75
+ .on('connected', (message) => {
76
+ this.emit('success', message);
77
+ this.mqttConnected = true;
78
+ })
79
+ .on('subscribed', (message) => {
80
+ this.emit('success', message);
81
+ })
82
+ .on('set', async (key, value) => {
83
+ try {
84
+ await this.setOverExternalIntegration('MQTT', key, value);
85
+ } catch (error) {
86
+ this.emit('warn', `MQTT set error: ${error}`);
87
+ }
88
+ })
89
+ .on('debug', (debug) => this.emit('debug', debug))
90
+ .on('warn', (warn) => this.emit('warn', warn))
91
+ .on('error', (error) => this.emit('error', error));
92
+ } catch (error) {
93
+ this.emit('warn', `MQTT integration start error: ${error}`);
94
+ };
95
+ }
96
+
97
+ return true;
98
+ }
99
+
100
+ async setOverExternalIntegration(integration, key, value) {
101
+ try {
102
+ let set = false
103
+ switch (key) {
104
+ case 'Power':
105
+ const powerState = value ? 'ON' : 'OFF';
106
+ set = await this.openWrt.send('Power', powerState);
107
+ break;
108
+ default:
109
+ this.emit('warn', `${integration}, received key: ${key}, value: ${value}`);
110
+ break;
111
+ }
112
+ return set;
113
+ } catch (error) {
114
+ throw new Error(`${integration} set key: ${key}, value: ${value}, error: ${error}`);
115
+ }
116
+ }
117
+
118
+ //prepare accessory
119
+ async prepareAccessory() {
120
+ try {
121
+ //prepare accessory
122
+ if (this.logDebug) this.emit('debug', `prepare accessory`);
123
+ const accessoryName = this.name;
124
+ const accessoryUUID = AccessoryUUID.generate(this.deviceUuid);
125
+ const accessoryCategory = Categories.AIRPORT;
126
+ const accessory = new Accessory(accessoryName, accessoryUUID, accessoryCategory);
127
+
128
+ //prepare information service
129
+ if (this.logDebug) this.emit('debug', `prepare information service`);
130
+ accessory.getService(Service.AccessoryInformation)
131
+ .setCharacteristic(Characteristic.Manufacturer, 'OpenWrt')
132
+ .setCharacteristic(Characteristic.Model, accessoryName)
133
+ .setCharacteristic(Characteristic.SerialNumber, this.networkId)
134
+ .setCharacteristic(Characteristic.FirmwareRevision, this.organizationId)
135
+ .setCharacteristic(Characteristic.ConfiguredName, accessoryName);
136
+
137
+ if (this.logDebug) this.emit('debug', `prepare service`);
138
+
139
+ //device
140
+ this.services = [];
141
+ for (const ssid of this.ssids) {
142
+ const ssidName = ssid.name;
143
+ if (this.logDebug) this.emit('debug', `prepare ssid: ${ssidName} service`);
144
+
145
+ const serviceName = this.accessPoint.namePrefix ? `${this.name} ${ssidName}` : ssidName;
146
+ const service = accessory.addService(Service.Switch, serviceName, `service${ssidName}`);
147
+ service.addOptionalCharacteristic(Characteristic.ConfiguredName);
148
+ service.setCharacteristic(Characteristic.ConfiguredName, serviceName);
149
+ service.getCharacteristic(Characteristic.On)
150
+ .onGet(async () => {
151
+ const state = ssid.state;
152
+ if (this.logInfo) this.emit('message', `SSID: ${ssidName}, state: ${state ? 'Enabled' : 'Disabled'}`);
153
+ return state;
154
+ })
155
+ .onSet(async (state) => {
156
+ try {
157
+ state = state ? true : false;
158
+ await this.openWrt.send('ssid', ssidName, state);
159
+ if (this.logInfo) this.emit('message', `SSID: ${ssidName}, set State: ${state ? 'Enabled' : 'Disabled'}`);
160
+ } catch (error) {
161
+ this.emit('warn', `SSID: ${ssidName}, set state error: ${error}`);
162
+ }
163
+ });
164
+ this.services.push(service);
165
+
166
+ if (this.accessPoint.sensor) {
167
+ if (this.logDebug) this.emit('debug', `prepare ssid: ${ssidName} sensor service`);
168
+
169
+ this.sensorServices = [];
170
+ const sensorService = accessory.addService(Service.ContactSensor, ssidName, `sensorService${ssidName}`);
171
+ sensorService.addOptionalCharacteristic(Characteristic.ConfiguredName);
172
+ sensorService.setCharacteristic(Characteristic.ConfiguredName, ssidName);
173
+ sensorService.getCharacteristic(Characteristic.ContactSensorState)
174
+ .onGet(async () => {
175
+ const state = ssid.state;
176
+ return state;
177
+ });
178
+ this.sensorServices.push(sensorService);
179
+ };
180
+ };
181
+
182
+ return accessory;
183
+ } catch (error) {
184
+ throw new Error(error);
185
+ };
186
+ };
187
+
188
+ //start
189
+ async start() {
190
+ try {
191
+ //start external integrations
192
+ if (this.restFul.enable || this.mqtt.enable) await this.externalIntegrations();
193
+
194
+ this.emit('devInfo', `-------- Access Point ${this.name} --------`);
195
+ this.emit('devInfo', `Name: ${this.openWrtInfo.systemInfo.hostname}`);
196
+ this.emit('devInfo', `Model: ${this.openWrtInfo.systemInfo.model}`);
197
+ this.emit('devInfo', `System: ${this.openWrtInfo.systemInfo.system}`);
198
+ this.emit('devInfo', `Release: ${this.openWrtInfo.systemInfo.release?.description}`);
199
+ this.emit('devInfo', `----------------------------------`);
200
+
201
+ //denon client
202
+ this.openWrt.on('systemInfo', (info) => {
203
+ this.informationService?.updateCharacteristic(Characteristic.FirmwareRevision, info.release?.version);
204
+ })
205
+ .on('wirelessStatus', async (status) => {
206
+ })
207
+ .on('wirelessRadios', async (radios) => {
208
+ })
209
+ .on('ssids', async (ssids) => {
210
+ // sensors
211
+ for (let i = 0; i < ssids.length; i++) {
212
+ const ssid = ssids[i];
213
+
214
+ const name = ssid[i].name;
215
+ const state = ssid[i].state;
216
+ const serviceName = this.accessPoint.namePrefix ? `${this.name} ${name}` : name;
217
+ this.services?.[i]
218
+ ?.setCharacteristic(Characteristic.ConfiguredName, serviceName)
219
+ .updateCharacteristic(Characteristic.On, state);
220
+
221
+ this.sensorServices?.[i]
222
+ ?.setCharacteristic(Characteristic.ConfiguredName, name)
223
+ .updateCharacteristic(Characteristic.ContactSensorState, state ? 0 : 1);
224
+
225
+ if (this.logInfo) {
226
+ this.emit('info', `SSID name: ${ssid.name}`);
227
+ this.emit('info', `Name: ${ssid.state}`);
228
+ this.emit('info', `Mode: ${ssid.mode}`);
229
+ }
230
+ }
231
+ })
232
+ .on('restFul', (path, data) => {
233
+ if (this.restFulConnected) this.restFul1.update(path, data);
234
+ })
235
+ .on('mqtt', (topic, message) => {
236
+ if (this.mqttConnected) this.mqtt1.emit('publish', topic, message);
237
+ });
238
+
239
+ //prepare accessory
240
+ const accessory = await this.prepareAccessory();
241
+ return accessory;
242
+ } catch (error) {
243
+ throw new Error(`Start error: ${error}`);
244
+ }
245
+ }
246
+ };
247
+ export default AccessPointDevice;
@@ -0,0 +1,6 @@
1
+ export const PlatformName = "OpenWrt";
2
+ export const PluginName = "homebridge-openwrt-control";
3
+
4
+ export const ApiUrls = {
5
+
6
+ }
@@ -0,0 +1,81 @@
1
+ import { promises as fsPromises } from 'fs';
2
+ import { DiacriticsMap } from './constants.js';
3
+
4
+ class Functions {
5
+ constructor() {
6
+ }
7
+
8
+ async saveData(path, data, stringify = true) {
9
+ try {
10
+ data = stringify ? JSON.stringify(data, null, 2) : data;
11
+ await fsPromises.writeFile(path, data);
12
+ return true;
13
+ } catch (error) {
14
+ throw new Error(`Save data error: ${error}`);
15
+ }
16
+ }
17
+
18
+ async readData(path, parseJson = false) {
19
+ try {
20
+ const data = await fsPromises.readFile(path, 'utf8');
21
+
22
+ if (parseJson) {
23
+ if (!data.trim()) {
24
+ // Empty file when expecting JSON
25
+ return null;
26
+ }
27
+ try {
28
+ return JSON.parse(data);
29
+ } catch (jsonError) {
30
+ throw new Error(`JSON parse error in file "${path}": ${jsonError.message}`);
31
+ }
32
+ }
33
+
34
+ // For non-JSON, just return file content (can be empty string)
35
+ return data;
36
+ } catch (error) {
37
+ if (error.code === 'ENOENT') {
38
+ // File does not exist
39
+ return null;
40
+ }
41
+ // Preserve original error details
42
+ const wrappedError = new Error(`Read data error for "${path}": ${error.message}`);
43
+ wrappedError.original = error;
44
+ throw wrappedError;
45
+ }
46
+ }
47
+
48
+ async sanitizeString(str) {
49
+ if (!str) return '';
50
+
51
+ // Replace diacritics using map
52
+ str = str.replace(/[^\u0000-\u007E]/g, ch => DiacriticsMap[ch] || ch);
53
+
54
+ // Replace separators between words with space
55
+ str = str.replace(/(\w)[.:;+\-\/]+(\w)/g, '$1 $2');
56
+
57
+ // Replace remaining standalone separators with space
58
+ str = str.replace(/[.:;+\-\/]/g, ' ');
59
+
60
+ // Remove remaining invalid characters (keep letters, digits, space, apostrophe)
61
+ str = str.replace(/[^A-Za-z0-9 ']/g, ' ');
62
+
63
+ // Collapse multiple spaces
64
+ str = str.replace(/\s+/g, ' ');
65
+
66
+ // Trim
67
+ return str.trim();
68
+ }
69
+
70
+ async findIfaceBySsid(status, targetSsid) {
71
+ for (const radio of Object.values(status.radios)) {
72
+ for (const iface of Object.values(radio.interfaces)) {
73
+ if (iface.ssid === targetSsid) {
74
+ return iface;
75
+ }
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ }
81
+ export default Functions
@@ -0,0 +1,41 @@
1
+ import EventEmitter from 'events';
2
+
3
+ class ImpulseGenerator extends EventEmitter {
4
+ constructor() {
5
+ super();
6
+ this.timersState = false;
7
+ this.timers = [];
8
+ }
9
+
10
+ async state(state, timers = []) {
11
+ // Stop current timers before new start
12
+ if (this.timersState && state) {
13
+ await this.state(false);
14
+ }
15
+
16
+ if (state) {
17
+ if (!Array.isArray(timers)) throw new Error('Timers must be an array');
18
+
19
+ for (const { name, sampling } of timers) {
20
+ if (!name || !sampling) continue;
21
+
22
+ this.emit(name);
23
+
24
+ const interval = setInterval(() => {
25
+ this.emit(name);
26
+ }, sampling);
27
+
28
+ this.timers.push(interval);
29
+ }
30
+ } else {
31
+ this.timers.forEach(clearInterval);
32
+ this.timers = [];
33
+ }
34
+
35
+ this.timersState = state;
36
+ this.emit('state', state);
37
+ return true;
38
+ }
39
+ }
40
+
41
+ export default ImpulseGenerator;
package/src/openwrt.js ADDED
@@ -0,0 +1,173 @@
1
+ import EventEmitter from "events";
2
+ import axios from "axios";
3
+ import Functions from './functions.js';
4
+ import ImpulseGenerator from "./impulsegenerator.js";
5
+
6
+ class OpenWrt extends EventEmitter {
7
+ constructor(config) {
8
+ super();
9
+
10
+ this.name = config.name;
11
+ this.host = config.host;
12
+ this.user = config.user;
13
+ this.passwd = config.passwd;
14
+ this.logDebug = config.logDebug;
15
+
16
+ //external integration
17
+ this.restFulEnabled = config.restFul?.enable || false;
18
+ this.mqttEnabled = config.mqtt?.enable || false;
19
+
20
+ this.firstRun = true;
21
+ this.lock = false;
22
+
23
+ this.sessionId = null;
24
+ this.sessionExpiresAt = 0;
25
+
26
+ this.functions = new Functions();
27
+ this.axiosInstance = axios.create({
28
+ baseURL: `${config.host}/ubus`,
29
+ timeout: 5000,
30
+ headers: {
31
+ "Content-Type": "application/json"
32
+ }
33
+ });
34
+
35
+ this.impulseGenerator = new ImpulseGenerator()
36
+ .on("connect", () => this.handleWithLock(async () => {
37
+ await this.connect();
38
+ }))
39
+ .on("state", (state) => {
40
+ this.emit(state ? "success" : "warn", `Impulse generator ${state ? "started" : "stopped"}`);
41
+ });
42
+ }
43
+
44
+ async handleWithLock(fn) {
45
+ if (this.lock) return;
46
+ this.lock = true;
47
+
48
+ try {
49
+ await fn();
50
+ } catch (error) {
51
+ this.emit("error", `Impulse generator error: ${error.message}`
52
+ );
53
+ } finally {
54
+ this.lock = false;
55
+ }
56
+ }
57
+
58
+ async login() {
59
+ const now = Date.now();
60
+ if (this.sessionId && now < this.sessionExpiresAt) {
61
+ return this.sessionId;
62
+ }
63
+
64
+ const response = await this.axiosInstance.post("", {
65
+ jsonrpc: "2.0",
66
+ id: 1,
67
+ method: "call",
68
+ params: ["00000000000000000000000000000000", "session", "login", { username: this.user, password: this.passwd }]
69
+ });
70
+
71
+ const result = response.data?.result?.[1];
72
+ if (!result?.ubus_rpc_session) {
73
+ throw new Error("ubus login failed");
74
+ }
75
+
76
+ this.sessionId = result.ubus_rpc_session;
77
+ this.sessionExpiresAt = now + 240_000;
78
+
79
+ if (this.logDebug) this.emit("debug", `Ubus login OK`);
80
+ return this.sessionId;
81
+ }
82
+
83
+ async ubusCall(service, method, params = {}) {
84
+ const session = await this.login();
85
+
86
+ const response = await this.axiosInstance.post("", {
87
+ jsonrpc: "2.0",
88
+ id: 2,
89
+ method: "call",
90
+ params: [session, service, method, params]
91
+ });
92
+
93
+ if (response.data?.error) {
94
+ throw new Error(response.data.error.message || "ubus call error");
95
+ }
96
+
97
+ return response.data.result[1];
98
+ }
99
+
100
+ async connect() {
101
+ const openWrtInfo = { state: false, systemInfo: {}, wirelessStatus: {}, wirelessRadios: [], ssids: [] }
102
+ const systemInfo = await this.ubusCall("system", "board");
103
+ const wirelessStatus = await this.ubusCall("network.wireless", "status");
104
+ if (this.logDebug) this.emit("debug", `Status data: ${JSON.stringify(wirelessStatus, null, 2)}`);
105
+
106
+ const wirelessRadios = Object.values(status.radios).map(radio => ({
107
+ name: radio.name,
108
+ state: radio.up === true,
109
+ interfaces: Object.values(radio.interfaces).map(i => ({
110
+ name: i.ssid,
111
+ state: i.up === true,
112
+ mode: i.mode
113
+ }))
114
+ }));
115
+
116
+ const ssids = openWrtInfo.wirelessRadios.flatMap(radio => radio.interfaces);
117
+ this.emit("systemInfo", systemInfo);
118
+ this.emit("wirelessStatus", wirelessStatus);
119
+ this.emit("wirelessRadios", wirelessRadios);
120
+ this.emit("ssids", ssids);
121
+
122
+ if (this.firstRun) {
123
+ this.emit("success", `Connect success`);
124
+ this.firstRun = false;
125
+ }
126
+
127
+ openWrtInfo.state = true;
128
+ openWrtInfo.systemInfo = systemInfo;
129
+ openWrtInfo.wirelessStatus = wirelessStatus;
130
+ openWrtInfo.wirelessRadios = wirelessRadios;
131
+ openWrtInfo.ssids = ssids;
132
+
133
+ if (this.logDebug) this.emit("debug", `OpenWrt Data: ${JSON.stringify(openWrtInfo, null, 2)}`);
134
+ return openWrtInfo;
135
+ }
136
+
137
+ async send(type, ssidName, state) {
138
+ switch (type) {
139
+ case 'ssid':
140
+ await this.handleWithLock(async () => {
141
+ if (this.logDebug) this.emit("debug", `${state ? "Enabling" : "Disabling"} SSID ${ssidName}`);
142
+
143
+ const status = await this.ubusCall("network.wireless", "status");
144
+ const iface = await this.functions.findIfaceBySsid(status, ssidName);
145
+ if (!iface) throw new Error(`SSID ${ssidName} not found`);
146
+
147
+ const section = iface.section;
148
+ if (!section) throw new Error(`No UCI section for SSID ${ssidName}`);
149
+
150
+ await this.ubusCall("uci", "set",
151
+ {
152
+ config: "wireless",
153
+ section: section,
154
+ values: {
155
+ disabled: state ? "0" : "1"
156
+ }
157
+ }
158
+ );
159
+
160
+ await this.ubusCall("uci", "commit", { config: "wireless" });
161
+ await this.ubusCall("network.wireless", "reload", {});
162
+
163
+ this.emit("success", `SSID ${ssidName} ${state ? "enabled" : "disabled"}`);
164
+ });
165
+ break;
166
+ case 'switch':
167
+ break;
168
+ }
169
+ }
170
+ }
171
+
172
+ export default OpenWrt;
173
+