homebridge-frontier-silicon-plugin 1.2.7 → 1.2.9
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/config.schema.json +8 -1
- package/index.js +345 -300
- package/package.json +35 -11
package/config.schema.json
CHANGED
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
"footerDisplay": "Configure one or more Frontier Silicon based radios. Presets must be saved on the radio first.",
|
|
6
6
|
"schema": {
|
|
7
7
|
"type": "object",
|
|
8
|
-
"required": ["platform", "accessories"],
|
|
8
|
+
"required": ["platform", "name", "accessories"],
|
|
9
9
|
"properties": {
|
|
10
10
|
"platform": {
|
|
11
11
|
"type": "string",
|
|
12
12
|
"const": "frontier-silicon"
|
|
13
13
|
},
|
|
14
|
+
"name": {
|
|
15
|
+
"title": "Platform Name",
|
|
16
|
+
"type": "string",
|
|
17
|
+
"default": "Frontier Silicon Radios",
|
|
18
|
+
"description": "A label for this platform instance in Homebridge."
|
|
19
|
+
},
|
|
14
20
|
"accessories": {
|
|
15
21
|
"title": "Radios",
|
|
16
22
|
"type": "array",
|
|
@@ -95,6 +101,7 @@
|
|
|
95
101
|
}
|
|
96
102
|
},
|
|
97
103
|
"form": [
|
|
104
|
+
"name",
|
|
98
105
|
{
|
|
99
106
|
"key": "accessories",
|
|
100
107
|
"type": "array",
|
package/index.js
CHANGED
|
@@ -1,407 +1,452 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const http = require("http");
|
|
4
|
+
|
|
3
5
|
let Service;
|
|
4
6
|
let Characteristic;
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
const PLUGIN_NAME = "homebridge-frontier-silicon-plugin"; // must match package.json "name"
|
|
9
|
+
const PLATFORM_NAME = "frontier-silicon"; // must match config.schema.json platform const and Homebridge config
|
|
10
|
+
|
|
11
|
+
module.exports = (homebridge) => {
|
|
7
12
|
Service = homebridge.hap.Service;
|
|
8
13
|
Characteristic = homebridge.hap.Characteristic;
|
|
9
14
|
|
|
10
|
-
homebridge.
|
|
11
|
-
"homebridge-frontier-silicone",
|
|
12
|
-
"frontier-silicon",
|
|
13
|
-
FrontierSiliconAccessory
|
|
14
|
-
);
|
|
15
|
+
homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, FrontierSiliconPlatform);
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
class FrontierSiliconPlatform {
|
|
19
|
+
constructor(log, config, api) {
|
|
20
|
+
this.log = log;
|
|
21
|
+
this.api = api;
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.pin = String(config.pin ?? "1234");
|
|
23
|
-
this.pollIntervalSeconds = Number(config.pollIntervalSeconds ?? 5);
|
|
23
|
+
this.config = config || {};
|
|
24
|
+
this.accessoriesByUUID = new Map();
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
if (!this.api || typeof this.api.on !== "function") {
|
|
27
|
+
this.log.warn("Homebridge API not available, platform will not start.");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
this.api.on("didFinishLaunching", () => {
|
|
32
|
+
try {
|
|
33
|
+
this.discoverAndSync();
|
|
34
|
+
} catch (e) {
|
|
35
|
+
this.log.error("Failed to start platform:", e?.message || e);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
configureAccessory(accessory) {
|
|
41
|
+
this.accessoriesByUUID.set(accessory.UUID, accessory);
|
|
42
|
+
}
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.stations = Array.isArray(config.stations) ? config.stations : [];
|
|
44
|
+
discoverAndSync() {
|
|
45
|
+
const radios = Array.isArray(this.config.accessories) ? this.config.accessories : [];
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
this.lastKnownRadioVolume = null;
|
|
47
|
+
const desiredUUIDs = new Set();
|
|
38
48
|
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
for (const radioCfg of radios) {
|
|
50
|
+
if (!radioCfg || typeof radioCfg !== "object") continue;
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
const name = String(radioCfg.name || "Frontier Silicon Radio");
|
|
53
|
+
const ip = String(radioCfg.ip || "").trim();
|
|
54
|
+
if (!ip) continue;
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
const uuid = this.api.hap.uuid.generate(`frontier-silicon:${ip}`);
|
|
57
|
+
desiredUUIDs.add(uuid);
|
|
58
|
+
|
|
59
|
+
const existing = this.accessoriesByUUID.get(uuid);
|
|
60
|
+
|
|
61
|
+
if (existing) {
|
|
62
|
+
existing.context.config = radioCfg;
|
|
63
|
+
existing.displayName = name;
|
|
64
|
+
|
|
65
|
+
this.log.info(`Updating radio accessory: ${name} (${ip})`);
|
|
66
|
+
new FrontierSiliconRadioAccessory(this.log, existing, radioCfg);
|
|
67
|
+
} else {
|
|
68
|
+
this.log.info(`Adding radio accessory: ${name} (${ip})`);
|
|
69
|
+
const acc = new this.api.platformAccessory(name, uuid);
|
|
70
|
+
acc.context.config = radioCfg;
|
|
71
|
+
|
|
72
|
+
new FrontierSiliconRadioAccessory(this.log, acc, radioCfg);
|
|
47
73
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
pin: this.pin,
|
|
51
|
-
log: this.log
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
this.informationService = new Service.AccessoryInformation()
|
|
55
|
-
.setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
|
|
56
|
-
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
57
|
-
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
58
|
-
|
|
59
|
-
this.switchService = new Service.Switch(this.name);
|
|
60
|
-
this.switchService
|
|
61
|
-
.getCharacteristic(Characteristic.On)
|
|
62
|
-
.on("get", this.handleGetPower.bind(this))
|
|
63
|
-
.on("set", this.handleSetPower.bind(this));
|
|
64
|
-
|
|
65
|
-
if (this.enableVolume) {
|
|
66
|
-
if (this.exposeSpeakerService) {
|
|
67
|
-
this.speakerService = new Service.Speaker(this.name + " Speaker");
|
|
68
|
-
|
|
69
|
-
this.speakerService
|
|
70
|
-
.getCharacteristic(Characteristic.Volume)
|
|
71
|
-
.on("get", this.handleGetVolume.bind(this))
|
|
72
|
-
.on("set", this.handleSetVolume.bind(this));
|
|
73
|
-
|
|
74
|
-
if (Characteristic.Mute) {
|
|
75
|
-
this.speakerService
|
|
76
|
-
.getCharacteristic(Characteristic.Mute)
|
|
77
|
-
.on("get", (cb) => cb(null, false))
|
|
78
|
-
.on("set", (_val, cb) => cb(null));
|
|
74
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
|
|
75
|
+
this.accessoriesByUUID.set(uuid, acc);
|
|
79
76
|
}
|
|
80
77
|
}
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
.getCharacteristic(Characteristic.On)
|
|
87
|
-
.on("get", (cb) => cb(null, true))
|
|
88
|
-
.on("set", (_val, cb) => cb(null));
|
|
79
|
+
const toRemove = [];
|
|
80
|
+
for (const [uuid, acc] of this.accessoriesByUUID.entries()) {
|
|
81
|
+
if (!desiredUUIDs.has(uuid)) toRemove.push(acc);
|
|
82
|
+
}
|
|
89
83
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
.on("set", this.handleSetVolume.bind(this));
|
|
84
|
+
if (toRemove.length > 0) {
|
|
85
|
+
for (const acc of toRemove) this.accessoriesByUUID.delete(acc.UUID);
|
|
86
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toRemove);
|
|
94
87
|
}
|
|
95
88
|
}
|
|
89
|
+
}
|
|
96
90
|
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
class FrontierSiliconRadioAccessory {
|
|
92
|
+
constructor(log, accessory, config) {
|
|
93
|
+
this.log = log;
|
|
94
|
+
this.accessory = accessory;
|
|
99
95
|
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
this.name = String(config.name || accessory.displayName || "Frontier Silicon Radio");
|
|
97
|
+
this.ip = String(config.ip || "").trim();
|
|
98
|
+
this.pin = String(config.pin ?? "1234");
|
|
99
|
+
|
|
100
|
+
this.pollIntervalSeconds = Number(config.pollIntervalSeconds ?? 5);
|
|
101
|
+
this.enableVolume = config.enableVolume !== false;
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
this.exposeSpeakerService = config.exposeSpeakerService !== false;
|
|
104
|
+
this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
// stations: [{ name: "Radio 2", preset: 2 }, ...] where preset is 1 based
|
|
109
|
+
this.stations = Array.isArray(config.stations) ? config.stations : [];
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
this.lastKnownPower = null;
|
|
112
|
+
this.lastKnownRadioVolume = null;
|
|
113
|
+
this.lastKnownPresetKey = null;
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
if (!Number.isFinite(presetUi)) continue;
|
|
115
|
+
this.isUpdatingStationSwitches = false;
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
this.client = new FsApiClient({
|
|
118
|
+
ip: this.ip,
|
|
119
|
+
pin: this.pin,
|
|
120
|
+
timeoutMs: 2500
|
|
121
|
+
});
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
this.setupServices();
|
|
124
|
+
this.startPolling();
|
|
125
|
+
}
|
|
124
126
|
|
|
125
|
-
|
|
127
|
+
setupServices() {
|
|
128
|
+
const info = this.accessory.getService(Service.AccessoryInformation) ||
|
|
129
|
+
this.accessory.addService(Service.AccessoryInformation);
|
|
130
|
+
|
|
131
|
+
info
|
|
132
|
+
.setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
|
|
133
|
+
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
134
|
+
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
135
|
+
|
|
136
|
+
// Power
|
|
137
|
+
this.powerService = this.accessory.getServiceById(Service.Switch, "power") ||
|
|
138
|
+
this.accessory.addService(Service.Switch, this.name, "power");
|
|
139
|
+
|
|
140
|
+
this.powerService.setCharacteristic(Characteristic.Name, this.name);
|
|
141
|
+
|
|
142
|
+
const powerChar = this.powerService.getCharacteristic(Characteristic.On);
|
|
143
|
+
powerChar.removeAllListeners("get");
|
|
144
|
+
powerChar.removeAllListeners("set");
|
|
145
|
+
powerChar.on("get", this.handleGetPower.bind(this));
|
|
146
|
+
powerChar.on("set", this.handleSetPower.bind(this));
|
|
147
|
+
|
|
148
|
+
// Volume services
|
|
149
|
+
if (this.enableVolume && this.exposeSpeakerService) {
|
|
150
|
+
this.speakerService = this.accessory.getServiceById(Service.Speaker, "speaker") ||
|
|
151
|
+
this.accessory.addService(Service.Speaker, `${this.name} Speaker`, "speaker");
|
|
152
|
+
|
|
153
|
+
const volChar = this.speakerService.getCharacteristic(Characteristic.Volume);
|
|
154
|
+
volChar.removeAllListeners("get");
|
|
155
|
+
volChar.removeAllListeners("set");
|
|
156
|
+
volChar.on("get", this.handleGetVolume.bind(this));
|
|
157
|
+
volChar.on("set", this.handleSetVolume.bind(this));
|
|
158
|
+
} else {
|
|
159
|
+
const sp = this.accessory.getServiceById(Service.Speaker, "speaker");
|
|
160
|
+
if (sp) this.accessory.removeService(sp);
|
|
161
|
+
this.speakerService = null;
|
|
162
|
+
}
|
|
126
163
|
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
164
|
+
if (this.enableVolume && this.exposeVolumeSlider) {
|
|
165
|
+
this.volumeSliderService = this.accessory.getServiceById(Service.Lightbulb, "volumeSlider") ||
|
|
166
|
+
this.accessory.addService(Service.Lightbulb, `${this.name} Volume`, "volumeSlider");
|
|
167
|
+
|
|
168
|
+
const onChar = this.volumeSliderService.getCharacteristic(Characteristic.On);
|
|
169
|
+
onChar.removeAllListeners("get");
|
|
170
|
+
onChar.removeAllListeners("set");
|
|
171
|
+
onChar.on("get", (cb) => cb(null, true));
|
|
172
|
+
onChar.on("set", (_v, cb) => cb(null));
|
|
173
|
+
|
|
174
|
+
const brChar = this.volumeSliderService.getCharacteristic(Characteristic.Brightness);
|
|
175
|
+
brChar.removeAllListeners("get");
|
|
176
|
+
brChar.removeAllListeners("set");
|
|
177
|
+
brChar.on("get", this.handleGetVolume.bind(this));
|
|
178
|
+
brChar.on("set", this.handleSetVolume.bind(this));
|
|
179
|
+
} else {
|
|
180
|
+
const vs = this.accessory.getServiceById(Service.Lightbulb, "volumeSlider");
|
|
181
|
+
if (vs) this.accessory.removeService(vs);
|
|
182
|
+
this.volumeSliderService = null;
|
|
183
|
+
}
|
|
134
184
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
service: sw
|
|
140
|
-
});
|
|
185
|
+
// Rebuild station services
|
|
186
|
+
this.removeOldStationServices();
|
|
187
|
+
this.stationServices = [];
|
|
188
|
+
this.buildStationServices();
|
|
141
189
|
}
|
|
142
|
-
};
|
|
143
190
|
|
|
144
|
-
|
|
145
|
-
|
|
191
|
+
removeOldStationServices() {
|
|
192
|
+
for (const s of this.accessory.services || []) {
|
|
193
|
+
if (s.UUID === Service.Switch.UUID && typeof s.subtype === "string" && s.subtype.startsWith("station_")) {
|
|
194
|
+
this.accessory.removeService(s);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
146
198
|
|
|
147
|
-
|
|
148
|
-
|
|
199
|
+
buildStationServices() {
|
|
200
|
+
if (!Array.isArray(this.stations) || this.stations.length === 0) return;
|
|
149
201
|
|
|
150
|
-
|
|
202
|
+
const seen = new Set();
|
|
151
203
|
|
|
152
|
-
|
|
153
|
-
|
|
204
|
+
for (const st of this.stations) {
|
|
205
|
+
if (!st || typeof st !== "object") continue;
|
|
154
206
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const power = await this.client.getPower();
|
|
158
|
-
this.lastKnownPower = power;
|
|
159
|
-
callback(null, power);
|
|
160
|
-
} catch (err) {
|
|
161
|
-
this.log.warn("Power get failed, returning last known state.", toMsg(err));
|
|
162
|
-
callback(null, this.lastKnownPower ?? false);
|
|
163
|
-
}
|
|
164
|
-
};
|
|
207
|
+
const stationName = String(st.name ?? "").trim();
|
|
208
|
+
const presetUi = Number(st.preset);
|
|
165
209
|
|
|
166
|
-
|
|
167
|
-
|
|
210
|
+
if (!stationName) continue;
|
|
211
|
+
if (!Number.isFinite(presetUi)) continue;
|
|
168
212
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
this.lastKnownPower = target;
|
|
172
|
-
callback(null);
|
|
173
|
-
} catch (err) {
|
|
174
|
-
this.log.warn("Power set failed, keeping last known state.", toMsg(err));
|
|
175
|
-
callback(null);
|
|
176
|
-
}
|
|
177
|
-
};
|
|
213
|
+
const presetKey = Math.trunc(presetUi) - 1; // 1 based UI to 0 based FSAPI
|
|
214
|
+
if (presetKey < 0) continue;
|
|
178
215
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
this.lastKnownRadioVolume = radioVol;
|
|
183
|
-
callback(null, radioToHomekitVolume(radioVol));
|
|
184
|
-
} catch (err) {
|
|
185
|
-
this.log.warn("Volume get failed, returning last known level.", toMsg(err));
|
|
186
|
-
const fallbackRadio = this.lastKnownRadioVolume ?? 0;
|
|
187
|
-
callback(null, radioToHomekitVolume(fallbackRadio));
|
|
188
|
-
}
|
|
189
|
-
};
|
|
216
|
+
const subtype = `station_${presetKey}`;
|
|
217
|
+
if (seen.has(subtype)) continue;
|
|
218
|
+
seen.add(subtype);
|
|
190
219
|
|
|
191
|
-
|
|
192
|
-
const radioVol = homekitToRadioVolume(value);
|
|
220
|
+
const sw = this.accessory.addService(Service.Switch, stationName, subtype);
|
|
193
221
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this.log.warn("Volume set failed, keeping last known level.", toMsg(err));
|
|
200
|
-
callback(null);
|
|
201
|
-
}
|
|
202
|
-
};
|
|
222
|
+
const ch = sw.getCharacteristic(Characteristic.On);
|
|
223
|
+
ch.removeAllListeners("get");
|
|
224
|
+
ch.removeAllListeners("set");
|
|
225
|
+
ch.on("get", (cb) => cb(null, this.lastKnownPresetKey === presetKey));
|
|
226
|
+
ch.on("set", (value, cb) => this.handleSetStationPreset(presetKey, !!value, cb));
|
|
203
227
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
callback(null);
|
|
207
|
-
return;
|
|
228
|
+
this.stationServices.push({ presetKey, name: stationName, service: sw });
|
|
229
|
+
}
|
|
208
230
|
}
|
|
209
231
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
232
|
+
async handleGetPower(callback) {
|
|
233
|
+
try {
|
|
234
|
+
const power = await this.client.getPower();
|
|
235
|
+
this.lastKnownPower = power;
|
|
236
|
+
callback(null, power);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
callback(null, this.lastKnownPower ?? false);
|
|
239
|
+
}
|
|
216
240
|
}
|
|
217
241
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
242
|
+
async handleSetPower(value, callback) {
|
|
243
|
+
try {
|
|
244
|
+
const target = !!value;
|
|
245
|
+
await this.client.setPower(target);
|
|
246
|
+
this.lastKnownPower = target;
|
|
247
|
+
callback(null);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
callback(null);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
221
252
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
253
|
+
async handleGetVolume(callback) {
|
|
254
|
+
try {
|
|
255
|
+
const radioVol = await this.client.getVolume();
|
|
256
|
+
this.lastKnownRadioVolume = radioVol;
|
|
257
|
+
callback(null, radioToHomekitVolume(radioVol));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
const fallback = this.lastKnownRadioVolume ?? 0;
|
|
260
|
+
callback(null, radioToHomekitVolume(fallback));
|
|
231
261
|
}
|
|
262
|
+
}
|
|
232
263
|
|
|
233
|
-
|
|
264
|
+
async handleSetVolume(value, callback) {
|
|
265
|
+
try {
|
|
266
|
+
const radioVol = homekitToRadioVolume(value);
|
|
267
|
+
await this.client.setVolume(radioVol);
|
|
268
|
+
this.lastKnownRadioVolume = radioVol;
|
|
269
|
+
callback(null);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
callback(null);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
234
274
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
275
|
+
async handleSetStationPreset(presetKey, turnOn, callback) {
|
|
276
|
+
if (this.isUpdatingStationSwitches) {
|
|
277
|
+
callback(null);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
239
280
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
281
|
+
if (!turnOn) {
|
|
282
|
+
callback(null);
|
|
283
|
+
this.syncStationSwitches(this.lastKnownPresetKey);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
244
286
|
|
|
245
|
-
|
|
246
|
-
|
|
287
|
+
this.lastKnownPresetKey = presetKey;
|
|
288
|
+
this.syncStationSwitches(presetKey);
|
|
247
289
|
|
|
248
|
-
|
|
290
|
+
try {
|
|
291
|
+
if (this.autoPowerOnOnPreset) {
|
|
292
|
+
try {
|
|
293
|
+
await this.client.setPower(true);
|
|
294
|
+
this.lastKnownPower = true;
|
|
295
|
+
this.powerService.getCharacteristic(Characteristic.On).updateValue(true);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
}
|
|
298
|
+
}
|
|
249
299
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
300
|
+
await this.client.selectPreset(presetKey);
|
|
301
|
+
callback(null);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
callback(null);
|
|
304
|
+
this.syncStationSwitches(this.lastKnownPresetKey);
|
|
254
305
|
}
|
|
255
|
-
} finally {
|
|
256
|
-
this.isUpdatingStationSwitches = false;
|
|
257
306
|
}
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
261
|
-
if (!this.ip) return;
|
|
262
307
|
|
|
263
|
-
|
|
308
|
+
syncStationSwitches(presetKey) {
|
|
309
|
+
if (!this.stationServices || this.stationServices.length === 0) return;
|
|
264
310
|
|
|
265
|
-
|
|
311
|
+
this.isUpdatingStationSwitches = true;
|
|
266
312
|
try {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
this.lastKnownPower = power;
|
|
270
|
-
this.switchService.getCharacteristic(Characteristic.On).updateValue(power);
|
|
313
|
+
for (const s of this.stationServices) {
|
|
314
|
+
s.service.getCharacteristic(Characteristic.On).updateValue(presetKey === s.presetKey);
|
|
271
315
|
}
|
|
272
|
-
}
|
|
273
|
-
|
|
316
|
+
} finally {
|
|
317
|
+
this.isUpdatingStationSwitches = false;
|
|
274
318
|
}
|
|
319
|
+
}
|
|
275
320
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const radioVol = await this.client.getVolume();
|
|
279
|
-
if (this.lastKnownRadioVolume !== radioVol) {
|
|
280
|
-
this.lastKnownRadioVolume = radioVol;
|
|
281
|
-
const homekitVol = radioToHomekitVolume(radioVol);
|
|
321
|
+
startPolling() {
|
|
322
|
+
if (!this.ip) return;
|
|
282
323
|
|
|
283
|
-
|
|
284
|
-
this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
|
|
285
|
-
}
|
|
324
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
286
325
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
326
|
+
const intervalMs = Math.max(2, this.pollIntervalSeconds) * 1000;
|
|
327
|
+
|
|
328
|
+
const tick = async () => {
|
|
329
|
+
try {
|
|
330
|
+
const power = await this.client.getPower();
|
|
331
|
+
if (this.lastKnownPower !== power) {
|
|
332
|
+
this.lastKnownPower = power;
|
|
333
|
+
this.powerService.getCharacteristic(Characteristic.On).updateValue(power);
|
|
290
334
|
}
|
|
291
|
-
} catch (
|
|
292
|
-
if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
|
|
335
|
+
} catch (e) {
|
|
293
336
|
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Preset readback is not implemented for this firmware
|
|
297
|
-
// We keep station switch states based on the last selected presetKey via HomeKit
|
|
298
|
-
};
|
|
299
337
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
338
|
+
if (this.enableVolume) {
|
|
339
|
+
try {
|
|
340
|
+
const radioVol = await this.client.getVolume();
|
|
341
|
+
if (this.lastKnownRadioVolume !== radioVol) {
|
|
342
|
+
this.lastKnownRadioVolume = radioVol;
|
|
343
|
+
const hk = radioToHomekitVolume(radioVol);
|
|
303
344
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
345
|
+
if (this.speakerService) this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(hk);
|
|
346
|
+
if (this.volumeSliderService) this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(hk);
|
|
347
|
+
}
|
|
348
|
+
} catch (e) {
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
308
352
|
|
|
309
|
-
|
|
310
|
-
|
|
353
|
+
tick();
|
|
354
|
+
this.pollTimer = setInterval(tick, intervalMs);
|
|
355
|
+
}
|
|
311
356
|
}
|
|
312
357
|
|
|
313
|
-
FsApiClient
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
358
|
+
class FsApiClient {
|
|
359
|
+
constructor({ ip, pin, timeoutMs }) {
|
|
360
|
+
this.ip = ip;
|
|
361
|
+
this.pin = pin;
|
|
362
|
+
this.timeoutMs = timeoutMs || 2500;
|
|
363
|
+
}
|
|
318
364
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
};
|
|
365
|
+
getPower() {
|
|
366
|
+
return this.getNodeNumber("netRemote.sys.power").then((v) => v === 1);
|
|
367
|
+
}
|
|
323
368
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
};
|
|
369
|
+
setPower(on) {
|
|
370
|
+
const v = on ? 1 : 0;
|
|
371
|
+
return this.setNodeNumber("netRemote.sys.power", v);
|
|
372
|
+
}
|
|
329
373
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
};
|
|
374
|
+
getVolume() {
|
|
375
|
+
return this.getNodeNumber("netRemote.sys.audio.volume").then((v) => clampInt(v, 0, 100));
|
|
376
|
+
}
|
|
334
377
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (!Number.isFinite(p)) throw new Error("Invalid preset key");
|
|
378
|
+
setVolume(v) {
|
|
379
|
+
return this.setNodeNumber("netRemote.sys.audio.volume", clampInt(v, 0, 100));
|
|
380
|
+
}
|
|
339
381
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
};
|
|
382
|
+
async selectPreset(presetKey) {
|
|
383
|
+
const p = Math.trunc(Number(presetKey));
|
|
384
|
+
if (!Number.isFinite(p)) throw new Error("Invalid presetKey");
|
|
344
385
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
386
|
+
// navigation based FSAPI required by many firmwares
|
|
387
|
+
await this.setNodeNumber("netRemote.nav.state", 1);
|
|
388
|
+
await this.setNodeNumber("netRemote.nav.action.selectPreset", p);
|
|
389
|
+
await this.setNodeNumber("netRemote.nav.state", 0);
|
|
390
|
+
}
|
|
348
391
|
|
|
349
|
-
|
|
350
|
-
|
|
392
|
+
async getNodeNumber(node) {
|
|
393
|
+
const xml = await this.request(`/fsapi/GET/${node}`);
|
|
394
|
+
return parseFsapiValue(xml);
|
|
395
|
+
}
|
|
351
396
|
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
clearTimeout(t);
|
|
397
|
+
async setNodeNumber(node, value) {
|
|
398
|
+
const xml = await this.request(`/fsapi/SET/${node}?value=${encodeURIComponent(String(value))}`);
|
|
399
|
+
const ok = String(xml).includes("<status>FS_OK</status>");
|
|
400
|
+
if (!ok) throw new Error("FSAPI SET failed");
|
|
401
|
+
return true;
|
|
358
402
|
}
|
|
359
|
-
};
|
|
360
403
|
|
|
361
|
-
|
|
362
|
-
|
|
404
|
+
request(path) {
|
|
405
|
+
const url = `http://${this.ip}${path}${path.includes("?") ? "&" : "?"}pin=${encodeURIComponent(this.pin)}`;
|
|
363
406
|
|
|
364
|
-
|
|
407
|
+
return new Promise((resolve, reject) => {
|
|
408
|
+
const req = http.get(url, { timeout: this.timeoutMs }, (res) => {
|
|
409
|
+
let data = "";
|
|
410
|
+
res.setEncoding("utf8");
|
|
411
|
+
res.on("data", (c) => (data += c));
|
|
412
|
+
res.on("end", () => resolve(data));
|
|
413
|
+
});
|
|
365
414
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
trimmed.match(/<value>\s*<u32>(\d+)<\/u32>\s*<\/value>/i) ||
|
|
370
|
-
trimmed.match(/<value>\s*<s16>(-?\d+)<\/s16>\s*<\/value>/i) ||
|
|
371
|
-
trimmed.match(/<value>\s*<c8_array>(.*?)<\/c8_array>\s*<\/value>/i);
|
|
415
|
+
req.on("timeout", () => {
|
|
416
|
+
req.destroy(new Error("timeout"));
|
|
417
|
+
});
|
|
372
418
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const num = Number(raw);
|
|
376
|
-
return Number.isFinite(num) ? num : raw;
|
|
419
|
+
req.on("error", reject);
|
|
420
|
+
});
|
|
377
421
|
}
|
|
422
|
+
}
|
|
378
423
|
|
|
379
|
-
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
424
|
+
function parseFsapiValue(xml) {
|
|
425
|
+
if (!xml) return null;
|
|
426
|
+
|
|
427
|
+
const s = String(xml);
|
|
428
|
+
|
|
429
|
+
const m =
|
|
430
|
+
s.match(/<value>\s*<u8>(-?\d+)<\/u8>\s*<\/value>/i) ||
|
|
431
|
+
s.match(/<value>\s*<u16>(-?\d+)<\/u16>\s*<\/value>/i) ||
|
|
432
|
+
s.match(/<value>\s*<u32>(-?\d+)<\/u32>\s*<\/value>/i) ||
|
|
433
|
+
s.match(/<value>\s*<s16>(-?\d+)<\/s16>\s*<\/value>/i);
|
|
385
434
|
|
|
386
|
-
|
|
387
|
-
if (tailNum) return Number(tailNum[1]);
|
|
435
|
+
if (m) return Number(m[1]);
|
|
388
436
|
|
|
389
|
-
|
|
437
|
+
const t = s.match(/<value>\s*<c8_array>(.*?)<\/c8_array>\s*<\/value>/i);
|
|
438
|
+
if (t) return t[1];
|
|
439
|
+
|
|
440
|
+
return null;
|
|
390
441
|
}
|
|
391
442
|
|
|
392
443
|
function clampInt(n, min, max) {
|
|
393
|
-
const v = Number.isFinite(n) ? Math.round(n) : min;
|
|
444
|
+
const v = Number.isFinite(Number(n)) ? Math.round(Number(n)) : min;
|
|
394
445
|
if (v < min) return min;
|
|
395
446
|
if (v > max) return max;
|
|
396
447
|
return v;
|
|
397
448
|
}
|
|
398
449
|
|
|
399
|
-
function toMsg(err) {
|
|
400
|
-
if (!err) return "";
|
|
401
|
-
if (err instanceof Error) return err.message;
|
|
402
|
-
return String(err);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
450
|
function homekitToRadioVolume(homekitValue) {
|
|
406
451
|
const x = clampInt(Number(homekitValue), 0, 100) / 100;
|
|
407
452
|
return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
|
package/package.json
CHANGED
|
@@ -1,31 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-frontier-silicon-plugin",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "Homebridge plugin for Frontier Silicon FSAPI
|
|
5
|
-
"
|
|
6
|
-
"license": "ISC",
|
|
7
|
-
"main": "index.js",
|
|
8
|
-
"homepage": "https://github.com/boikedamhuis/homebridge-frontier-silicon#readme",
|
|
3
|
+
"version": "1.2.9",
|
|
4
|
+
"description": "Homebridge plugin for Frontier Silicon (FSAPI / NetRemote) based internet and DAB+ radios",
|
|
5
|
+
"displayName": "Frontier Silicon Radio",
|
|
9
6
|
"keywords": [
|
|
10
7
|
"homebridge-plugin",
|
|
11
8
|
"homebridge",
|
|
12
|
-
"frontier",
|
|
13
|
-
"silicon",
|
|
9
|
+
"frontier-silicon",
|
|
14
10
|
"fsapi",
|
|
15
|
-
"internet-radio"
|
|
11
|
+
"internet-radio",
|
|
12
|
+
"dab",
|
|
13
|
+
"dab+",
|
|
14
|
+
"undok",
|
|
15
|
+
"reciva"
|
|
16
16
|
],
|
|
17
|
+
"homepage": "https://github.com/boikedamhuis/homebridge-frontier-silicon#readme",
|
|
17
18
|
"repository": {
|
|
18
19
|
"type": "git",
|
|
19
|
-
"url": "https://github.com/boikedamhuis/homebridge-frontier-silicon"
|
|
20
|
+
"url": "https://github.com/boikedamhuis/homebridge-frontier-silicon.git"
|
|
20
21
|
},
|
|
21
22
|
"bugs": {
|
|
22
23
|
"url": "https://github.com/boikedamhuis/homebridge-frontier-silicon/issues"
|
|
23
24
|
},
|
|
25
|
+
"license": "MIT",
|
|
24
26
|
"author": {
|
|
25
|
-
"name": "Boike Damhuis"
|
|
27
|
+
"name": "Boike Damhuis",
|
|
28
|
+
"url": "https://github.com/boikedamhuis"
|
|
26
29
|
},
|
|
30
|
+
"main": "index.js",
|
|
27
31
|
"engines": {
|
|
28
32
|
"node": ">=18.0.0",
|
|
29
33
|
"homebridge": ">=1.6.0"
|
|
34
|
+
},
|
|
35
|
+
"icon": "icon.png",
|
|
36
|
+
"files": [
|
|
37
|
+
"index.js",
|
|
38
|
+
"config.schema.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"icon.png"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"lint": "echo \"No lint configured\"",
|
|
45
|
+
"test": "echo \"No tests configured\""
|
|
46
|
+
},
|
|
47
|
+
"homebridge": {
|
|
48
|
+
"platforms": [
|
|
49
|
+
{
|
|
50
|
+
"platform": "frontier-silicon",
|
|
51
|
+
"name": "Frontier Silicon Radios"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
30
54
|
}
|
|
31
55
|
}
|