homebridge-frontier-silicon-plugin 1.1.0 → 1.2.1
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 +127 -14
- 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,17 +24,29 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
24
24
|
|
|
25
25
|
this.enableVolume = config.enableVolume !== false;
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
// Speaker service is for correct semantics
|
|
29
|
-
// Lightbulb slider is for Apple Home app usability
|
|
27
|
+
// Keep both by default
|
|
30
28
|
this.exposeSpeakerService = config.exposeSpeakerService !== false;
|
|
31
29
|
this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
|
|
32
30
|
|
|
31
|
+
// When selecting a station, power on first
|
|
32
|
+
this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
|
|
33
|
+
|
|
34
|
+
// Stations: [{ name: "Radio 2", preset: 0 }, ...]
|
|
35
|
+
// Important: preset numbers refer to the radio preset slots.
|
|
36
|
+
// If the radio says "empty preset", store that station to that preset slot on the radio first.
|
|
37
|
+
this.stations = Array.isArray(config.stations) ? config.stations : [];
|
|
38
|
+
|
|
33
39
|
this.lastKnownPower = null;
|
|
34
40
|
|
|
35
41
|
// Store last known RADIO volume on device scale 0..100
|
|
36
42
|
this.lastKnownRadioVolume = null;
|
|
37
43
|
|
|
44
|
+
// We cannot reliably read current preset index on all firmwares.
|
|
45
|
+
// We track what we last selected via HomeKit and sync switches accordingly.
|
|
46
|
+
this.lastKnownPresetIndex = null;
|
|
47
|
+
|
|
48
|
+
this.isUpdatingStationSwitches = false;
|
|
49
|
+
|
|
38
50
|
if (!this.ip) {
|
|
39
51
|
this.log.warn("No ip configured, accessory will not work.");
|
|
40
52
|
}
|
|
@@ -50,13 +62,14 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
50
62
|
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
51
63
|
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
52
64
|
|
|
53
|
-
// Power
|
|
65
|
+
// Power switch
|
|
54
66
|
this.switchService = new Service.Switch(this.name);
|
|
55
67
|
this.switchService
|
|
56
68
|
.getCharacteristic(Characteristic.On)
|
|
57
69
|
.on("get", this.handleGetPower.bind(this))
|
|
58
70
|
.on("set", this.handleSetPower.bind(this));
|
|
59
71
|
|
|
72
|
+
// Volume services
|
|
60
73
|
if (this.enableVolume) {
|
|
61
74
|
if (this.exposeSpeakerService) {
|
|
62
75
|
this.speakerService = new Service.Speaker(this.name + " Speaker");
|
|
@@ -66,8 +79,6 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
66
79
|
.on("get", this.handleGetVolume.bind(this))
|
|
67
80
|
.on("set", this.handleSetVolume.bind(this));
|
|
68
81
|
|
|
69
|
-
// Optional mute placeholder, kept non functional by design
|
|
70
|
-
// Some radios support mute through FSAPI, but that endpoint differs per model
|
|
71
82
|
if (Characteristic.Mute) {
|
|
72
83
|
this.speakerService
|
|
73
84
|
.getCharacteristic(Characteristic.Mute)
|
|
@@ -77,7 +88,6 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
if (this.exposeVolumeSlider) {
|
|
80
|
-
// Slider that works in Apple Home app
|
|
81
91
|
this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
|
|
82
92
|
|
|
83
93
|
this.volumeSliderService
|
|
@@ -92,15 +102,56 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
92
102
|
}
|
|
93
103
|
}
|
|
94
104
|
|
|
105
|
+
// Station switches
|
|
106
|
+
this.stationServices = [];
|
|
107
|
+
this.buildStationServices();
|
|
108
|
+
|
|
95
109
|
this.startPolling();
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
FrontierSiliconAccessory.prototype.buildStationServices = function () {
|
|
113
|
+
if (!Array.isArray(this.stations) || this.stations.length === 0) return;
|
|
114
|
+
|
|
115
|
+
const seenSubtypes = new Set();
|
|
116
|
+
|
|
117
|
+
for (const s of this.stations) {
|
|
118
|
+
if (!s || typeof s !== "object") continue;
|
|
119
|
+
|
|
120
|
+
const stationName = String(s.name ?? "").trim();
|
|
121
|
+
const preset = Number(s.preset);
|
|
122
|
+
|
|
123
|
+
if (!stationName) continue;
|
|
124
|
+
if (!Number.isFinite(preset)) continue;
|
|
125
|
+
|
|
126
|
+
const p = Math.trunc(preset);
|
|
127
|
+
const subtype = "preset_" + String(p);
|
|
128
|
+
|
|
129
|
+
if (seenSubtypes.has(subtype)) continue;
|
|
130
|
+
seenSubtypes.add(subtype);
|
|
131
|
+
|
|
132
|
+
const sw = new Service.Switch(stationName, subtype);
|
|
133
|
+
|
|
134
|
+
sw.getCharacteristic(Characteristic.On)
|
|
135
|
+
.on("get", (cb) => {
|
|
136
|
+
const isOn = this.lastKnownPresetIndex === p;
|
|
137
|
+
cb(null, isOn);
|
|
138
|
+
})
|
|
139
|
+
.on("set", (value, cb) => {
|
|
140
|
+
this.handleSetStationPreset(p, !!value, cb);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
this.stationServices.push({ preset: p, name: stationName, service: sw });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
98
147
|
FrontierSiliconAccessory.prototype.getServices = function () {
|
|
99
148
|
const services = [this.informationService, this.switchService];
|
|
100
149
|
|
|
101
150
|
if (this.enableVolume && this.speakerService) services.push(this.speakerService);
|
|
102
151
|
if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
|
|
103
152
|
|
|
153
|
+
for (const s of this.stationServices) services.push(s.service);
|
|
154
|
+
|
|
104
155
|
return services;
|
|
105
156
|
};
|
|
106
157
|
|
|
@@ -155,6 +206,57 @@ FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, call
|
|
|
155
206
|
}
|
|
156
207
|
};
|
|
157
208
|
|
|
209
|
+
FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (preset, turnOn, callback) {
|
|
210
|
+
if (this.isUpdatingStationSwitches) {
|
|
211
|
+
callback(null);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!turnOn) {
|
|
216
|
+
callback(null);
|
|
217
|
+
this.syncStationSwitchesFromPreset(this.lastKnownPresetIndex);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (this.autoPowerOnOnPreset) {
|
|
223
|
+
try {
|
|
224
|
+
await this.client.setPower(true);
|
|
225
|
+
this.lastKnownPower = true;
|
|
226
|
+
this.switchService.getCharacteristic(Characteristic.On).updateValue(true);
|
|
227
|
+
} catch (_e) {
|
|
228
|
+
// ignore
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await this.client.setPresetIndex(preset);
|
|
233
|
+
|
|
234
|
+
this.lastKnownPresetIndex = preset;
|
|
235
|
+
this.syncStationSwitchesFromPreset(preset);
|
|
236
|
+
|
|
237
|
+
callback(null);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this.log.warn("Preset set failed.", toMsg(err));
|
|
240
|
+
callback(null);
|
|
241
|
+
this.syncStationSwitchesFromPreset(this.lastKnownPresetIndex);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
FrontierSiliconAccessory.prototype.syncStationSwitchesFromPreset = function (presetIndex) {
|
|
246
|
+
if (!this.stationServices || this.stationServices.length === 0) return;
|
|
247
|
+
|
|
248
|
+
this.isUpdatingStationSwitches = true;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
for (const s of this.stationServices) {
|
|
252
|
+
const shouldBeOn = presetIndex === s.preset;
|
|
253
|
+
s.service.getCharacteristic(Characteristic.On).updateValue(shouldBeOn);
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
this.isUpdatingStationSwitches = false;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
158
260
|
FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
159
261
|
if (!this.ip) return;
|
|
160
262
|
|
|
@@ -174,28 +276,28 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
|
174
276
|
if (this.enableVolume) {
|
|
175
277
|
try {
|
|
176
278
|
const radioVol = await this.client.getVolume();
|
|
177
|
-
|
|
178
279
|
if (this.lastKnownRadioVolume !== radioVol) {
|
|
179
280
|
this.lastKnownRadioVolume = radioVol;
|
|
180
281
|
|
|
181
282
|
const homekitVol = radioToHomekitVolume(radioVol);
|
|
182
283
|
|
|
183
284
|
if (this.speakerService) {
|
|
184
|
-
this.speakerService
|
|
185
|
-
.getCharacteristic(Characteristic.Volume)
|
|
186
|
-
.updateValue(homekitVol);
|
|
285
|
+
this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
|
|
187
286
|
}
|
|
188
287
|
|
|
189
288
|
if (this.volumeSliderService) {
|
|
190
|
-
this.volumeSliderService
|
|
191
|
-
.getCharacteristic(Characteristic.Brightness)
|
|
192
|
-
.updateValue(homekitVol);
|
|
289
|
+
this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(homekitVol);
|
|
193
290
|
}
|
|
194
291
|
}
|
|
195
292
|
} catch (err) {
|
|
196
293
|
if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
|
|
197
294
|
}
|
|
198
295
|
}
|
|
296
|
+
|
|
297
|
+
// Preset polling is intentionally not implemented for this firmware,
|
|
298
|
+
// because netRemote.sys.preset.index does not exist.
|
|
299
|
+
// If you want full sync from the radio front panel later,
|
|
300
|
+
// we can add alternative detection based on play.info nodes.
|
|
199
301
|
};
|
|
200
302
|
|
|
201
303
|
tick();
|
|
@@ -233,6 +335,16 @@ FsApiClient.prototype.setVolume = async function (volume) {
|
|
|
233
335
|
await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
|
|
234
336
|
};
|
|
235
337
|
|
|
338
|
+
// DIR3010 style preset selection via navigation state
|
|
339
|
+
FsApiClient.prototype.setPresetIndex = async function (preset) {
|
|
340
|
+
const p = Math.trunc(Number(preset));
|
|
341
|
+
if (!Number.isFinite(p)) throw new Error("Invalid preset");
|
|
342
|
+
|
|
343
|
+
await this.fetchText("/fsapi/SET/netRemote.nav.state?value=1");
|
|
344
|
+
await this.fetchText("/fsapi/SET/netRemote.nav.action.selectPreset?value=" + encodeURIComponent(String(p)));
|
|
345
|
+
await this.fetchText("/fsapi/SET/netRemote.nav.state?value=0");
|
|
346
|
+
};
|
|
347
|
+
|
|
236
348
|
FsApiClient.prototype.fetchText = async function (pathAndQuery) {
|
|
237
349
|
const joiner = pathAndQuery.includes("?") ? "&" : "?";
|
|
238
350
|
const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
|
|
@@ -293,6 +405,7 @@ function toMsg(err) {
|
|
|
293
405
|
return String(err);
|
|
294
406
|
}
|
|
295
407
|
|
|
408
|
+
// Non linear volume mapping
|
|
296
409
|
function homekitToRadioVolume(homekitValue) {
|
|
297
410
|
const x = clampInt(Number(homekitValue), 0, 100) / 100;
|
|
298
411
|
return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
|
package/package.json
CHANGED