homebridge-frontier-silicon-plugin 1.0.3 → 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,17 +4,30 @@ 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
 
20
+ ### Added
21
+ 1. Added an optional native HomeKit Speaker service for volume control using the Volume characteristic.
22
+ 2. Added configuration switches to expose the Speaker service and the Apple Home slider independently.
23
+
9
24
  ### Changed
10
- - Replaced the Lightbulb Brightness based volume control with a native HomeKit **Speaker** service.
11
- - Volume is now exposed using the official `Volume` characteristic.
12
- - Power and volume are logically separated, matching HomeKit audio device expectations.
25
+ 1. Volume can now be controlled through either the Speaker service, the Apple Home slider service, or both.
13
26
 
14
27
  ### Improved
15
- - More natural HomeKit UI for audio devices.
16
- - Better compatibility with HomeKit audio controls and third-party Home apps.
17
- - Non-linear volume mapping retained for precise low-volume control.
28
+ 1. Better compatibility with third party HomeKit apps via the Speaker service.
29
+ 2. Apple Home usability retained via the Brightness based slider.
30
+ 3. Non linear volume mapping remains for precise low volume control.
18
31
 
19
32
  ## [1.0.2] – 2025-12-28
20
33
 
@@ -1,29 +1,111 @@
1
1
  {
2
- "name": "homebridge-frontier-silicon-plugin",
3
- "version": "1.0.0",
4
- "description": "Homebridge plugin for Frontier Silicon FSAPI devices, power and volume with safe polling",
5
- "license": "ISC",
6
- "main": "index.js",
7
- "keywords": [
8
- "homebridge-pluginconfig.schema.json",
9
- "homebridge",
10
- "frontier",
11
- "silicon",
12
- "fsapi",
13
- "internet-radio"
14
- ],
15
- "repository": {
16
- "type": "git",
17
- "url": "https://github.com/boikedamhuis/homebridge-frontier-silicon"
18
- },
19
- "bugs": {
20
- "url": "https://github.com/boikedamhuis/homebridge-frontier-silicon/issues"
2
+ "pluginAlias": "frontier-silicon",
3
+ "pluginType": "accessory",
4
+ "singular": false,
5
+ "headerDisplay": "Frontier Silicon FSAPI radios",
6
+ "footerDisplay": "Presets must be saved on the radio first. The plugin switches between preset numbers.",
7
+ "schema": {
8
+ "type": "object",
9
+ "required": ["name", "ip"],
10
+ "properties": {
11
+ "name": {
12
+ "title": "Name",
13
+ "type": "string",
14
+ "default": "Living Room Radio",
15
+ "description": "The name shown in HomeKit."
16
+ },
17
+ "ip": {
18
+ "title": "IP Address",
19
+ "type": "string",
20
+ "default": "192.168.1.50",
21
+ "description": "The IP address of the radio on your network.",
22
+ "pattern": "^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$"
23
+ },
24
+ "pin": {
25
+ "title": "FSAPI PIN",
26
+ "type": "string",
27
+ "default": "1234",
28
+ "description": "FSAPI PIN for the radio. Default is usually 1234."
29
+ },
30
+ "pollIntervalSeconds": {
31
+ "title": "Poll interval (seconds)",
32
+ "type": "integer",
33
+ "default": 5,
34
+ "minimum": 2,
35
+ "maximum": 120,
36
+ "description": "How often to poll the device for updates."
37
+ },
38
+ "enableVolume": {
39
+ "title": "Enable volume control",
40
+ "type": "boolean",
41
+ "default": true,
42
+ "description": "Enable volume support."
43
+ },
44
+ "exposeSpeakerService": {
45
+ "title": "Expose Speaker service",
46
+ "type": "boolean",
47
+ "default": true,
48
+ "description": "Adds a native HomeKit Speaker service using the Volume characteristic."
49
+ },
50
+ "exposeVolumeSlider": {
51
+ "title": "Expose Apple Home volume slider",
52
+ "type": "boolean",
53
+ "default": true,
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": []
85
+ }
86
+ }
21
87
  },
22
- "author": {
23
- "name": "Boike Damhuis"
24
- },
25
- "engines": {
26
- "node": ">=18.0.0",
27
- "homebridge": ">=1.6.0"
88
+ "form": [
89
+ "name",
90
+ "ip",
91
+ "pin",
92
+ "pollIntervalSeconds",
93
+ "enableVolume",
94
+ "exposeSpeakerService",
95
+ "exposeVolumeSlider",
96
+ "autoPowerOnOnPreset",
97
+ {
98
+ "key": "stations",
99
+ "type": "array",
100
+ "title": "Stations",
101
+ "items": [
102
+ "stations[].name",
103
+ "stations[].preset"
104
+ ]
105
+ }
106
+ ],
107
+ "display": {
108
+ "name": "Frontier Silicon Radio",
109
+ "description": "Control power, volume, and station presets via FSAPI."
28
110
  }
29
111
  }
package/index.js CHANGED
@@ -22,13 +22,20 @@ function FrontierSiliconAccessory(log, config) {
22
22
  this.pin = String(config.pin ?? "1234");
23
23
  this.pollIntervalSeconds = Number(config.pollIntervalSeconds ?? 5);
24
24
 
25
- // Keep volume enabled by default. This now uses a Speaker service.
26
25
  this.enableVolume = config.enableVolume !== false;
27
26
 
28
- this.lastKnownPower = null;
27
+ this.exposeSpeakerService = config.exposeSpeakerService !== false;
28
+ this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
29
+
30
+ this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
31
+
32
+ this.stations = Array.isArray(config.stations) ? config.stations : [];
29
33
 
30
- // Store last known RADIO volume in device scale 0..100
34
+ this.lastKnownPower = null;
31
35
  this.lastKnownRadioVolume = null;
36
+ this.lastKnownPresetIndex = null;
37
+
38
+ this.isUpdatingStationSwitches = false;
32
39
 
33
40
  if (!this.ip) {
34
41
  this.log.warn("No ip configured, accessory will not work.");
@@ -45,40 +52,94 @@ function FrontierSiliconAccessory(log, config) {
45
52
  .setCharacteristic(Characteristic.Model, "FSAPI Radio")
46
53
  .setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
47
54
 
48
- // Power as Switch (kept as-is for maximum Home app compatibility)
49
55
  this.switchService = new Service.Switch(this.name);
50
56
  this.switchService
51
57
  .getCharacteristic(Characteristic.On)
52
58
  .on("get", this.handleGetPower.bind(this))
53
59
  .on("set", this.handleSetPower.bind(this));
54
60
 
55
- // Speaker service for volume (and optional mute)
56
61
  if (this.enableVolume) {
57
- this.speakerService = new Service.Speaker(this.name + " Speaker");
58
-
59
- // Volume characteristic uses HomeKit scale 0..100
60
- this.speakerService
61
- .getCharacteristic(Characteristic.Volume)
62
- .on("get", this.handleGetVolume.bind(this))
63
- .on("set", this.handleSetVolume.bind(this));
64
-
65
- // Optional mute support placeholder:
66
- // If you want real mute later, we can map this to an FSAPI endpoint if available.
67
- // For now, we expose mute but keep it non-functional (always false) to avoid confusion.
68
- if (Characteristic.Mute) {
62
+ if (this.exposeSpeakerService) {
63
+ this.speakerService = new Service.Speaker(this.name + " Speaker");
64
+
69
65
  this.speakerService
70
- .getCharacteristic(Characteristic.Mute)
71
- .on("get", (cb) => cb(null, false))
66
+ .getCharacteristic(Characteristic.Volume)
67
+ .on("get", this.handleGetVolume.bind(this))
68
+ .on("set", this.handleSetVolume.bind(this));
69
+
70
+ if (Characteristic.Mute) {
71
+ this.speakerService
72
+ .getCharacteristic(Characteristic.Mute)
73
+ .on("get", (cb) => cb(null, false))
74
+ .on("set", (_val, cb) => cb(null));
75
+ }
76
+ }
77
+
78
+ if (this.exposeVolumeSlider) {
79
+ this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
80
+
81
+ this.volumeSliderService
82
+ .getCharacteristic(Characteristic.On)
83
+ .on("get", (cb) => cb(null, true))
72
84
  .on("set", (_val, cb) => cb(null));
85
+
86
+ this.volumeSliderService
87
+ .getCharacteristic(Characteristic.Brightness)
88
+ .on("get", this.handleGetVolume.bind(this))
89
+ .on("set", this.handleSetVolume.bind(this));
73
90
  }
74
91
  }
75
92
 
93
+ this.stationServices = [];
94
+ this.stationServiceByPreset = new Map();
95
+
96
+ this.buildStationServices();
97
+
76
98
  this.startPolling();
77
99
  }
78
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
+
79
135
  FrontierSiliconAccessory.prototype.getServices = function () {
80
136
  const services = [this.informationService, this.switchService];
137
+
81
138
  if (this.enableVolume && this.speakerService) services.push(this.speakerService);
139
+ if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
140
+
141
+ for (const s of this.stationServices) services.push(s.service);
142
+
82
143
  return services;
83
144
  };
84
145
 
@@ -121,7 +182,6 @@ FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
121
182
  };
122
183
 
123
184
  FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, callback) {
124
- // Non linear mapping so low slider values are much softer
125
185
  const radioVol = homekitToRadioVolume(value);
126
186
 
127
187
  try {
@@ -134,6 +194,55 @@ FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, call
134
194
  }
135
195
  };
136
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
+
137
246
  FrontierSiliconAccessory.prototype.startPolling = function () {
138
247
  if (!this.ip) return;
139
248
 
@@ -150,20 +259,38 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
150
259
  if (this.log.debug) this.log.debug("Polling power failed.", toMsg(err));
151
260
  }
152
261
 
153
- if (this.enableVolume && this.speakerService) {
262
+ if (this.enableVolume) {
154
263
  try {
155
264
  const radioVol = await this.client.getVolume();
156
265
  if (this.lastKnownRadioVolume !== radioVol) {
157
266
  this.lastKnownRadioVolume = radioVol;
267
+
158
268
  const homekitVol = radioToHomekitVolume(radioVol);
159
- this.speakerService
160
- .getCharacteristic(Characteristic.Volume)
161
- .updateValue(homekitVol);
269
+
270
+ if (this.speakerService) {
271
+ this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
272
+ }
273
+
274
+ if (this.volumeSliderService) {
275
+ this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(homekitVol);
276
+ }
162
277
  }
163
278
  } catch (err) {
164
279
  if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
165
280
  }
166
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
+ }
167
294
  };
168
295
 
169
296
  tick();
@@ -201,14 +328,20 @@ FsApiClient.prototype.setVolume = async function (volume) {
201
328
  await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
202
329
  };
203
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
+
204
342
  FsApiClient.prototype.fetchText = async function (pathAndQuery) {
205
343
  const joiner = pathAndQuery.includes("?") ? "&" : "?";
206
- const url =
207
- this.baseUrl +
208
- pathAndQuery +
209
- joiner +
210
- "pin=" +
211
- encodeURIComponent(this.pin);
344
+ const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
212
345
 
213
346
  const controller = new AbortController();
214
347
  const t = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -266,10 +399,6 @@ function toMsg(err) {
266
399
  return String(err);
267
400
  }
268
401
 
269
- // Non linear volume mapping
270
- // HomeKit slider 0..100 is mapped to device volume 0..100
271
- // Low slider values become much softer, high end remains reachable
272
-
273
402
  function homekitToRadioVolume(homekitValue) {
274
403
  const x = clampInt(Number(homekitValue), 0, 100) / 100;
275
404
  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.0.3",
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",