homebridge-frontier-silicon-plugin 1.1.0 → 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 +11 -0
- package/config.schema.json +44 -4
- package/index.js +122 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ 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
|
|
|
9
20
|
### Added
|
package/config.schema.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"pluginType": "accessory",
|
|
4
4
|
"singular": false,
|
|
5
5
|
"headerDisplay": "Frontier Silicon FSAPI radios",
|
|
6
|
-
"footerDisplay": "
|
|
6
|
+
"footerDisplay": "Presets must be saved on the radio first. The plugin switches between preset numbers.",
|
|
7
7
|
"schema": {
|
|
8
8
|
"type": "object",
|
|
9
9
|
"required": ["name", "ip"],
|
|
@@ -45,13 +45,43 @@
|
|
|
45
45
|
"title": "Expose Speaker service",
|
|
46
46
|
"type": "boolean",
|
|
47
47
|
"default": true,
|
|
48
|
-
"description": "Adds a native HomeKit Speaker service using the Volume characteristic.
|
|
48
|
+
"description": "Adds a native HomeKit Speaker service using the Volume characteristic."
|
|
49
49
|
},
|
|
50
50
|
"exposeVolumeSlider": {
|
|
51
51
|
"title": "Expose Apple Home volume slider",
|
|
52
52
|
"type": "boolean",
|
|
53
53
|
"default": true,
|
|
54
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": []
|
|
55
85
|
}
|
|
56
86
|
}
|
|
57
87
|
},
|
|
@@ -62,10 +92,20 @@
|
|
|
62
92
|
"pollIntervalSeconds",
|
|
63
93
|
"enableVolume",
|
|
64
94
|
"exposeSpeakerService",
|
|
65
|
-
"exposeVolumeSlider"
|
|
95
|
+
"exposeVolumeSlider",
|
|
96
|
+
"autoPowerOnOnPreset",
|
|
97
|
+
{
|
|
98
|
+
"key": "stations",
|
|
99
|
+
"type": "array",
|
|
100
|
+
"title": "Stations",
|
|
101
|
+
"items": [
|
|
102
|
+
"stations[].name",
|
|
103
|
+
"stations[].preset"
|
|
104
|
+
]
|
|
105
|
+
}
|
|
66
106
|
],
|
|
67
107
|
"display": {
|
|
68
108
|
"name": "Frontier Silicon Radio",
|
|
69
|
-
"description": "Control power and
|
|
109
|
+
"description": "Control power, volume, and station presets via FSAPI."
|
|
70
110
|
}
|
|
71
111
|
}
|
package/index.js
CHANGED
|
@@ -24,16 +24,18 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
24
24
|
|
|
25
25
|
this.enableVolume = config.enableVolume !== false;
|
|
26
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
27
|
this.exposeSpeakerService = config.exposeSpeakerService !== false;
|
|
31
28
|
this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
|
|
32
29
|
|
|
33
|
-
this.
|
|
30
|
+
this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
|
|
31
|
+
|
|
32
|
+
this.stations = Array.isArray(config.stations) ? config.stations : [];
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
this.lastKnownPower = null;
|
|
36
35
|
this.lastKnownRadioVolume = null;
|
|
36
|
+
this.lastKnownPresetIndex = null;
|
|
37
|
+
|
|
38
|
+
this.isUpdatingStationSwitches = false;
|
|
37
39
|
|
|
38
40
|
if (!this.ip) {
|
|
39
41
|
this.log.warn("No ip configured, accessory will not work.");
|
|
@@ -50,7 +52,6 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
50
52
|
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
51
53
|
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
52
54
|
|
|
53
|
-
// Power as Switch
|
|
54
55
|
this.switchService = new Service.Switch(this.name);
|
|
55
56
|
this.switchService
|
|
56
57
|
.getCharacteristic(Characteristic.On)
|
|
@@ -66,8 +67,6 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
66
67
|
.on("get", this.handleGetVolume.bind(this))
|
|
67
68
|
.on("set", this.handleSetVolume.bind(this));
|
|
68
69
|
|
|
69
|
-
// Optional mute placeholder, kept non functional by design
|
|
70
|
-
// Some radios support mute through FSAPI, but that endpoint differs per model
|
|
71
70
|
if (Characteristic.Mute) {
|
|
72
71
|
this.speakerService
|
|
73
72
|
.getCharacteristic(Characteristic.Mute)
|
|
@@ -77,7 +76,6 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
if (this.exposeVolumeSlider) {
|
|
80
|
-
// Slider that works in Apple Home app
|
|
81
79
|
this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
|
|
82
80
|
|
|
83
81
|
this.volumeSliderService
|
|
@@ -92,15 +90,56 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
92
90
|
}
|
|
93
91
|
}
|
|
94
92
|
|
|
93
|
+
this.stationServices = [];
|
|
94
|
+
this.stationServiceByPreset = new Map();
|
|
95
|
+
|
|
96
|
+
this.buildStationServices();
|
|
97
|
+
|
|
95
98
|
this.startPolling();
|
|
96
99
|
}
|
|
97
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
|
+
|
|
98
135
|
FrontierSiliconAccessory.prototype.getServices = function () {
|
|
99
136
|
const services = [this.informationService, this.switchService];
|
|
100
137
|
|
|
101
138
|
if (this.enableVolume && this.speakerService) services.push(this.speakerService);
|
|
102
139
|
if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
|
|
103
140
|
|
|
141
|
+
for (const s of this.stationServices) services.push(s.service);
|
|
142
|
+
|
|
104
143
|
return services;
|
|
105
144
|
};
|
|
106
145
|
|
|
@@ -155,6 +194,55 @@ FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, call
|
|
|
155
194
|
}
|
|
156
195
|
};
|
|
157
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
|
+
|
|
158
246
|
FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
159
247
|
if (!this.ip) return;
|
|
160
248
|
|
|
@@ -174,28 +262,35 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
|
174
262
|
if (this.enableVolume) {
|
|
175
263
|
try {
|
|
176
264
|
const radioVol = await this.client.getVolume();
|
|
177
|
-
|
|
178
265
|
if (this.lastKnownRadioVolume !== radioVol) {
|
|
179
266
|
this.lastKnownRadioVolume = radioVol;
|
|
180
267
|
|
|
181
268
|
const homekitVol = radioToHomekitVolume(radioVol);
|
|
182
269
|
|
|
183
270
|
if (this.speakerService) {
|
|
184
|
-
this.speakerService
|
|
185
|
-
.getCharacteristic(Characteristic.Volume)
|
|
186
|
-
.updateValue(homekitVol);
|
|
271
|
+
this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
|
|
187
272
|
}
|
|
188
273
|
|
|
189
274
|
if (this.volumeSliderService) {
|
|
190
|
-
this.volumeSliderService
|
|
191
|
-
.getCharacteristic(Characteristic.Brightness)
|
|
192
|
-
.updateValue(homekitVol);
|
|
275
|
+
this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(homekitVol);
|
|
193
276
|
}
|
|
194
277
|
}
|
|
195
278
|
} catch (err) {
|
|
196
279
|
if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
|
|
197
280
|
}
|
|
198
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
|
+
}
|
|
199
294
|
};
|
|
200
295
|
|
|
201
296
|
tick();
|
|
@@ -233,6 +328,17 @@ FsApiClient.prototype.setVolume = async function (volume) {
|
|
|
233
328
|
await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
|
|
234
329
|
};
|
|
235
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
|
+
|
|
236
342
|
FsApiClient.prototype.fetchText = async function (pathAndQuery) {
|
|
237
343
|
const joiner = pathAndQuery.includes("?") ? "&" : "?";
|
|
238
344
|
const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
|
package/package.json
CHANGED