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.
Files changed (3) hide show
  1. package/config.schema.json +8 -1
  2. package/index.js +345 -300
  3. package/package.json +35 -11
@@ -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
- module.exports = function (homebridge) {
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.registerAccessory(
11
- "homebridge-frontier-silicone",
12
- "frontier-silicon",
13
- FrontierSiliconAccessory
14
- );
15
+ homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, FrontierSiliconPlatform);
15
16
  };
16
17
 
17
- function FrontierSiliconAccessory(log, config) {
18
- this.log = log;
18
+ class FrontierSiliconPlatform {
19
+ constructor(log, config, api) {
20
+ this.log = log;
21
+ this.api = api;
19
22
 
20
- this.name = config.name || "Frontier Silicon Radio";
21
- this.ip = config.ip;
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
- this.enableVolume = config.enableVolume !== false;
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
- this.exposeSpeakerService = config.exposeSpeakerService !== false;
28
- this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
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
- this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
40
+ configureAccessory(accessory) {
41
+ this.accessoriesByUUID.set(accessory.UUID, accessory);
42
+ }
31
43
 
32
- // stations: [{ name: "Radio 2", preset: 2 }, ...]
33
- // preset is 1 based like the radio UI, internally we convert to 0 based presetKey for FSAPI
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
- this.lastKnownPower = null;
37
- this.lastKnownRadioVolume = null;
47
+ const desiredUUIDs = new Set();
38
48
 
39
- // 0 based FSAPI preset key that we last selected via HomeKit
40
- this.lastKnownPresetKey = null;
49
+ for (const radioCfg of radios) {
50
+ if (!radioCfg || typeof radioCfg !== "object") continue;
41
51
 
42
- this.isUpdatingStationSwitches = false;
52
+ const name = String(radioCfg.name || "Frontier Silicon Radio");
53
+ const ip = String(radioCfg.ip || "").trim();
54
+ if (!ip) continue;
43
55
 
44
- if (!this.ip) {
45
- this.log.warn("No ip configured, accessory will not work.");
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
- this.client = new FsApiClient({
49
- ip: this.ip,
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
- if (this.exposeVolumeSlider) {
83
- this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
84
-
85
- this.volumeSliderService
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
- this.volumeSliderService
91
- .getCharacteristic(Characteristic.Brightness)
92
- .on("get", this.handleGetVolume.bind(this))
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
- this.stationServices = [];
98
- this.buildStationServices();
91
+ class FrontierSiliconRadioAccessory {
92
+ constructor(log, accessory, config) {
93
+ this.log = log;
94
+ this.accessory = accessory;
99
95
 
100
- this.startPolling();
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
- FrontierSiliconAccessory.prototype.buildStationServices = function () {
104
- if (!Array.isArray(this.stations) || this.stations.length === 0) return;
103
+ this.exposeSpeakerService = config.exposeSpeakerService !== false;
104
+ this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
105
105
 
106
- const seenSubtypes = new Set();
106
+ this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
107
107
 
108
- for (const s of this.stations) {
109
- if (!s || typeof s !== "object") continue;
108
+ // stations: [{ name: "Radio 2", preset: 2 }, ...] where preset is 1 based
109
+ this.stations = Array.isArray(config.stations) ? config.stations : [];
110
110
 
111
- const stationName = String(s.name ?? "").trim();
112
- const presetUi = Number(s.preset);
111
+ this.lastKnownPower = null;
112
+ this.lastKnownRadioVolume = null;
113
+ this.lastKnownPresetKey = null;
113
114
 
114
- if (!stationName) continue;
115
- if (!Number.isFinite(presetUi)) continue;
115
+ this.isUpdatingStationSwitches = false;
116
116
 
117
- // Convert 1 based UI preset number to 0 based FSAPI key
118
- const presetKey = Math.trunc(presetUi) - 1;
119
- if (presetKey < 0) continue;
117
+ this.client = new FsApiClient({
118
+ ip: this.ip,
119
+ pin: this.pin,
120
+ timeoutMs: 2500
121
+ });
120
122
 
121
- const subtype = "preset_" + String(presetKey);
122
- if (seenSubtypes.has(subtype)) continue;
123
- seenSubtypes.add(subtype);
123
+ this.setupServices();
124
+ this.startPolling();
125
+ }
124
126
 
125
- const sw = new Service.Switch(stationName, subtype);
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
- sw.getCharacteristic(Characteristic.On)
128
- .on("get", (cb) => {
129
- cb(null, this.lastKnownPresetKey === presetKey);
130
- })
131
- .on("set", (value, cb) => {
132
- this.handleSetStationPreset(presetKey, !!value, cb);
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
- this.stationServices.push({
136
- presetKey,
137
- presetUi: Math.trunc(presetUi),
138
- name: stationName,
139
- service: sw
140
- });
185
+ // Rebuild station services
186
+ this.removeOldStationServices();
187
+ this.stationServices = [];
188
+ this.buildStationServices();
141
189
  }
142
- };
143
190
 
144
- FrontierSiliconAccessory.prototype.getServices = function () {
145
- const services = [this.informationService, this.switchService];
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
- if (this.enableVolume && this.speakerService) services.push(this.speakerService);
148
- if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
199
+ buildStationServices() {
200
+ if (!Array.isArray(this.stations) || this.stations.length === 0) return;
149
201
 
150
- for (const s of this.stationServices) services.push(s.service);
202
+ const seen = new Set();
151
203
 
152
- return services;
153
- };
204
+ for (const st of this.stations) {
205
+ if (!st || typeof st !== "object") continue;
154
206
 
155
- FrontierSiliconAccessory.prototype.handleGetPower = async function (callback) {
156
- try {
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
- FrontierSiliconAccessory.prototype.handleSetPower = async function (value, callback) {
167
- const target = !!value;
210
+ if (!stationName) continue;
211
+ if (!Number.isFinite(presetUi)) continue;
168
212
 
169
- try {
170
- await this.client.setPower(target);
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
- FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
180
- try {
181
- const radioVol = await this.client.getVolume();
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
- FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, callback) {
192
- const radioVol = homekitToRadioVolume(value);
220
+ const sw = this.accessory.addService(Service.Switch, stationName, subtype);
193
221
 
194
- try {
195
- await this.client.setVolume(radioVol);
196
- this.lastKnownRadioVolume = radioVol;
197
- callback(null);
198
- } catch (err) {
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
- FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (presetKey, turnOn, callback) {
205
- if (this.isUpdatingStationSwitches) {
206
- callback(null);
207
- return;
228
+ this.stationServices.push({ presetKey, name: stationName, service: sw });
229
+ }
208
230
  }
209
231
 
210
- // Exclusive selector behaviour
211
- // Turning OFF does not change the radio, we snap back to the current selection
212
- if (!turnOn) {
213
- callback(null);
214
- this.syncStationSwitchesFromPresetKey(this.lastKnownPresetKey);
215
- return;
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
- // Optimistic UI update so previous station immediately turns off
219
- this.lastKnownPresetKey = presetKey;
220
- this.syncStationSwitchesFromPresetKey(presetKey);
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
- try {
223
- if (this.autoPowerOnOnPreset) {
224
- try {
225
- await this.client.setPower(true);
226
- this.lastKnownPower = true;
227
- this.switchService.getCharacteristic(Characteristic.On).updateValue(true);
228
- } catch (_e) {
229
- // ignore
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
- await this.client.setPresetKey(presetKey);
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
- callback(null);
236
- } catch (err) {
237
- this.log.warn("Preset set failed.", toMsg(err));
238
- callback(null);
275
+ async handleSetStationPreset(presetKey, turnOn, callback) {
276
+ if (this.isUpdatingStationSwitches) {
277
+ callback(null);
278
+ return;
279
+ }
239
280
 
240
- // Keep UI consistent with the last selection we know about
241
- this.syncStationSwitchesFromPresetKey(this.lastKnownPresetKey);
242
- }
243
- };
281
+ if (!turnOn) {
282
+ callback(null);
283
+ this.syncStationSwitches(this.lastKnownPresetKey);
284
+ return;
285
+ }
244
286
 
245
- FrontierSiliconAccessory.prototype.syncStationSwitchesFromPresetKey = function (presetKey) {
246
- if (!this.stationServices || this.stationServices.length === 0) return;
287
+ this.lastKnownPresetKey = presetKey;
288
+ this.syncStationSwitches(presetKey);
247
289
 
248
- this.isUpdatingStationSwitches = true;
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
- try {
251
- for (const s of this.stationServices) {
252
- const shouldBeOn = presetKey === s.presetKey;
253
- s.service.getCharacteristic(Characteristic.On).updateValue(shouldBeOn);
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
- const intervalMs = Math.max(2, this.pollIntervalSeconds) * 1000;
308
+ syncStationSwitches(presetKey) {
309
+ if (!this.stationServices || this.stationServices.length === 0) return;
264
310
 
265
- const tick = async () => {
311
+ this.isUpdatingStationSwitches = true;
266
312
  try {
267
- const power = await this.client.getPower();
268
- if (this.lastKnownPower !== power) {
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
- } catch (err) {
273
- if (this.log.debug) this.log.debug("Polling power failed.", toMsg(err));
316
+ } finally {
317
+ this.isUpdatingStationSwitches = false;
274
318
  }
319
+ }
275
320
 
276
- if (this.enableVolume) {
277
- try {
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
- if (this.speakerService) {
284
- this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
285
- }
324
+ if (this.pollTimer) clearInterval(this.pollTimer);
286
325
 
287
- if (this.volumeSliderService) {
288
- this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(homekitVol);
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 (err) {
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
- tick();
301
- this.pollTimer = setInterval(tick, intervalMs);
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
- function FsApiClient({ ip, pin, log }) {
305
- this.ip = ip;
306
- this.pin = pin;
307
- this.log = log;
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
- this.baseUrl = "http://" + ip;
310
- this.timeoutMs = 2500;
353
+ tick();
354
+ this.pollTimer = setInterval(tick, intervalMs);
355
+ }
311
356
  }
312
357
 
313
- FsApiClient.prototype.getPower = async function () {
314
- const text = await this.fetchText("/fsapi/GET/netRemote.sys.power");
315
- const value = parseFsapiValue(text);
316
- return value === 1;
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
- FsApiClient.prototype.setPower = async function (on) {
320
- const v = on ? 1 : 0;
321
- await this.fetchText("/fsapi/SET/netRemote.sys.power?value=" + v);
322
- };
365
+ getPower() {
366
+ return this.getNodeNumber("netRemote.sys.power").then((v) => v === 1);
367
+ }
323
368
 
324
- FsApiClient.prototype.getVolume = async function () {
325
- const text = await this.fetchText("/fsapi/GET/netRemote.sys.audio.volume");
326
- const value = parseFsapiValue(text);
327
- return clampInt(Number(value), 0, 100);
328
- };
369
+ setPower(on) {
370
+ const v = on ? 1 : 0;
371
+ return this.setNodeNumber("netRemote.sys.power", v);
372
+ }
329
373
 
330
- FsApiClient.prototype.setVolume = async function (volume) {
331
- const v = clampInt(Number(volume), 0, 100);
332
- await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
333
- };
374
+ getVolume() {
375
+ return this.getNodeNumber("netRemote.sys.audio.volume").then((v) => clampInt(v, 0, 100));
376
+ }
334
377
 
335
- // DIR3010 style preset selection using nav.state and selectPreset
336
- FsApiClient.prototype.setPresetKey = async function (presetKey) {
337
- const p = Math.trunc(Number(presetKey));
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
- await this.fetchText("/fsapi/SET/netRemote.nav.state?value=1");
341
- await this.fetchText("/fsapi/SET/netRemote.nav.action.selectPreset?value=" + encodeURIComponent(String(p)));
342
- await this.fetchText("/fsapi/SET/netRemote.nav.state?value=0");
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
- FsApiClient.prototype.fetchText = async function (pathAndQuery) {
346
- const joiner = pathAndQuery.includes("?") ? "&" : "?";
347
- const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
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
- const controller = new AbortController();
350
- const t = setTimeout(() => controller.abort(), this.timeoutMs);
392
+ async getNodeNumber(node) {
393
+ const xml = await this.request(`/fsapi/GET/${node}`);
394
+ return parseFsapiValue(xml);
395
+ }
351
396
 
352
- try {
353
- const res = await fetch(url, { signal: controller.signal });
354
- if (!res.ok) throw new Error("HTTP " + res.status);
355
- return await res.text();
356
- } finally {
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
- function parseFsapiValue(body) {
362
- if (!body) return null;
404
+ request(path) {
405
+ const url = `http://${this.ip}${path}${path.includes("?") ? "&" : "?"}pin=${encodeURIComponent(this.pin)}`;
363
406
 
364
- const trimmed = String(body).trim();
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
- const xmlMatch =
367
- trimmed.match(/<value>\s*<u8>(\d+)<\/u8>\s*<\/value>/i) ||
368
- trimmed.match(/<value>\s*<u16>(\d+)<\/u16>\s*<\/value>/i) ||
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
- if (xmlMatch) {
374
- const raw = xmlMatch[1];
375
- const num = Number(raw);
376
- return Number.isFinite(num) ? num : raw;
419
+ req.on("error", reject);
420
+ });
377
421
  }
422
+ }
378
423
 
379
- const okMatch = trimmed.match(/FS_OK\s+(.+)$/i);
380
- if (okMatch) {
381
- const raw = okMatch[1].trim();
382
- const num = Number(raw);
383
- return Number.isFinite(num) ? num : raw;
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
- const tailNum = trimmed.match(/(-?\d+)\s*$/);
387
- if (tailNum) return Number(tailNum[1]);
435
+ if (m) return Number(m[1]);
388
436
 
389
- return trimmed;
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.7",
4
- "description": "Homebridge plugin for Frontier Silicon FSAPI devices, power and volume with safe polling",
5
- "icon": "icon.png",
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
  }