homebridge-frontier-silicon-plugin 1.0.3 → 1.1.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 +8 -6
- package/config.schema.json +67 -25
- package/index.js +56 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,15 +6,17 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi
|
|
|
6
6
|
|
|
7
7
|
## [1.1.0] – 2025-12-28
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
1. Added an optional native HomeKit Speaker service for volume control using the Volume characteristic.
|
|
11
|
+
2. Added configuration switches to expose the Speaker service and the Apple Home slider independently.
|
|
12
|
+
|
|
9
13
|
### Changed
|
|
10
|
-
|
|
11
|
-
- Volume is now exposed using the official `Volume` characteristic.
|
|
12
|
-
- Power and volume are logically separated, matching HomeKit audio device expectations.
|
|
14
|
+
1. Volume can now be controlled through either the Speaker service, the Apple Home slider service, or both.
|
|
13
15
|
|
|
14
16
|
### Improved
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
1. Better compatibility with third party HomeKit apps via the Speaker service.
|
|
18
|
+
2. Apple Home usability retained via the Brightness based slider.
|
|
19
|
+
3. Non linear volume mapping remains for precise low volume control.
|
|
18
20
|
|
|
19
21
|
## [1.0.2] – 2025-12-28
|
|
20
22
|
|
package/config.schema.json
CHANGED
|
@@ -1,29 +1,71 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
2
|
+
"pluginAlias": "frontier-silicon",
|
|
3
|
+
"pluginType": "accessory",
|
|
4
|
+
"singular": false,
|
|
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.",
|
|
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. Some apps show this, Apple Home often does not."
|
|
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
|
+
}
|
|
21
57
|
},
|
|
22
|
-
"
|
|
23
|
-
"name"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
58
|
+
"form": [
|
|
59
|
+
"name",
|
|
60
|
+
"ip",
|
|
61
|
+
"pin",
|
|
62
|
+
"pollIntervalSeconds",
|
|
63
|
+
"enableVolume",
|
|
64
|
+
"exposeSpeakerService",
|
|
65
|
+
"exposeVolumeSlider"
|
|
66
|
+
],
|
|
67
|
+
"display": {
|
|
68
|
+
"name": "Frontier Silicon Radio",
|
|
69
|
+
"description": "Control power and volume via FSAPI."
|
|
28
70
|
}
|
|
29
71
|
}
|
package/index.js
CHANGED
|
@@ -22,12 +22,17 @@ 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
|
|
|
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
|
+
this.exposeSpeakerService = config.exposeSpeakerService !== false;
|
|
31
|
+
this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
|
|
32
|
+
|
|
28
33
|
this.lastKnownPower = null;
|
|
29
34
|
|
|
30
|
-
// Store last known RADIO volume
|
|
35
|
+
// Store last known RADIO volume on device scale 0..100
|
|
31
36
|
this.lastKnownRadioVolume = null;
|
|
32
37
|
|
|
33
38
|
if (!this.ip) {
|
|
@@ -45,31 +50,45 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
45
50
|
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
46
51
|
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
47
52
|
|
|
48
|
-
// Power as Switch
|
|
53
|
+
// Power as Switch
|
|
49
54
|
this.switchService = new Service.Switch(this.name);
|
|
50
55
|
this.switchService
|
|
51
56
|
.getCharacteristic(Characteristic.On)
|
|
52
57
|
.on("get", this.handleGetPower.bind(this))
|
|
53
58
|
.on("set", this.handleSetPower.bind(this));
|
|
54
59
|
|
|
55
|
-
// Speaker service for volume (and optional mute)
|
|
56
60
|
if (this.enableVolume) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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) {
|
|
61
|
+
if (this.exposeSpeakerService) {
|
|
62
|
+
this.speakerService = new Service.Speaker(this.name + " Speaker");
|
|
63
|
+
|
|
69
64
|
this.speakerService
|
|
70
|
-
.getCharacteristic(Characteristic.
|
|
71
|
-
.on("get", (
|
|
65
|
+
.getCharacteristic(Characteristic.Volume)
|
|
66
|
+
.on("get", this.handleGetVolume.bind(this))
|
|
67
|
+
.on("set", this.handleSetVolume.bind(this));
|
|
68
|
+
|
|
69
|
+
// Optional mute placeholder, kept non functional by design
|
|
70
|
+
// Some radios support mute through FSAPI, but that endpoint differs per model
|
|
71
|
+
if (Characteristic.Mute) {
|
|
72
|
+
this.speakerService
|
|
73
|
+
.getCharacteristic(Characteristic.Mute)
|
|
74
|
+
.on("get", (cb) => cb(null, false))
|
|
75
|
+
.on("set", (_val, cb) => cb(null));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.exposeVolumeSlider) {
|
|
80
|
+
// Slider that works in Apple Home app
|
|
81
|
+
this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
|
|
82
|
+
|
|
83
|
+
this.volumeSliderService
|
|
84
|
+
.getCharacteristic(Characteristic.On)
|
|
85
|
+
.on("get", (cb) => cb(null, true))
|
|
72
86
|
.on("set", (_val, cb) => cb(null));
|
|
87
|
+
|
|
88
|
+
this.volumeSliderService
|
|
89
|
+
.getCharacteristic(Characteristic.Brightness)
|
|
90
|
+
.on("get", this.handleGetVolume.bind(this))
|
|
91
|
+
.on("set", this.handleSetVolume.bind(this));
|
|
73
92
|
}
|
|
74
93
|
}
|
|
75
94
|
|
|
@@ -78,7 +97,10 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
78
97
|
|
|
79
98
|
FrontierSiliconAccessory.prototype.getServices = function () {
|
|
80
99
|
const services = [this.informationService, this.switchService];
|
|
100
|
+
|
|
81
101
|
if (this.enableVolume && this.speakerService) services.push(this.speakerService);
|
|
102
|
+
if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
|
|
103
|
+
|
|
82
104
|
return services;
|
|
83
105
|
};
|
|
84
106
|
|
|
@@ -121,7 +143,6 @@ FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
|
|
|
121
143
|
};
|
|
122
144
|
|
|
123
145
|
FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, callback) {
|
|
124
|
-
// Non linear mapping so low slider values are much softer
|
|
125
146
|
const radioVol = homekitToRadioVolume(value);
|
|
126
147
|
|
|
127
148
|
try {
|
|
@@ -150,15 +171,26 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
|
150
171
|
if (this.log.debug) this.log.debug("Polling power failed.", toMsg(err));
|
|
151
172
|
}
|
|
152
173
|
|
|
153
|
-
if (this.enableVolume
|
|
174
|
+
if (this.enableVolume) {
|
|
154
175
|
try {
|
|
155
176
|
const radioVol = await this.client.getVolume();
|
|
177
|
+
|
|
156
178
|
if (this.lastKnownRadioVolume !== radioVol) {
|
|
157
179
|
this.lastKnownRadioVolume = radioVol;
|
|
180
|
+
|
|
158
181
|
const homekitVol = radioToHomekitVolume(radioVol);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.
|
|
182
|
+
|
|
183
|
+
if (this.speakerService) {
|
|
184
|
+
this.speakerService
|
|
185
|
+
.getCharacteristic(Characteristic.Volume)
|
|
186
|
+
.updateValue(homekitVol);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (this.volumeSliderService) {
|
|
190
|
+
this.volumeSliderService
|
|
191
|
+
.getCharacteristic(Characteristic.Brightness)
|
|
192
|
+
.updateValue(homekitVol);
|
|
193
|
+
}
|
|
162
194
|
}
|
|
163
195
|
} catch (err) {
|
|
164
196
|
if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
|
|
@@ -203,12 +235,7 @@ FsApiClient.prototype.setVolume = async function (volume) {
|
|
|
203
235
|
|
|
204
236
|
FsApiClient.prototype.fetchText = async function (pathAndQuery) {
|
|
205
237
|
const joiner = pathAndQuery.includes("?") ? "&" : "?";
|
|
206
|
-
const url =
|
|
207
|
-
this.baseUrl +
|
|
208
|
-
pathAndQuery +
|
|
209
|
-
joiner +
|
|
210
|
-
"pin=" +
|
|
211
|
-
encodeURIComponent(this.pin);
|
|
238
|
+
const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
|
|
212
239
|
|
|
213
240
|
const controller = new AbortController();
|
|
214
241
|
const t = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -266,10 +293,6 @@ function toMsg(err) {
|
|
|
266
293
|
return String(err);
|
|
267
294
|
}
|
|
268
295
|
|
|
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
296
|
function homekitToRadioVolume(homekitValue) {
|
|
274
297
|
const x = clampInt(Number(homekitValue), 0, 100) / 100;
|
|
275
298
|
return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
|
package/package.json
CHANGED