homebridge-frontier-silicon-plugin 1.2.1 → 1.2.3
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 +20 -0
- package/README.md +73 -30
- package/index.js +39 -43
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ 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.2] – 2025-12-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Fixed station preset switches not turning off previously selected stations.
|
|
11
|
+
- Ensured station switches behave as an exclusive selector (radio-style behavior).
|
|
12
|
+
- Improved internal state handling to keep HomeKit UI in sync when switching stations.
|
|
13
|
+
|
|
14
|
+
### Improved
|
|
15
|
+
- More robust preset switching logic for Frontier Silicon radios using navigation-based FSAPI.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## [1.2.1] – 2025-12-28
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Corrected preset numbering by mapping Homebridge presets (1-based) to Frontier Silicon FSAPI keys (0-based).
|
|
22
|
+
- Fixed incorrect preset selection where presets were offset by one position on the radio.
|
|
23
|
+
|
|
24
|
+
### Improved
|
|
25
|
+
- Reliable DAB+ / preset switching using the required navigation-based FSAPI calls.
|
|
26
|
+
|
|
7
27
|
## [1.2.0] – 2025-12-28
|
|
8
28
|
|
|
9
29
|
### Added
|
package/README.md
CHANGED
|
@@ -30,49 +30,92 @@ This plugin is designed as a modern replacement for older Frontier Silicon Homeb
|
|
|
30
30
|
|
|
31
31
|
Most internet radios from brands such as Roberts, Ruark, Revo, Hama and similar use Frontier Silicon firmware and are compatible.
|
|
32
32
|
|
|
33
|
-
##
|
|
33
|
+
## Tested and Compatible Radios
|
|
34
|
+
|
|
35
|
+
This plugin is designed for radios based on the **Frontier Silicon FSAPI (NetRemote API)**.
|
|
36
|
+
It has been tested or is known to work on a wide range of internet and DAB+ radios using this platform.
|
|
37
|
+
|
|
38
|
+
### Known compatible brands and models (non-exhaustive)
|
|
39
|
+
|
|
40
|
+
**Hama**
|
|
41
|
+
- IR100
|
|
42
|
+
- IR110
|
|
43
|
+
- DIR3100 / DIR3110 series
|
|
44
|
+
|
|
45
|
+
**Medion**
|
|
46
|
+
- MD87180
|
|
47
|
+
- MD86988
|
|
48
|
+
- MD86955
|
|
49
|
+
- MD87528
|
|
50
|
+
|
|
51
|
+
**Roberts**
|
|
52
|
+
- Stream 83i
|
|
53
|
+
- Stream 93i
|
|
54
|
+
|
|
55
|
+
**Ruark**
|
|
56
|
+
- R2
|
|
57
|
+
- R5
|
|
34
58
|
|
|
35
|
-
|
|
59
|
+
**Revo**
|
|
60
|
+
- SuperConnect
|
|
36
61
|
|
|
37
|
-
|
|
62
|
+
**Auna**
|
|
63
|
+
- Connect 150
|
|
64
|
+
- Connect CD
|
|
65
|
+
- KR200
|
|
38
66
|
|
|
39
|
-
|
|
67
|
+
**TechniSat**
|
|
68
|
+
- DIGITRADIO 350 IR
|
|
69
|
+
- DIGITRADIO 850
|
|
70
|
+
- VIOLA series
|
|
40
71
|
|
|
41
|
-
|
|
72
|
+
**Silvercrest (Lidl)**
|
|
73
|
+
- SMRS18A1
|
|
74
|
+
- SMRS30A1
|
|
75
|
+
- SMRS35A1
|
|
76
|
+
- SIRD series
|
|
42
77
|
|
|
43
|
-
|
|
78
|
+
**Dual / Teufel**
|
|
79
|
+
- Dual IR 3a
|
|
80
|
+
- Teufel 3sixty
|
|
44
81
|
|
|
45
|
-
###
|
|
82
|
+
### Important note
|
|
46
83
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"name": "Living Room Radio",
|
|
50
|
-
"ip": "192.168.1.50",
|
|
51
|
-
"pin": "1234",
|
|
52
|
-
"pollIntervalSeconds": 5,
|
|
53
|
-
"enableVolume": true
|
|
54
|
-
}
|
|
84
|
+
This list is **not complete**.
|
|
85
|
+
Many internet and DAB+ radios use the same Frontier Silicon platform under different brand names.
|
|
55
86
|
|
|
56
|
-
|
|
87
|
+
If your radio responds to the following URL:
|
|
88
|
+
|
|
89
|
+
http://<radio-ip>/fsapi/GET/netRemote.sys.info.friendlyName
|
|
90
|
+
|
|
91
|
+
then it is very likely compatible with this plugin.
|
|
92
|
+
|
|
93
|
+
If you successfully use this plugin with a radio that is not listed above, please consider opening an issue or pull request to help extend this list.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## Installation
|
|
57
97
|
|
|
58
|
-
|
|
59
|
-
Displayed name in HomeKit
|
|
98
|
+
### Recommended: Install via Homebridge UI
|
|
60
99
|
|
|
61
|
-
|
|
62
|
-
|
|
100
|
+
This plugin can be installed and configured entirely through the **Homebridge Config UI X**.
|
|
101
|
+
No manual JSON editing is required.
|
|
63
102
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
103
|
+
1. Open the Homebridge UI in your browser
|
|
104
|
+
2. Go to **Plugins**
|
|
105
|
+
3. Search for **homebridge-frontier-silicon-plugin**
|
|
106
|
+
4. Click **Install**
|
|
107
|
+
5. After installation, go to **Settings → Accessories**
|
|
108
|
+
6. Add a new accessory and select **Frontier Silicon Radio**
|
|
109
|
+
7. Fill in the required fields:
|
|
110
|
+
- Name
|
|
111
|
+
- IP address of the radio
|
|
112
|
+
- FSAPI PIN (default is usually `1234`)
|
|
113
|
+
8. (Optional) Configure volume options and station presets via the UI
|
|
114
|
+
9. Save the configuration and **restart Homebridge**
|
|
67
115
|
|
|
68
|
-
|
|
69
|
-
Polling interval in seconds
|
|
70
|
-
Minimum value is 2
|
|
71
|
-
Default value is 5
|
|
116
|
+
After the restart, the radio and its controls will appear in the Apple Home app.
|
|
72
117
|
|
|
73
|
-
|
|
74
|
-
Enable volume control
|
|
75
|
-
Default is true
|
|
118
|
+
---
|
|
76
119
|
|
|
77
120
|
## HomeKit behaviour
|
|
78
121
|
|
package/index.js
CHANGED
|
@@ -24,26 +24,20 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
24
24
|
|
|
25
25
|
this.enableVolume = config.enableVolume !== false;
|
|
26
26
|
|
|
27
|
-
// Keep both by default
|
|
28
27
|
this.exposeSpeakerService = config.exposeSpeakerService !== false;
|
|
29
28
|
this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
|
|
30
29
|
|
|
31
|
-
// When selecting a station, power on first
|
|
32
30
|
this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
|
|
33
31
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// If the radio says "empty preset", store that station to that preset slot on the radio first.
|
|
32
|
+
// stations: [{ name: "Radio 2", preset: 2 }, ...]
|
|
33
|
+
// preset is 1 based like the radio UI, internally we convert to 0 based presetKey for FSAPI
|
|
37
34
|
this.stations = Array.isArray(config.stations) ? config.stations : [];
|
|
38
35
|
|
|
39
36
|
this.lastKnownPower = null;
|
|
40
|
-
|
|
41
|
-
// Store last known RADIO volume on device scale 0..100
|
|
42
37
|
this.lastKnownRadioVolume = null;
|
|
43
38
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
this.lastKnownPresetIndex = null;
|
|
39
|
+
// 0 based FSAPI preset key that we last selected via HomeKit
|
|
40
|
+
this.lastKnownPresetKey = null;
|
|
47
41
|
|
|
48
42
|
this.isUpdatingStationSwitches = false;
|
|
49
43
|
|
|
@@ -62,14 +56,12 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
62
56
|
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
63
57
|
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
64
58
|
|
|
65
|
-
// Power switch
|
|
66
59
|
this.switchService = new Service.Switch(this.name);
|
|
67
60
|
this.switchService
|
|
68
61
|
.getCharacteristic(Characteristic.On)
|
|
69
62
|
.on("get", this.handleGetPower.bind(this))
|
|
70
63
|
.on("set", this.handleSetPower.bind(this));
|
|
71
64
|
|
|
72
|
-
// Volume services
|
|
73
65
|
if (this.enableVolume) {
|
|
74
66
|
if (this.exposeSpeakerService) {
|
|
75
67
|
this.speakerService = new Service.Speaker(this.name + " Speaker");
|
|
@@ -102,7 +94,6 @@ function FrontierSiliconAccessory(log, config) {
|
|
|
102
94
|
}
|
|
103
95
|
}
|
|
104
96
|
|
|
105
|
-
// Station switches
|
|
106
97
|
this.stationServices = [];
|
|
107
98
|
this.buildStationServices();
|
|
108
99
|
|
|
@@ -118,14 +109,16 @@ FrontierSiliconAccessory.prototype.buildStationServices = function () {
|
|
|
118
109
|
if (!s || typeof s !== "object") continue;
|
|
119
110
|
|
|
120
111
|
const stationName = String(s.name ?? "").trim();
|
|
121
|
-
const
|
|
112
|
+
const presetUi = Number(s.preset);
|
|
122
113
|
|
|
123
114
|
if (!stationName) continue;
|
|
124
|
-
if (!Number.isFinite(
|
|
115
|
+
if (!Number.isFinite(presetUi)) continue;
|
|
125
116
|
|
|
126
|
-
|
|
127
|
-
const
|
|
117
|
+
// Convert 1 based UI preset number to 0 based FSAPI key
|
|
118
|
+
const presetKey = Math.trunc(presetUi) - 1;
|
|
119
|
+
if (presetKey < 0) continue;
|
|
128
120
|
|
|
121
|
+
const subtype = "preset_" + String(presetKey);
|
|
129
122
|
if (seenSubtypes.has(subtype)) continue;
|
|
130
123
|
seenSubtypes.add(subtype);
|
|
131
124
|
|
|
@@ -133,14 +126,18 @@ FrontierSiliconAccessory.prototype.buildStationServices = function () {
|
|
|
133
126
|
|
|
134
127
|
sw.getCharacteristic(Characteristic.On)
|
|
135
128
|
.on("get", (cb) => {
|
|
136
|
-
|
|
137
|
-
cb(null, isOn);
|
|
129
|
+
cb(null, this.lastKnownPresetKey === presetKey);
|
|
138
130
|
})
|
|
139
131
|
.on("set", (value, cb) => {
|
|
140
|
-
this.handleSetStationPreset(
|
|
132
|
+
this.handleSetStationPreset(presetKey, !!value, cb);
|
|
141
133
|
});
|
|
142
134
|
|
|
143
|
-
this.stationServices.push({
|
|
135
|
+
this.stationServices.push({
|
|
136
|
+
presetKey,
|
|
137
|
+
presetUi: Math.trunc(presetUi),
|
|
138
|
+
name: stationName,
|
|
139
|
+
service: sw
|
|
140
|
+
});
|
|
144
141
|
}
|
|
145
142
|
};
|
|
146
143
|
|
|
@@ -183,9 +180,7 @@ FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
|
|
|
183
180
|
try {
|
|
184
181
|
const radioVol = await this.client.getVolume();
|
|
185
182
|
this.lastKnownRadioVolume = radioVol;
|
|
186
|
-
|
|
187
|
-
const homekitVol = radioToHomekitVolume(radioVol);
|
|
188
|
-
callback(null, homekitVol);
|
|
183
|
+
callback(null, radioToHomekitVolume(radioVol));
|
|
189
184
|
} catch (err) {
|
|
190
185
|
this.log.warn("Volume get failed, returning last known level.", toMsg(err));
|
|
191
186
|
const fallbackRadio = this.lastKnownRadioVolume ?? 0;
|
|
@@ -206,18 +201,24 @@ FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, call
|
|
|
206
201
|
}
|
|
207
202
|
};
|
|
208
203
|
|
|
209
|
-
FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (
|
|
204
|
+
FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (presetKey, turnOn, callback) {
|
|
210
205
|
if (this.isUpdatingStationSwitches) {
|
|
211
206
|
callback(null);
|
|
212
207
|
return;
|
|
213
208
|
}
|
|
214
209
|
|
|
210
|
+
// Exclusive selector behaviour
|
|
211
|
+
// Turning OFF does not change the radio, we snap back to the current selection
|
|
215
212
|
if (!turnOn) {
|
|
216
213
|
callback(null);
|
|
217
|
-
this.
|
|
214
|
+
this.syncStationSwitchesFromPresetKey(this.lastKnownPresetKey);
|
|
218
215
|
return;
|
|
219
216
|
}
|
|
220
217
|
|
|
218
|
+
// Optimistic UI update so previous station immediately turns off
|
|
219
|
+
this.lastKnownPresetKey = presetKey;
|
|
220
|
+
this.syncStationSwitchesFromPresetKey(presetKey);
|
|
221
|
+
|
|
221
222
|
try {
|
|
222
223
|
if (this.autoPowerOnOnPreset) {
|
|
223
224
|
try {
|
|
@@ -229,27 +230,26 @@ FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (pres
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
await this.client.
|
|
233
|
-
|
|
234
|
-
this.lastKnownPresetIndex = preset;
|
|
235
|
-
this.syncStationSwitchesFromPreset(preset);
|
|
233
|
+
await this.client.setPresetKey(presetKey);
|
|
236
234
|
|
|
237
235
|
callback(null);
|
|
238
236
|
} catch (err) {
|
|
239
237
|
this.log.warn("Preset set failed.", toMsg(err));
|
|
240
238
|
callback(null);
|
|
241
|
-
|
|
239
|
+
|
|
240
|
+
// Keep UI consistent with the last selection we know about
|
|
241
|
+
this.syncStationSwitchesFromPresetKey(this.lastKnownPresetKey);
|
|
242
242
|
}
|
|
243
243
|
};
|
|
244
244
|
|
|
245
|
-
FrontierSiliconAccessory.prototype.
|
|
245
|
+
FrontierSiliconAccessory.prototype.syncStationSwitchesFromPresetKey = function (presetKey) {
|
|
246
246
|
if (!this.stationServices || this.stationServices.length === 0) return;
|
|
247
247
|
|
|
248
248
|
this.isUpdatingStationSwitches = true;
|
|
249
249
|
|
|
250
250
|
try {
|
|
251
251
|
for (const s of this.stationServices) {
|
|
252
|
-
const shouldBeOn =
|
|
252
|
+
const shouldBeOn = presetKey === s.presetKey;
|
|
253
253
|
s.service.getCharacteristic(Characteristic.On).updateValue(shouldBeOn);
|
|
254
254
|
}
|
|
255
255
|
} finally {
|
|
@@ -278,7 +278,6 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
|
278
278
|
const radioVol = await this.client.getVolume();
|
|
279
279
|
if (this.lastKnownRadioVolume !== radioVol) {
|
|
280
280
|
this.lastKnownRadioVolume = radioVol;
|
|
281
|
-
|
|
282
281
|
const homekitVol = radioToHomekitVolume(radioVol);
|
|
283
282
|
|
|
284
283
|
if (this.speakerService) {
|
|
@@ -294,10 +293,8 @@ FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
|
294
293
|
}
|
|
295
294
|
}
|
|
296
295
|
|
|
297
|
-
// Preset
|
|
298
|
-
//
|
|
299
|
-
// If you want full sync from the radio front panel later,
|
|
300
|
-
// we can add alternative detection based on play.info nodes.
|
|
296
|
+
// Preset readback is not implemented for this firmware
|
|
297
|
+
// We keep station switch states based on the last selected presetKey via HomeKit
|
|
301
298
|
};
|
|
302
299
|
|
|
303
300
|
tick();
|
|
@@ -335,10 +332,10 @@ FsApiClient.prototype.setVolume = async function (volume) {
|
|
|
335
332
|
await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
|
|
336
333
|
};
|
|
337
334
|
|
|
338
|
-
// DIR3010 style preset selection
|
|
339
|
-
FsApiClient.prototype.
|
|
340
|
-
const p = Math.trunc(Number(
|
|
341
|
-
if (!Number.isFinite(p)) throw new Error("Invalid preset");
|
|
335
|
+
// DIR3010 style preset selection using nav.state and selectPreset
|
|
336
|
+
FsApiClient.prototype.setPresetKey = async function (presetKey) {
|
|
337
|
+
const p = Math.trunc(Number(presetKey));
|
|
338
|
+
if (!Number.isFinite(p)) throw new Error("Invalid preset key");
|
|
342
339
|
|
|
343
340
|
await this.fetchText("/fsapi/SET/netRemote.nav.state?value=1");
|
|
344
341
|
await this.fetchText("/fsapi/SET/netRemote.nav.action.selectPreset?value=" + encodeURIComponent(String(p)));
|
|
@@ -405,7 +402,6 @@ function toMsg(err) {
|
|
|
405
402
|
return String(err);
|
|
406
403
|
}
|
|
407
404
|
|
|
408
|
-
// Non linear volume mapping
|
|
409
405
|
function homekitToRadioVolume(homekitValue) {
|
|
410
406
|
const x = clampInt(Number(homekitValue), 0, 100) / 100;
|
|
411
407
|
return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
|
package/package.json
CHANGED