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 +19 -6
- package/config.schema.json +107 -25
- package/index.js +163 -34
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
package/config.schema.json
CHANGED
|
@@ -1,29 +1,111 @@
|
|
|
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": "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
|
-
"
|
|
23
|
-
"name"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
62
|
+
if (this.exposeSpeakerService) {
|
|
63
|
+
this.speakerService = new Service.Speaker(this.name + " Speaker");
|
|
64
|
+
|
|
69
65
|
this.speakerService
|
|
70
|
-
.getCharacteristic(Characteristic.
|
|
71
|
-
.on("get", (
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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