homebridge-frontier-silicon-plugin 1.0.1 → 1.0.3

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/CHANGELOG.md +50 -0
  2. package/index.js +69 -32
  3. package/package.json +1 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on Keep a Changelog, and this project follows Semantic Versioning.
6
+
7
+ ## [1.1.0] – 2025-12-28
8
+
9
+ ### 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.
13
+
14
+ ### 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.
18
+
19
+ ## [1.0.2] – 2025-12-28
20
+
21
+ ### Changed
22
+ - Improved volume handling using a non-linear volume curve.
23
+ - Low volume levels are now significantly softer, allowing precise control at the bottom of the HomeKit slider.
24
+ - Higher volume levels ramp up faster to maintain full output range.
25
+
26
+ ### Improved
27
+ - Volume control now feels more natural and audio-appropriate.
28
+ - Better alignment between HomeKit slider behaviour and perceived loudness on Frontier Silicon radios.
29
+ - Overall usability of volume control in daily use.
30
+
31
+ ## [1.0.1] – 2025-12-28
32
+
33
+ ### Fixed
34
+ - Fixed FSAPI SET request formatting so power and volume changes are correctly applied to the radio.
35
+ - Resolved issue where HomeKit could read state changes but not write them back to the device.
36
+ - Improved reliability of write operations across Frontier Silicon devices.
37
+
38
+ ## [1.0.0] – 2025-12-28
39
+
40
+ ### Added
41
+ - Initial stable release of the Homebridge Frontier Silicon plugin.
42
+ - Power control via HomeKit using native FSAPI communication.
43
+ - Volume control with safe polling and automatic recovery when the device is unreachable.
44
+ - Configurable polling interval.
45
+ - Direct HTTP communication without external or native dependencies.
46
+
47
+ ### Fixed
48
+ - Eliminated crashes when the radio becomes temporarily unreachable.
49
+ - Removed dependency on legacy wifiradio and request modules.
50
+ - Replaced legacy polling mechanisms with safe async polling.
package/index.js CHANGED
@@ -21,10 +21,14 @@ function FrontierSiliconAccessory(log, config) {
21
21
  this.ip = config.ip;
22
22
  this.pin = String(config.pin ?? "1234");
23
23
  this.pollIntervalSeconds = Number(config.pollIntervalSeconds ?? 5);
24
+
25
+ // Keep volume enabled by default. This now uses a Speaker service.
24
26
  this.enableVolume = config.enableVolume !== false;
25
27
 
26
28
  this.lastKnownPower = null;
27
- this.lastKnownVolume = null;
29
+
30
+ // Store last known RADIO volume in device scale 0..100
31
+ this.lastKnownRadioVolume = null;
28
32
 
29
33
  if (!this.ip) {
30
34
  this.log.warn("No ip configured, accessory will not work.");
@@ -36,37 +40,45 @@ function FrontierSiliconAccessory(log, config) {
36
40
  log: this.log
37
41
  });
38
42
 
39
- this.switchService = new Service.Switch(this.name);
43
+ this.informationService = new Service.AccessoryInformation()
44
+ .setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
45
+ .setCharacteristic(Characteristic.Model, "FSAPI Radio")
46
+ .setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
40
47
 
48
+ // Power as Switch (kept as-is for maximum Home app compatibility)
49
+ this.switchService = new Service.Switch(this.name);
41
50
  this.switchService
42
51
  .getCharacteristic(Characteristic.On)
43
52
  .on("get", this.handleGetPower.bind(this))
44
53
  .on("set", this.handleSetPower.bind(this));
45
54
 
55
+ // Speaker service for volume (and optional mute)
46
56
  if (this.enableVolume) {
47
- this.volumeService = new Service.Lightbulb(this.name + " Volume");
48
- this.volumeService
49
- .getCharacteristic(Characteristic.On)
50
- .on("get", (cb) => cb(null, true))
51
- .on("set", (val, cb) => cb(null));
52
-
53
- this.volumeService
54
- .getCharacteristic(Characteristic.Brightness)
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)
55
62
  .on("get", this.handleGetVolume.bind(this))
56
63
  .on("set", this.handleSetVolume.bind(this));
57
- }
58
64
 
59
- this.informationService = new Service.AccessoryInformation()
60
- .setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
61
- .setCharacteristic(Characteristic.Model, "FSAPI Radio")
62
- .setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
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) {
69
+ this.speakerService
70
+ .getCharacteristic(Characteristic.Mute)
71
+ .on("get", (cb) => cb(null, false))
72
+ .on("set", (_val, cb) => cb(null));
73
+ }
74
+ }
63
75
 
64
76
  this.startPolling();
65
77
  }
66
78
 
67
79
  FrontierSiliconAccessory.prototype.getServices = function () {
68
80
  const services = [this.informationService, this.switchService];
69
- if (this.enableVolume && this.volumeService) services.push(this.volumeService);
81
+ if (this.enableVolume && this.speakerService) services.push(this.speakerService);
70
82
  return services;
71
83
  };
72
84
 
@@ -96,21 +108,25 @@ FrontierSiliconAccessory.prototype.handleSetPower = async function (value, callb
96
108
 
97
109
  FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
98
110
  try {
99
- const vol = await this.client.getVolume();
100
- this.lastKnownVolume = vol;
101
- callback(null, vol);
111
+ const radioVol = await this.client.getVolume();
112
+ this.lastKnownRadioVolume = radioVol;
113
+
114
+ const homekitVol = radioToHomekitVolume(radioVol);
115
+ callback(null, homekitVol);
102
116
  } catch (err) {
103
117
  this.log.warn("Volume get failed, returning last known level.", toMsg(err));
104
- callback(null, this.lastKnownVolume ?? 0);
118
+ const fallbackRadio = this.lastKnownRadioVolume ?? 0;
119
+ callback(null, radioToHomekitVolume(fallbackRadio));
105
120
  }
106
121
  };
107
122
 
108
123
  FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, callback) {
109
- const vol = clampInt(Number(value), 0, 100);
124
+ // Non linear mapping so low slider values are much softer
125
+ const radioVol = homekitToRadioVolume(value);
110
126
 
111
127
  try {
112
- await this.client.setVolume(vol);
113
- this.lastKnownVolume = vol;
128
+ await this.client.setVolume(radioVol);
129
+ this.lastKnownRadioVolume = radioVol;
114
130
  callback(null);
115
131
  } catch (err) {
116
132
  this.log.warn("Volume set failed, keeping last known level.", toMsg(err));
@@ -131,24 +147,26 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
131
147
  this.switchService.getCharacteristic(Characteristic.On).updateValue(power);
132
148
  }
133
149
  } catch (err) {
134
- this.log.debug ? this.log.debug("Polling power failed.", toMsg(err)) : this.log("Polling power failed.");
150
+ if (this.log.debug) this.log.debug("Polling power failed.", toMsg(err));
135
151
  }
136
152
 
137
- if (this.enableVolume && this.volumeService) {
153
+ if (this.enableVolume && this.speakerService) {
138
154
  try {
139
- const vol = await this.client.getVolume();
140
- if (this.lastKnownVolume !== vol) {
141
- this.lastKnownVolume = vol;
142
- this.volumeService.getCharacteristic(Characteristic.Brightness).updateValue(vol);
155
+ const radioVol = await this.client.getVolume();
156
+ if (this.lastKnownRadioVolume !== radioVol) {
157
+ this.lastKnownRadioVolume = radioVol;
158
+ const homekitVol = radioToHomekitVolume(radioVol);
159
+ this.speakerService
160
+ .getCharacteristic(Characteristic.Volume)
161
+ .updateValue(homekitVol);
143
162
  }
144
163
  } catch (err) {
145
- this.log.debug ? this.log.debug("Polling volume failed.", toMsg(err)) : this.log("Polling volume failed.");
164
+ if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
146
165
  }
147
166
  }
148
167
  };
149
168
 
150
169
  tick();
151
-
152
170
  this.pollTimer = setInterval(tick, intervalMs);
153
171
  };
154
172
 
@@ -185,7 +203,12 @@ FsApiClient.prototype.setVolume = async function (volume) {
185
203
 
186
204
  FsApiClient.prototype.fetchText = async function (pathAndQuery) {
187
205
  const joiner = pathAndQuery.includes("?") ? "&" : "?";
188
- const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
206
+ const url =
207
+ this.baseUrl +
208
+ pathAndQuery +
209
+ joiner +
210
+ "pin=" +
211
+ encodeURIComponent(this.pin);
189
212
 
190
213
  const controller = new AbortController();
191
214
  const t = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -242,3 +265,17 @@ function toMsg(err) {
242
265
  if (err instanceof Error) return err.message;
243
266
  return String(err);
244
267
  }
268
+
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
+ function homekitToRadioVolume(homekitValue) {
274
+ const x = clampInt(Number(homekitValue), 0, 100) / 100;
275
+ return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
276
+ }
277
+
278
+ function radioToHomekitVolume(radioValue) {
279
+ const x = clampInt(Number(radioValue), 0, 100) / 100;
280
+ return clampInt(Math.round(Math.sqrt(x) * 100), 0, 100);
281
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-frontier-silicon-plugin",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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",