homebridge-frontier-silicon-plugin 1.0.2 → 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 +14 -0
- package/config.schema.json +67 -25
- package/index.js +66 -37
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ 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.1.0] – 2025-12-28
|
|
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
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
1. Volume can now be controlled through either the Speaker service, the Apple Home slider service, or both.
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
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.
|
|
20
|
+
|
|
7
21
|
## [1.0.2] – 2025-12-28
|
|
8
22
|
|
|
9
23
|
### Changed
|
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
|
@@ -21,11 +21,18 @@ 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
|
+
|
|
24
25
|
this.enableVolume = config.enableVolume !== false;
|
|
25
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
|
+
|
|
26
33
|
this.lastKnownPower = null;
|
|
27
34
|
|
|
28
|
-
//
|
|
35
|
+
// Store last known RADIO volume on device scale 0..100
|
|
29
36
|
this.lastKnownRadioVolume = null;
|
|
30
37
|
|
|
31
38
|
if (!this.ip) {
|
|
@@ -38,39 +45,62 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
38
45
|
log: this.log
|
|
39
46
|
});
|
|
40
47
|
|
|
41
|
-
this.
|
|
48
|
+
this.informationService = new Service.AccessoryInformation()
|
|
49
|
+
.setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
|
|
50
|
+
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
51
|
+
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
42
52
|
|
|
53
|
+
// Power as Switch
|
|
54
|
+
this.switchService = new Service.Switch(this.name);
|
|
43
55
|
this.switchService
|
|
44
56
|
.getCharacteristic(Characteristic.On)
|
|
45
57
|
.on("get", this.handleGetPower.bind(this))
|
|
46
58
|
.on("set", this.handleSetPower.bind(this));
|
|
47
59
|
|
|
48
60
|
if (this.enableVolume) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
if (this.exposeSpeakerService) {
|
|
62
|
+
this.speakerService = new Service.Speaker(this.name + " Speaker");
|
|
63
|
+
|
|
64
|
+
this.speakerService
|
|
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
|
+
}
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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))
|
|
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));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
67
94
|
|
|
68
95
|
this.startPolling();
|
|
69
96
|
}
|
|
70
97
|
|
|
71
98
|
FrontierSiliconAccessory.prototype.getServices = function () {
|
|
72
99
|
const services = [this.informationService, this.switchService];
|
|
73
|
-
|
|
100
|
+
|
|
101
|
+
if (this.enableVolume && this.speakerService) services.push(this.speakerService);
|
|
102
|
+
if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
|
|
103
|
+
|
|
74
104
|
return services;
|
|
75
105
|
};
|
|
76
106
|
|
|
@@ -113,7 +143,6 @@ FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
|
|
|
113
143
|
};
|
|
114
144
|
|
|
115
145
|
FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, callback) {
|
|
116
|
-
// Non linear mapping so low slider values are much softer
|
|
117
146
|
const radioVol = homekitToRadioVolume(value);
|
|
118
147
|
|
|
119
148
|
try {
|
|
@@ -142,15 +171,26 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
|
142
171
|
if (this.log.debug) this.log.debug("Polling power failed.", toMsg(err));
|
|
143
172
|
}
|
|
144
173
|
|
|
145
|
-
if (this.enableVolume
|
|
174
|
+
if (this.enableVolume) {
|
|
146
175
|
try {
|
|
147
176
|
const radioVol = await this.client.getVolume();
|
|
177
|
+
|
|
148
178
|
if (this.lastKnownRadioVolume !== radioVol) {
|
|
149
179
|
this.lastKnownRadioVolume = radioVol;
|
|
180
|
+
|
|
150
181
|
const homekitVol = radioToHomekitVolume(radioVol);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
.
|
|
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
|
+
}
|
|
154
194
|
}
|
|
155
195
|
} catch (err) {
|
|
156
196
|
if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
|
|
@@ -179,7 +219,6 @@ FsApiClient.prototype.getPower = async function () {
|
|
|
179
219
|
|
|
180
220
|
FsApiClient.prototype.setPower = async function (on) {
|
|
181
221
|
const v = on ? 1 : 0;
|
|
182
|
-
// Important: SET must start query with ?value= so pin can be appended with &
|
|
183
222
|
await this.fetchText("/fsapi/SET/netRemote.sys.power?value=" + v);
|
|
184
223
|
};
|
|
185
224
|
|
|
@@ -191,18 +230,12 @@ FsApiClient.prototype.getVolume = async function () {
|
|
|
191
230
|
|
|
192
231
|
FsApiClient.prototype.setVolume = async function (volume) {
|
|
193
232
|
const v = clampInt(Number(volume), 0, 100);
|
|
194
|
-
// Important: SET must start query with ?value= so pin can be appended with &
|
|
195
233
|
await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
|
|
196
234
|
};
|
|
197
235
|
|
|
198
236
|
FsApiClient.prototype.fetchText = async function (pathAndQuery) {
|
|
199
237
|
const joiner = pathAndQuery.includes("?") ? "&" : "?";
|
|
200
|
-
const url =
|
|
201
|
-
this.baseUrl +
|
|
202
|
-
pathAndQuery +
|
|
203
|
-
joiner +
|
|
204
|
-
"pin=" +
|
|
205
|
-
encodeURIComponent(this.pin);
|
|
238
|
+
const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
|
|
206
239
|
|
|
207
240
|
const controller = new AbortController();
|
|
208
241
|
const t = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -260,10 +293,6 @@ function toMsg(err) {
|
|
|
260
293
|
return String(err);
|
|
261
294
|
}
|
|
262
295
|
|
|
263
|
-
// Non linear volume mapping
|
|
264
|
-
// HomeKit slider 0..100 is mapped to device volume 0..100
|
|
265
|
-
// Low slider values become much softer, high end remains reachable
|
|
266
|
-
|
|
267
296
|
function homekitToRadioVolume(homekitValue) {
|
|
268
297
|
const x = clampInt(Number(homekitValue), 0, 100) / 100;
|
|
269
298
|
return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
|
package/package.json
CHANGED