homebridge-frontier-silicon-plugin 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and this project follows Semantic Versioning.
6
6
 
7
+ ## [1.2.0] – 2025-12-28
8
+
9
+ ### Added
10
+ - Station selection via HomeKit using preset-based switches.
11
+ - Optional native HomeKit Speaker service alongside Apple Home volume slider.
12
+ - Configurable station list in Homebridge UI mapped to radio presets.
13
+
14
+ ### Improved
15
+ - Better HomeKit usability for audio devices.
16
+ - Volume control remains visible in Apple Home while supporting proper audio semantics.
17
+
7
18
  ## [1.1.0] – 2025-12-28
8
19
 
9
20
  ### Added
@@ -3,7 +3,7 @@
3
3
  "pluginType": "accessory",
4
4
  "singular": false,
5
5
  "headerDisplay": "Frontier Silicon FSAPI radios",
6
- "footerDisplay": "Tip: most devices use PIN 1234. If the device is offline, Homebridge will keep the last known state and recover automatically.",
6
+ "footerDisplay": "Presets must be saved on the radio first. The plugin switches between preset numbers.",
7
7
  "schema": {
8
8
  "type": "object",
9
9
  "required": ["name", "ip"],
@@ -45,13 +45,43 @@
45
45
  "title": "Expose Speaker service",
46
46
  "type": "boolean",
47
47
  "default": true,
48
- "description": "Adds a native HomeKit Speaker service using the Volume characteristic. Some apps show this, Apple Home often does not."
48
+ "description": "Adds a native HomeKit Speaker service using the Volume characteristic."
49
49
  },
50
50
  "exposeVolumeSlider": {
51
51
  "title": "Expose Apple Home volume slider",
52
52
  "type": "boolean",
53
53
  "default": true,
54
54
  "description": "Adds a Lightbulb Brightness slider for volume control that is visible in the Apple Home app."
55
+ },
56
+ "autoPowerOnOnPreset": {
57
+ "title": "Power on when selecting a station",
58
+ "type": "boolean",
59
+ "default": true,
60
+ "description": "When you turn on a station switch, the radio will be powered on first."
61
+ },
62
+ "stations": {
63
+ "title": "Stations",
64
+ "type": "array",
65
+ "description": "List of station switches mapped to preset numbers. Save the stations to presets on the radio first.",
66
+ "items": {
67
+ "type": "object",
68
+ "required": ["name", "preset"],
69
+ "properties": {
70
+ "name": {
71
+ "title": "Station name",
72
+ "type": "string",
73
+ "default": "Radio 2"
74
+ },
75
+ "preset": {
76
+ "title": "Preset number",
77
+ "type": "integer",
78
+ "default": 1,
79
+ "minimum": 0,
80
+ "maximum": 99
81
+ }
82
+ }
83
+ },
84
+ "default": []
55
85
  }
56
86
  }
57
87
  },
@@ -62,10 +92,20 @@
62
92
  "pollIntervalSeconds",
63
93
  "enableVolume",
64
94
  "exposeSpeakerService",
65
- "exposeVolumeSlider"
95
+ "exposeVolumeSlider",
96
+ "autoPowerOnOnPreset",
97
+ {
98
+ "key": "stations",
99
+ "type": "array",
100
+ "title": "Stations",
101
+ "items": [
102
+ "stations[].name",
103
+ "stations[].preset"
104
+ ]
105
+ }
66
106
  ],
67
107
  "display": {
68
108
  "name": "Frontier Silicon Radio",
69
- "description": "Control power and volume via FSAPI."
109
+ "description": "Control power, volume, and station presets via FSAPI."
70
110
  }
71
111
  }
package/index.js CHANGED
@@ -24,16 +24,18 @@ function FrontierSiliconAccessory(log, config) {
24
24
 
25
25
  this.enableVolume = config.enableVolume !== false;
26
26
 
27
- // In 1.1.0 we expose both by default
28
- // Speaker service is for correct semantics
29
- // Lightbulb slider is for Apple Home app usability
30
27
  this.exposeSpeakerService = config.exposeSpeakerService !== false;
31
28
  this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
32
29
 
33
- this.lastKnownPower = null;
30
+ this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
31
+
32
+ this.stations = Array.isArray(config.stations) ? config.stations : [];
34
33
 
35
- // Store last known RADIO volume on device scale 0..100
34
+ this.lastKnownPower = null;
36
35
  this.lastKnownRadioVolume = null;
36
+ this.lastKnownPresetIndex = null;
37
+
38
+ this.isUpdatingStationSwitches = false;
37
39
 
38
40
  if (!this.ip) {
39
41
  this.log.warn("No ip configured, accessory will not work.");
@@ -50,7 +52,6 @@ function FrontierSiliconAccessory(log, config) {
50
52
  .setCharacteristic(Characteristic.Model, "FSAPI Radio")
51
53
  .setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
52
54
 
53
- // Power as Switch
54
55
  this.switchService = new Service.Switch(this.name);
55
56
  this.switchService
56
57
  .getCharacteristic(Characteristic.On)
@@ -66,8 +67,6 @@ function FrontierSiliconAccessory(log, config) {
66
67
  .on("get", this.handleGetVolume.bind(this))
67
68
  .on("set", this.handleSetVolume.bind(this));
68
69
 
69
- // Optional mute placeholder, kept non functional by design
70
- // Some radios support mute through FSAPI, but that endpoint differs per model
71
70
  if (Characteristic.Mute) {
72
71
  this.speakerService
73
72
  .getCharacteristic(Characteristic.Mute)
@@ -77,7 +76,6 @@ function FrontierSiliconAccessory(log, config) {
77
76
  }
78
77
 
79
78
  if (this.exposeVolumeSlider) {
80
- // Slider that works in Apple Home app
81
79
  this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
82
80
 
83
81
  this.volumeSliderService
@@ -92,15 +90,56 @@ function FrontierSiliconAccessory(log, config) {
92
90
  }
93
91
  }
94
92
 
93
+ this.stationServices = [];
94
+ this.stationServiceByPreset = new Map();
95
+
96
+ this.buildStationServices();
97
+
95
98
  this.startPolling();
96
99
  }
97
100
 
101
+ FrontierSiliconAccessory.prototype.buildStationServices = function () {
102
+ if (!Array.isArray(this.stations) || this.stations.length === 0) return;
103
+
104
+ const seenSubtypes = new Set();
105
+
106
+ for (const s of this.stations) {
107
+ if (!s || typeof s !== "object") continue;
108
+
109
+ const stationName = String(s.name ?? "").trim();
110
+ const preset = Number(s.preset);
111
+
112
+ if (!stationName) continue;
113
+ if (!Number.isFinite(preset)) continue;
114
+
115
+ const subtype = "preset_" + String(Math.trunc(preset));
116
+ if (seenSubtypes.has(subtype)) continue;
117
+ seenSubtypes.add(subtype);
118
+
119
+ const sw = new Service.Switch(stationName, subtype);
120
+
121
+ sw.getCharacteristic(Characteristic.On)
122
+ .on("get", (cb) => {
123
+ const isOn = this.lastKnownPresetIndex === Math.trunc(preset);
124
+ cb(null, isOn);
125
+ })
126
+ .on("set", (value, cb) => {
127
+ this.handleSetStationPreset(Math.trunc(preset), !!value, cb);
128
+ });
129
+
130
+ this.stationServices.push({ preset: Math.trunc(preset), name: stationName, service: sw });
131
+ this.stationServiceByPreset.set(Math.trunc(preset), sw);
132
+ }
133
+ };
134
+
98
135
  FrontierSiliconAccessory.prototype.getServices = function () {
99
136
  const services = [this.informationService, this.switchService];
100
137
 
101
138
  if (this.enableVolume && this.speakerService) services.push(this.speakerService);
102
139
  if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
103
140
 
141
+ for (const s of this.stationServices) services.push(s.service);
142
+
104
143
  return services;
105
144
  };
106
145
 
@@ -155,6 +194,55 @@ FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, call
155
194
  }
156
195
  };
157
196
 
197
+ FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (preset, turnOn, callback) {
198
+ if (this.isUpdatingStationSwitches) {
199
+ callback(null);
200
+ return;
201
+ }
202
+
203
+ if (!turnOn) {
204
+ callback(null);
205
+ this.syncStationSwitchesFromPreset(this.lastKnownPresetIndex);
206
+ return;
207
+ }
208
+
209
+ try {
210
+ if (this.autoPowerOnOnPreset) {
211
+ try {
212
+ await this.client.setPower(true);
213
+ this.lastKnownPower = true;
214
+ this.switchService.getCharacteristic(Characteristic.On).updateValue(true);
215
+ } catch (_e) {
216
+ }
217
+ }
218
+
219
+ await this.client.setPresetIndex(preset);
220
+ this.lastKnownPresetIndex = preset;
221
+
222
+ this.syncStationSwitchesFromPreset(preset);
223
+ callback(null);
224
+ } catch (err) {
225
+ this.log.warn("Preset set failed.", toMsg(err));
226
+ callback(null);
227
+ this.syncStationSwitchesFromPreset(this.lastKnownPresetIndex);
228
+ }
229
+ };
230
+
231
+ FrontierSiliconAccessory.prototype.syncStationSwitchesFromPreset = function (presetIndex) {
232
+ if (!this.stationServices || this.stationServices.length === 0) return;
233
+
234
+ this.isUpdatingStationSwitches = true;
235
+
236
+ try {
237
+ for (const s of this.stationServices) {
238
+ const shouldBeOn = presetIndex === s.preset;
239
+ s.service.getCharacteristic(Characteristic.On).updateValue(shouldBeOn);
240
+ }
241
+ } finally {
242
+ this.isUpdatingStationSwitches = false;
243
+ }
244
+ };
245
+
158
246
  FrontierSiliconAccessory.prototype.startPolling = function () {
159
247
  if (!this.ip) return;
160
248
 
@@ -174,28 +262,35 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
174
262
  if (this.enableVolume) {
175
263
  try {
176
264
  const radioVol = await this.client.getVolume();
177
-
178
265
  if (this.lastKnownRadioVolume !== radioVol) {
179
266
  this.lastKnownRadioVolume = radioVol;
180
267
 
181
268
  const homekitVol = radioToHomekitVolume(radioVol);
182
269
 
183
270
  if (this.speakerService) {
184
- this.speakerService
185
- .getCharacteristic(Characteristic.Volume)
186
- .updateValue(homekitVol);
271
+ this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
187
272
  }
188
273
 
189
274
  if (this.volumeSliderService) {
190
- this.volumeSliderService
191
- .getCharacteristic(Characteristic.Brightness)
192
- .updateValue(homekitVol);
275
+ this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(homekitVol);
193
276
  }
194
277
  }
195
278
  } catch (err) {
196
279
  if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
197
280
  }
198
281
  }
282
+
283
+ if (this.stationServices && this.stationServices.length > 0) {
284
+ try {
285
+ const preset = await this.client.getPresetIndex();
286
+ if (Number.isFinite(preset) && this.lastKnownPresetIndex !== preset) {
287
+ this.lastKnownPresetIndex = preset;
288
+ this.syncStationSwitchesFromPreset(preset);
289
+ }
290
+ } catch (err) {
291
+ if (this.log.debug) this.log.debug("Polling preset failed.", toMsg(err));
292
+ }
293
+ }
199
294
  };
200
295
 
201
296
  tick();
@@ -233,6 +328,17 @@ FsApiClient.prototype.setVolume = async function (volume) {
233
328
  await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
234
329
  };
235
330
 
331
+ FsApiClient.prototype.getPresetIndex = async function () {
332
+ const text = await this.fetchText("/fsapi/GET/netRemote.sys.preset.index");
333
+ const value = parseFsapiValue(text);
334
+ const n = Number(value);
335
+ return Number.isFinite(n) ? Math.trunc(n) : null;
336
+ };
337
+
338
+ FsApiClient.prototype.setPresetIndex = async function (preset) {
339
+ await this.fetchText("/fsapi/SET/netRemote.sys.preset.index?value=" + encodeURIComponent(String(preset)));
340
+ };
341
+
236
342
  FsApiClient.prototype.fetchText = async function (pathAndQuery) {
237
343
  const joiner = pathAndQuery.includes("?") ? "&" : "?";
238
344
  const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-frontier-silicon-plugin",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Homebridge plugin for Frontier Silicon FSAPI devices, power and volume with safe polling",
5
5
  "license": "ISC",
6
6
  "main": "index.js",