homebridge-frontier-silicon-plugin 1.1.0 → 1.2.1

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,17 +24,29 @@ 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
27
+ // Keep both by default
30
28
  this.exposeSpeakerService = config.exposeSpeakerService !== false;
31
29
  this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
32
30
 
31
+ // When selecting a station, power on first
32
+ this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
33
+
34
+ // Stations: [{ name: "Radio 2", preset: 0 }, ...]
35
+ // Important: preset numbers refer to the radio preset slots.
36
+ // If the radio says "empty preset", store that station to that preset slot on the radio first.
37
+ this.stations = Array.isArray(config.stations) ? config.stations : [];
38
+
33
39
  this.lastKnownPower = null;
34
40
 
35
41
  // Store last known RADIO volume on device scale 0..100
36
42
  this.lastKnownRadioVolume = null;
37
43
 
44
+ // We cannot reliably read current preset index on all firmwares.
45
+ // We track what we last selected via HomeKit and sync switches accordingly.
46
+ this.lastKnownPresetIndex = null;
47
+
48
+ this.isUpdatingStationSwitches = false;
49
+
38
50
  if (!this.ip) {
39
51
  this.log.warn("No ip configured, accessory will not work.");
40
52
  }
@@ -50,13 +62,14 @@ function FrontierSiliconAccessory(log, config) {
50
62
  .setCharacteristic(Characteristic.Model, "FSAPI Radio")
51
63
  .setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
52
64
 
53
- // Power as Switch
65
+ // Power switch
54
66
  this.switchService = new Service.Switch(this.name);
55
67
  this.switchService
56
68
  .getCharacteristic(Characteristic.On)
57
69
  .on("get", this.handleGetPower.bind(this))
58
70
  .on("set", this.handleSetPower.bind(this));
59
71
 
72
+ // Volume services
60
73
  if (this.enableVolume) {
61
74
  if (this.exposeSpeakerService) {
62
75
  this.speakerService = new Service.Speaker(this.name + " Speaker");
@@ -66,8 +79,6 @@ function FrontierSiliconAccessory(log, config) {
66
79
  .on("get", this.handleGetVolume.bind(this))
67
80
  .on("set", this.handleSetVolume.bind(this));
68
81
 
69
- // Optional mute placeholder, kept non functional by design
70
- // Some radios support mute through FSAPI, but that endpoint differs per model
71
82
  if (Characteristic.Mute) {
72
83
  this.speakerService
73
84
  .getCharacteristic(Characteristic.Mute)
@@ -77,7 +88,6 @@ function FrontierSiliconAccessory(log, config) {
77
88
  }
78
89
 
79
90
  if (this.exposeVolumeSlider) {
80
- // Slider that works in Apple Home app
81
91
  this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
82
92
 
83
93
  this.volumeSliderService
@@ -92,15 +102,56 @@ function FrontierSiliconAccessory(log, config) {
92
102
  }
93
103
  }
94
104
 
105
+ // Station switches
106
+ this.stationServices = [];
107
+ this.buildStationServices();
108
+
95
109
  this.startPolling();
96
110
  }
97
111
 
112
+ FrontierSiliconAccessory.prototype.buildStationServices = function () {
113
+ if (!Array.isArray(this.stations) || this.stations.length === 0) return;
114
+
115
+ const seenSubtypes = new Set();
116
+
117
+ for (const s of this.stations) {
118
+ if (!s || typeof s !== "object") continue;
119
+
120
+ const stationName = String(s.name ?? "").trim();
121
+ const preset = Number(s.preset);
122
+
123
+ if (!stationName) continue;
124
+ if (!Number.isFinite(preset)) continue;
125
+
126
+ const p = Math.trunc(preset);
127
+ const subtype = "preset_" + String(p);
128
+
129
+ if (seenSubtypes.has(subtype)) continue;
130
+ seenSubtypes.add(subtype);
131
+
132
+ const sw = new Service.Switch(stationName, subtype);
133
+
134
+ sw.getCharacteristic(Characteristic.On)
135
+ .on("get", (cb) => {
136
+ const isOn = this.lastKnownPresetIndex === p;
137
+ cb(null, isOn);
138
+ })
139
+ .on("set", (value, cb) => {
140
+ this.handleSetStationPreset(p, !!value, cb);
141
+ });
142
+
143
+ this.stationServices.push({ preset: p, name: stationName, service: sw });
144
+ }
145
+ };
146
+
98
147
  FrontierSiliconAccessory.prototype.getServices = function () {
99
148
  const services = [this.informationService, this.switchService];
100
149
 
101
150
  if (this.enableVolume && this.speakerService) services.push(this.speakerService);
102
151
  if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
103
152
 
153
+ for (const s of this.stationServices) services.push(s.service);
154
+
104
155
  return services;
105
156
  };
106
157
 
@@ -155,6 +206,57 @@ FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, call
155
206
  }
156
207
  };
157
208
 
209
+ FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (preset, turnOn, callback) {
210
+ if (this.isUpdatingStationSwitches) {
211
+ callback(null);
212
+ return;
213
+ }
214
+
215
+ if (!turnOn) {
216
+ callback(null);
217
+ this.syncStationSwitchesFromPreset(this.lastKnownPresetIndex);
218
+ return;
219
+ }
220
+
221
+ try {
222
+ if (this.autoPowerOnOnPreset) {
223
+ try {
224
+ await this.client.setPower(true);
225
+ this.lastKnownPower = true;
226
+ this.switchService.getCharacteristic(Characteristic.On).updateValue(true);
227
+ } catch (_e) {
228
+ // ignore
229
+ }
230
+ }
231
+
232
+ await this.client.setPresetIndex(preset);
233
+
234
+ this.lastKnownPresetIndex = preset;
235
+ this.syncStationSwitchesFromPreset(preset);
236
+
237
+ callback(null);
238
+ } catch (err) {
239
+ this.log.warn("Preset set failed.", toMsg(err));
240
+ callback(null);
241
+ this.syncStationSwitchesFromPreset(this.lastKnownPresetIndex);
242
+ }
243
+ };
244
+
245
+ FrontierSiliconAccessory.prototype.syncStationSwitchesFromPreset = function (presetIndex) {
246
+ if (!this.stationServices || this.stationServices.length === 0) return;
247
+
248
+ this.isUpdatingStationSwitches = true;
249
+
250
+ try {
251
+ for (const s of this.stationServices) {
252
+ const shouldBeOn = presetIndex === s.preset;
253
+ s.service.getCharacteristic(Characteristic.On).updateValue(shouldBeOn);
254
+ }
255
+ } finally {
256
+ this.isUpdatingStationSwitches = false;
257
+ }
258
+ };
259
+
158
260
  FrontierSiliconAccessory.prototype.startPolling = function () {
159
261
  if (!this.ip) return;
160
262
 
@@ -174,28 +276,28 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
174
276
  if (this.enableVolume) {
175
277
  try {
176
278
  const radioVol = await this.client.getVolume();
177
-
178
279
  if (this.lastKnownRadioVolume !== radioVol) {
179
280
  this.lastKnownRadioVolume = radioVol;
180
281
 
181
282
  const homekitVol = radioToHomekitVolume(radioVol);
182
283
 
183
284
  if (this.speakerService) {
184
- this.speakerService
185
- .getCharacteristic(Characteristic.Volume)
186
- .updateValue(homekitVol);
285
+ this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
187
286
  }
188
287
 
189
288
  if (this.volumeSliderService) {
190
- this.volumeSliderService
191
- .getCharacteristic(Characteristic.Brightness)
192
- .updateValue(homekitVol);
289
+ this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(homekitVol);
193
290
  }
194
291
  }
195
292
  } catch (err) {
196
293
  if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
197
294
  }
198
295
  }
296
+
297
+ // Preset polling is intentionally not implemented for this firmware,
298
+ // because netRemote.sys.preset.index does not exist.
299
+ // If you want full sync from the radio front panel later,
300
+ // we can add alternative detection based on play.info nodes.
199
301
  };
200
302
 
201
303
  tick();
@@ -233,6 +335,16 @@ FsApiClient.prototype.setVolume = async function (volume) {
233
335
  await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
234
336
  };
235
337
 
338
+ // DIR3010 style preset selection via navigation state
339
+ FsApiClient.prototype.setPresetIndex = async function (preset) {
340
+ const p = Math.trunc(Number(preset));
341
+ if (!Number.isFinite(p)) throw new Error("Invalid preset");
342
+
343
+ await this.fetchText("/fsapi/SET/netRemote.nav.state?value=1");
344
+ await this.fetchText("/fsapi/SET/netRemote.nav.action.selectPreset?value=" + encodeURIComponent(String(p)));
345
+ await this.fetchText("/fsapi/SET/netRemote.nav.state?value=0");
346
+ };
347
+
236
348
  FsApiClient.prototype.fetchText = async function (pathAndQuery) {
237
349
  const joiner = pathAndQuery.includes("?") ? "&" : "?";
238
350
  const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
@@ -293,6 +405,7 @@ function toMsg(err) {
293
405
  return String(err);
294
406
  }
295
407
 
408
+ // Non linear volume mapping
296
409
  function homekitToRadioVolume(homekitValue) {
297
410
  const x = clampInt(Number(homekitValue), 0, 100) / 100;
298
411
  return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
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.1",
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",