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.
- package/CHANGELOG.md +50 -0
- package/index.js +69 -32
- 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
|
-
|
|
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.
|
|
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.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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
|
|
100
|
-
this.
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
113
|
-
this.
|
|
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
|
|
150
|
+
if (this.log.debug) this.log.debug("Polling power failed.", toMsg(err));
|
|
135
151
|
}
|
|
136
152
|
|
|
137
|
-
if (this.enableVolume && this.
|
|
153
|
+
if (this.enableVolume && this.speakerService) {
|
|
138
154
|
try {
|
|
139
|
-
const
|
|
140
|
-
if (this.
|
|
141
|
-
this.
|
|
142
|
-
|
|
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
|
|
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 =
|
|
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