homebridge-frontier-silicon-plugin 1.2.9 → 2.0.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/config.schema.json +77 -100
- package/index.js +300 -345
- package/package.json +11 -35
package/config.schema.json
CHANGED
|
@@ -1,134 +1,111 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginAlias": "frontier-silicon",
|
|
3
|
-
"pluginType": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"pluginType": "accessory",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Frontier Silicon FSAPI radios",
|
|
6
|
+
"footerDisplay": "Presets must be saved on the radio first. The plugin switches between preset numbers.",
|
|
6
7
|
"schema": {
|
|
7
8
|
"type": "object",
|
|
8
|
-
"required": ["
|
|
9
|
+
"required": ["name", "ip"],
|
|
9
10
|
"properties": {
|
|
10
|
-
"
|
|
11
|
+
"name": {
|
|
12
|
+
"title": "Name",
|
|
11
13
|
"type": "string",
|
|
12
|
-
"
|
|
14
|
+
"default": "Living Room Radio",
|
|
15
|
+
"description": "The name shown in HomeKit."
|
|
13
16
|
},
|
|
14
|
-
"
|
|
15
|
-
"title": "
|
|
17
|
+
"ip": {
|
|
18
|
+
"title": "IP Address",
|
|
16
19
|
"type": "string",
|
|
17
|
-
"default": "
|
|
18
|
-
"description": "
|
|
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."
|
|
19
61
|
},
|
|
20
|
-
"
|
|
21
|
-
"title": "
|
|
62
|
+
"stations": {
|
|
63
|
+
"title": "Stations",
|
|
22
64
|
"type": "array",
|
|
65
|
+
"description": "List of station switches mapped to preset numbers. Save the stations to presets on the radio first.",
|
|
23
66
|
"items": {
|
|
24
67
|
"type": "object",
|
|
25
|
-
"required": ["name", "
|
|
68
|
+
"required": ["name", "preset"],
|
|
26
69
|
"properties": {
|
|
27
70
|
"name": {
|
|
28
|
-
"title": "
|
|
71
|
+
"title": "Station name",
|
|
29
72
|
"type": "string",
|
|
30
|
-
"default": "
|
|
31
|
-
"description": "The name shown in HomeKit."
|
|
73
|
+
"default": "Radio 2"
|
|
32
74
|
},
|
|
33
|
-
"
|
|
34
|
-
"title": "
|
|
35
|
-
"type": "string",
|
|
36
|
-
"description": "IP address of the radio on your network.",
|
|
37
|
-
"pattern": "^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$"
|
|
38
|
-
},
|
|
39
|
-
"pin": {
|
|
40
|
-
"title": "FSAPI PIN",
|
|
41
|
-
"type": "string",
|
|
42
|
-
"default": "1234",
|
|
43
|
-
"description": "FSAPI PIN of the radio (default is usually 1234)."
|
|
44
|
-
},
|
|
45
|
-
"pollIntervalSeconds": {
|
|
46
|
-
"title": "Poll interval (seconds)",
|
|
75
|
+
"preset": {
|
|
76
|
+
"title": "Preset number",
|
|
47
77
|
"type": "integer",
|
|
48
|
-
"default":
|
|
49
|
-
"minimum":
|
|
50
|
-
"maximum":
|
|
51
|
-
"description": "How often the radio is polled for state updates."
|
|
52
|
-
},
|
|
53
|
-
"enableVolume": {
|
|
54
|
-
"title": "Enable volume control",
|
|
55
|
-
"type": "boolean",
|
|
56
|
-
"default": true
|
|
57
|
-
},
|
|
58
|
-
"exposeSpeakerService": {
|
|
59
|
-
"title": "Expose Speaker service",
|
|
60
|
-
"type": "boolean",
|
|
61
|
-
"default": true,
|
|
62
|
-
"description": "Expose a native HomeKit Speaker service (may not show volume slider in Apple Home)."
|
|
63
|
-
},
|
|
64
|
-
"exposeVolumeSlider": {
|
|
65
|
-
"title": "Expose Apple Home volume slider",
|
|
66
|
-
"type": "boolean",
|
|
67
|
-
"default": true,
|
|
68
|
-
"description": "Expose a Lightbulb Brightness slider for volume control in Apple Home."
|
|
69
|
-
},
|
|
70
|
-
"autoPowerOnOnPreset": {
|
|
71
|
-
"title": "Power on when selecting a station",
|
|
72
|
-
"type": "boolean",
|
|
73
|
-
"default": true
|
|
74
|
-
},
|
|
75
|
-
"stations": {
|
|
76
|
-
"title": "Stations (presets)",
|
|
77
|
-
"type": "array",
|
|
78
|
-
"description": "Map HomeKit switches to radio preset numbers. Presets must be saved on the radio first.",
|
|
79
|
-
"items": {
|
|
80
|
-
"type": "object",
|
|
81
|
-
"required": ["name", "preset"],
|
|
82
|
-
"properties": {
|
|
83
|
-
"name": {
|
|
84
|
-
"title": "Station name",
|
|
85
|
-
"type": "string",
|
|
86
|
-
"default": "Radio 2"
|
|
87
|
-
},
|
|
88
|
-
"preset": {
|
|
89
|
-
"title": "Preset number",
|
|
90
|
-
"type": "integer",
|
|
91
|
-
"minimum": 1,
|
|
92
|
-
"description": "Preset number as shown on the radio (starting at 1)."
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
"default": []
|
|
78
|
+
"default": 1,
|
|
79
|
+
"minimum": 0,
|
|
80
|
+
"maximum": 99
|
|
97
81
|
}
|
|
98
82
|
}
|
|
99
|
-
}
|
|
83
|
+
},
|
|
84
|
+
"default": []
|
|
100
85
|
}
|
|
101
86
|
}
|
|
102
87
|
},
|
|
103
88
|
"form": [
|
|
104
89
|
"name",
|
|
90
|
+
"ip",
|
|
91
|
+
"pin",
|
|
92
|
+
"pollIntervalSeconds",
|
|
93
|
+
"enableVolume",
|
|
94
|
+
"exposeSpeakerService",
|
|
95
|
+
"exposeVolumeSlider",
|
|
96
|
+
"autoPowerOnOnPreset",
|
|
105
97
|
{
|
|
106
|
-
"key": "
|
|
98
|
+
"key": "stations",
|
|
107
99
|
"type": "array",
|
|
108
|
-
"title": "
|
|
100
|
+
"title": "Stations",
|
|
109
101
|
"items": [
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"accessories[].pin",
|
|
113
|
-
"accessories[].pollIntervalSeconds",
|
|
114
|
-
"accessories[].enableVolume",
|
|
115
|
-
"accessories[].exposeSpeakerService",
|
|
116
|
-
"accessories[].exposeVolumeSlider",
|
|
117
|
-
"accessories[].autoPowerOnOnPreset",
|
|
118
|
-
{
|
|
119
|
-
"key": "accessories[].stations",
|
|
120
|
-
"type": "array",
|
|
121
|
-
"title": "Stations",
|
|
122
|
-
"items": [
|
|
123
|
-
"accessories[].stations[].name",
|
|
124
|
-
"accessories[].stations[].preset"
|
|
125
|
-
]
|
|
126
|
-
}
|
|
102
|
+
"stations[].name",
|
|
103
|
+
"stations[].preset"
|
|
127
104
|
]
|
|
128
105
|
}
|
|
129
106
|
],
|
|
130
107
|
"display": {
|
|
131
108
|
"name": "Frontier Silicon Radio",
|
|
132
|
-
"description": "Control
|
|
109
|
+
"description": "Control power, volume, and station presets via FSAPI."
|
|
133
110
|
}
|
|
134
111
|
}
|
package/index.js
CHANGED
|
@@ -1,452 +1,407 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const http = require("http");
|
|
4
|
-
|
|
5
3
|
let Service;
|
|
6
4
|
let Characteristic;
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
const PLATFORM_NAME = "frontier-silicon"; // must match config.schema.json platform const and Homebridge config
|
|
10
|
-
|
|
11
|
-
module.exports = (homebridge) => {
|
|
6
|
+
module.exports = function (homebridge) {
|
|
12
7
|
Service = homebridge.hap.Service;
|
|
13
8
|
Characteristic = homebridge.hap.Characteristic;
|
|
14
9
|
|
|
15
|
-
homebridge.
|
|
10
|
+
homebridge.registerAccessory(
|
|
11
|
+
"homebridge-frontier-silicone",
|
|
12
|
+
"frontier-silicon",
|
|
13
|
+
FrontierSiliconAccessory
|
|
14
|
+
);
|
|
16
15
|
};
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.log = log;
|
|
21
|
-
this.api = api;
|
|
17
|
+
function FrontierSiliconAccessory(log, config) {
|
|
18
|
+
this.log = log;
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
this.name = config.name || "Frontier Silicon Radio";
|
|
21
|
+
this.ip = config.ip;
|
|
22
|
+
this.pin = String(config.pin ?? "1234");
|
|
23
|
+
this.pollIntervalSeconds = Number(config.pollIntervalSeconds ?? 5);
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
this.log.warn("Homebridge API not available, platform will not start.");
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
25
|
+
this.enableVolume = config.enableVolume !== false;
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
this.discoverAndSync();
|
|
34
|
-
} catch (e) {
|
|
35
|
-
this.log.error("Failed to start platform:", e?.message || e);
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
}
|
|
27
|
+
this.exposeSpeakerService = config.exposeSpeakerService !== false;
|
|
28
|
+
this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
|
|
39
29
|
|
|
40
|
-
|
|
41
|
-
this.accessoriesByUUID.set(accessory.UUID, accessory);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
discoverAndSync() {
|
|
45
|
-
const radios = Array.isArray(this.config.accessories) ? this.config.accessories : [];
|
|
46
|
-
|
|
47
|
-
const desiredUUIDs = new Set();
|
|
30
|
+
this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
|
|
48
31
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
34
|
+
this.stations = Array.isArray(config.stations) ? config.stations : [];
|
|
51
35
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!ip) continue;
|
|
36
|
+
this.lastKnownPower = null;
|
|
37
|
+
this.lastKnownRadioVolume = null;
|
|
55
38
|
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
// 0 based FSAPI preset key that we last selected via HomeKit
|
|
40
|
+
this.lastKnownPresetKey = null;
|
|
58
41
|
|
|
59
|
-
|
|
42
|
+
this.isUpdatingStationSwitches = false;
|
|
60
43
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.log.info(`Updating radio accessory: ${name} (${ip})`);
|
|
66
|
-
new FrontierSiliconRadioAccessory(this.log, existing, radioCfg);
|
|
67
|
-
} else {
|
|
68
|
-
this.log.info(`Adding radio accessory: ${name} (${ip})`);
|
|
69
|
-
const acc = new this.api.platformAccessory(name, uuid);
|
|
70
|
-
acc.context.config = radioCfg;
|
|
71
|
-
|
|
72
|
-
new FrontierSiliconRadioAccessory(this.log, acc, radioCfg);
|
|
44
|
+
if (!this.ip) {
|
|
45
|
+
this.log.warn("No ip configured, accessory will not work.");
|
|
46
|
+
}
|
|
73
47
|
|
|
74
|
-
|
|
75
|
-
|
|
48
|
+
this.client = new FsApiClient({
|
|
49
|
+
ip: this.ip,
|
|
50
|
+
pin: this.pin,
|
|
51
|
+
log: this.log
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.informationService = new Service.AccessoryInformation()
|
|
55
|
+
.setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
|
|
56
|
+
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
57
|
+
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
58
|
+
|
|
59
|
+
this.switchService = new Service.Switch(this.name);
|
|
60
|
+
this.switchService
|
|
61
|
+
.getCharacteristic(Characteristic.On)
|
|
62
|
+
.on("get", this.handleGetPower.bind(this))
|
|
63
|
+
.on("set", this.handleSetPower.bind(this));
|
|
64
|
+
|
|
65
|
+
if (this.enableVolume) {
|
|
66
|
+
if (this.exposeSpeakerService) {
|
|
67
|
+
this.speakerService = new Service.Speaker(this.name + " Speaker");
|
|
68
|
+
|
|
69
|
+
this.speakerService
|
|
70
|
+
.getCharacteristic(Characteristic.Volume)
|
|
71
|
+
.on("get", this.handleGetVolume.bind(this))
|
|
72
|
+
.on("set", this.handleSetVolume.bind(this));
|
|
73
|
+
|
|
74
|
+
if (Characteristic.Mute) {
|
|
75
|
+
this.speakerService
|
|
76
|
+
.getCharacteristic(Characteristic.Mute)
|
|
77
|
+
.on("get", (cb) => cb(null, false))
|
|
78
|
+
.on("set", (_val, cb) => cb(null));
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
if (this.exposeVolumeSlider) {
|
|
83
|
+
this.volumeSliderService = new Service.Lightbulb(this.name + " Volume");
|
|
84
|
+
|
|
85
|
+
this.volumeSliderService
|
|
86
|
+
.getCharacteristic(Characteristic.On)
|
|
87
|
+
.on("get", (cb) => cb(null, true))
|
|
88
|
+
.on("set", (_val, cb) => cb(null));
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
this.volumeSliderService
|
|
91
|
+
.getCharacteristic(Characteristic.Brightness)
|
|
92
|
+
.on("get", this.handleGetVolume.bind(this))
|
|
93
|
+
.on("set", this.handleSetVolume.bind(this));
|
|
87
94
|
}
|
|
88
95
|
}
|
|
89
|
-
}
|
|
90
96
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
this.log = log;
|
|
94
|
-
this.accessory = accessory;
|
|
97
|
+
this.stationServices = [];
|
|
98
|
+
this.buildStationServices();
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.pin = String(config.pin ?? "1234");
|
|
99
|
-
|
|
100
|
-
this.pollIntervalSeconds = Number(config.pollIntervalSeconds ?? 5);
|
|
101
|
-
this.enableVolume = config.enableVolume !== false;
|
|
102
|
-
|
|
103
|
-
this.exposeSpeakerService = config.exposeSpeakerService !== false;
|
|
104
|
-
this.exposeVolumeSlider = config.exposeVolumeSlider !== false;
|
|
100
|
+
this.startPolling();
|
|
101
|
+
}
|
|
105
102
|
|
|
106
|
-
|
|
103
|
+
FrontierSiliconAccessory.prototype.buildStationServices = function () {
|
|
104
|
+
if (!Array.isArray(this.stations) || this.stations.length === 0) return;
|
|
107
105
|
|
|
108
|
-
|
|
109
|
-
this.stations = Array.isArray(config.stations) ? config.stations : [];
|
|
106
|
+
const seenSubtypes = new Set();
|
|
110
107
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.lastKnownPresetKey = null;
|
|
108
|
+
for (const s of this.stations) {
|
|
109
|
+
if (!s || typeof s !== "object") continue;
|
|
114
110
|
|
|
115
|
-
|
|
111
|
+
const stationName = String(s.name ?? "").trim();
|
|
112
|
+
const presetUi = Number(s.preset);
|
|
116
113
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
pin: this.pin,
|
|
120
|
-
timeoutMs: 2500
|
|
121
|
-
});
|
|
114
|
+
if (!stationName) continue;
|
|
115
|
+
if (!Number.isFinite(presetUi)) continue;
|
|
122
116
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
// Convert 1 based UI preset number to 0 based FSAPI key
|
|
118
|
+
const presetKey = Math.trunc(presetUi) - 1;
|
|
119
|
+
if (presetKey < 0) continue;
|
|
126
120
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
info
|
|
132
|
-
.setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
|
|
133
|
-
.setCharacteristic(Characteristic.Model, "FSAPI Radio")
|
|
134
|
-
.setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
|
|
135
|
-
|
|
136
|
-
// Power
|
|
137
|
-
this.powerService = this.accessory.getServiceById(Service.Switch, "power") ||
|
|
138
|
-
this.accessory.addService(Service.Switch, this.name, "power");
|
|
139
|
-
|
|
140
|
-
this.powerService.setCharacteristic(Characteristic.Name, this.name);
|
|
141
|
-
|
|
142
|
-
const powerChar = this.powerService.getCharacteristic(Characteristic.On);
|
|
143
|
-
powerChar.removeAllListeners("get");
|
|
144
|
-
powerChar.removeAllListeners("set");
|
|
145
|
-
powerChar.on("get", this.handleGetPower.bind(this));
|
|
146
|
-
powerChar.on("set", this.handleSetPower.bind(this));
|
|
147
|
-
|
|
148
|
-
// Volume services
|
|
149
|
-
if (this.enableVolume && this.exposeSpeakerService) {
|
|
150
|
-
this.speakerService = this.accessory.getServiceById(Service.Speaker, "speaker") ||
|
|
151
|
-
this.accessory.addService(Service.Speaker, `${this.name} Speaker`, "speaker");
|
|
152
|
-
|
|
153
|
-
const volChar = this.speakerService.getCharacteristic(Characteristic.Volume);
|
|
154
|
-
volChar.removeAllListeners("get");
|
|
155
|
-
volChar.removeAllListeners("set");
|
|
156
|
-
volChar.on("get", this.handleGetVolume.bind(this));
|
|
157
|
-
volChar.on("set", this.handleSetVolume.bind(this));
|
|
158
|
-
} else {
|
|
159
|
-
const sp = this.accessory.getServiceById(Service.Speaker, "speaker");
|
|
160
|
-
if (sp) this.accessory.removeService(sp);
|
|
161
|
-
this.speakerService = null;
|
|
162
|
-
}
|
|
121
|
+
const subtype = "preset_" + String(presetKey);
|
|
122
|
+
if (seenSubtypes.has(subtype)) continue;
|
|
123
|
+
seenSubtypes.add(subtype);
|
|
163
124
|
|
|
164
|
-
|
|
165
|
-
this.volumeSliderService = this.accessory.getServiceById(Service.Lightbulb, "volumeSlider") ||
|
|
166
|
-
this.accessory.addService(Service.Lightbulb, `${this.name} Volume`, "volumeSlider");
|
|
167
|
-
|
|
168
|
-
const onChar = this.volumeSliderService.getCharacteristic(Characteristic.On);
|
|
169
|
-
onChar.removeAllListeners("get");
|
|
170
|
-
onChar.removeAllListeners("set");
|
|
171
|
-
onChar.on("get", (cb) => cb(null, true));
|
|
172
|
-
onChar.on("set", (_v, cb) => cb(null));
|
|
173
|
-
|
|
174
|
-
const brChar = this.volumeSliderService.getCharacteristic(Characteristic.Brightness);
|
|
175
|
-
brChar.removeAllListeners("get");
|
|
176
|
-
brChar.removeAllListeners("set");
|
|
177
|
-
brChar.on("get", this.handleGetVolume.bind(this));
|
|
178
|
-
brChar.on("set", this.handleSetVolume.bind(this));
|
|
179
|
-
} else {
|
|
180
|
-
const vs = this.accessory.getServiceById(Service.Lightbulb, "volumeSlider");
|
|
181
|
-
if (vs) this.accessory.removeService(vs);
|
|
182
|
-
this.volumeSliderService = null;
|
|
183
|
-
}
|
|
125
|
+
const sw = new Service.Switch(stationName, subtype);
|
|
184
126
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
127
|
+
sw.getCharacteristic(Characteristic.On)
|
|
128
|
+
.on("get", (cb) => {
|
|
129
|
+
cb(null, this.lastKnownPresetKey === presetKey);
|
|
130
|
+
})
|
|
131
|
+
.on("set", (value, cb) => {
|
|
132
|
+
this.handleSetStationPreset(presetKey, !!value, cb);
|
|
133
|
+
});
|
|
190
134
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
135
|
+
this.stationServices.push({
|
|
136
|
+
presetKey,
|
|
137
|
+
presetUi: Math.trunc(presetUi),
|
|
138
|
+
name: stationName,
|
|
139
|
+
service: sw
|
|
140
|
+
});
|
|
197
141
|
}
|
|
142
|
+
};
|
|
198
143
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const seen = new Set();
|
|
203
|
-
|
|
204
|
-
for (const st of this.stations) {
|
|
205
|
-
if (!st || typeof st !== "object") continue;
|
|
206
|
-
|
|
207
|
-
const stationName = String(st.name ?? "").trim();
|
|
208
|
-
const presetUi = Number(st.preset);
|
|
144
|
+
FrontierSiliconAccessory.prototype.getServices = function () {
|
|
145
|
+
const services = [this.informationService, this.switchService];
|
|
209
146
|
|
|
210
|
-
|
|
211
|
-
|
|
147
|
+
if (this.enableVolume && this.speakerService) services.push(this.speakerService);
|
|
148
|
+
if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
|
|
212
149
|
|
|
213
|
-
|
|
214
|
-
if (presetKey < 0) continue;
|
|
150
|
+
for (const s of this.stationServices) services.push(s.service);
|
|
215
151
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
seen.add(subtype);
|
|
152
|
+
return services;
|
|
153
|
+
};
|
|
219
154
|
|
|
220
|
-
|
|
155
|
+
FrontierSiliconAccessory.prototype.handleGetPower = async function (callback) {
|
|
156
|
+
try {
|
|
157
|
+
const power = await this.client.getPower();
|
|
158
|
+
this.lastKnownPower = power;
|
|
159
|
+
callback(null, power);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
this.log.warn("Power get failed, returning last known state.", toMsg(err));
|
|
162
|
+
callback(null, this.lastKnownPower ?? false);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
221
165
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
ch.removeAllListeners("set");
|
|
225
|
-
ch.on("get", (cb) => cb(null, this.lastKnownPresetKey === presetKey));
|
|
226
|
-
ch.on("set", (value, cb) => this.handleSetStationPreset(presetKey, !!value, cb));
|
|
166
|
+
FrontierSiliconAccessory.prototype.handleSetPower = async function (value, callback) {
|
|
167
|
+
const target = !!value;
|
|
227
168
|
|
|
228
|
-
|
|
229
|
-
|
|
169
|
+
try {
|
|
170
|
+
await this.client.setPower(target);
|
|
171
|
+
this.lastKnownPower = target;
|
|
172
|
+
callback(null);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
this.log.warn("Power set failed, keeping last known state.", toMsg(err));
|
|
175
|
+
callback(null);
|
|
230
176
|
}
|
|
177
|
+
};
|
|
231
178
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
179
|
+
FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
|
|
180
|
+
try {
|
|
181
|
+
const radioVol = await this.client.getVolume();
|
|
182
|
+
this.lastKnownRadioVolume = radioVol;
|
|
183
|
+
callback(null, radioToHomekitVolume(radioVol));
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.log.warn("Volume get failed, returning last known level.", toMsg(err));
|
|
186
|
+
const fallbackRadio = this.lastKnownRadioVolume ?? 0;
|
|
187
|
+
callback(null, radioToHomekitVolume(fallbackRadio));
|
|
240
188
|
}
|
|
189
|
+
};
|
|
241
190
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
191
|
+
FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, callback) {
|
|
192
|
+
const radioVol = homekitToRadioVolume(value);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
await this.client.setVolume(radioVol);
|
|
196
|
+
this.lastKnownRadioVolume = radioVol;
|
|
197
|
+
callback(null);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
this.log.warn("Volume set failed, keeping last known level.", toMsg(err));
|
|
200
|
+
callback(null);
|
|
251
201
|
}
|
|
202
|
+
};
|
|
252
203
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
callback(null, radioToHomekitVolume(radioVol));
|
|
258
|
-
} catch (e) {
|
|
259
|
-
const fallback = this.lastKnownRadioVolume ?? 0;
|
|
260
|
-
callback(null, radioToHomekitVolume(fallback));
|
|
261
|
-
}
|
|
204
|
+
FrontierSiliconAccessory.prototype.handleSetStationPreset = async function (presetKey, turnOn, callback) {
|
|
205
|
+
if (this.isUpdatingStationSwitches) {
|
|
206
|
+
callback(null);
|
|
207
|
+
return;
|
|
262
208
|
}
|
|
263
209
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
} catch (e) {
|
|
271
|
-
callback(null);
|
|
272
|
-
}
|
|
210
|
+
// Exclusive selector behaviour
|
|
211
|
+
// Turning OFF does not change the radio, we snap back to the current selection
|
|
212
|
+
if (!turnOn) {
|
|
213
|
+
callback(null);
|
|
214
|
+
this.syncStationSwitchesFromPresetKey(this.lastKnownPresetKey);
|
|
215
|
+
return;
|
|
273
216
|
}
|
|
274
217
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
218
|
+
// Optimistic UI update so previous station immediately turns off
|
|
219
|
+
this.lastKnownPresetKey = presetKey;
|
|
220
|
+
this.syncStationSwitchesFromPresetKey(presetKey);
|
|
280
221
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
222
|
+
try {
|
|
223
|
+
if (this.autoPowerOnOnPreset) {
|
|
224
|
+
try {
|
|
225
|
+
await this.client.setPower(true);
|
|
226
|
+
this.lastKnownPower = true;
|
|
227
|
+
this.switchService.getCharacteristic(Characteristic.On).updateValue(true);
|
|
228
|
+
} catch (_e) {
|
|
229
|
+
// ignore
|
|
230
|
+
}
|
|
285
231
|
}
|
|
286
232
|
|
|
287
|
-
this.
|
|
288
|
-
this.syncStationSwitches(presetKey);
|
|
233
|
+
await this.client.setPresetKey(presetKey);
|
|
289
234
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
this.lastKnownPower = true;
|
|
295
|
-
this.powerService.getCharacteristic(Characteristic.On).updateValue(true);
|
|
296
|
-
} catch (e) {
|
|
297
|
-
}
|
|
298
|
-
}
|
|
235
|
+
callback(null);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
this.log.warn("Preset set failed.", toMsg(err));
|
|
238
|
+
callback(null);
|
|
299
239
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
} catch (e) {
|
|
303
|
-
callback(null);
|
|
304
|
-
this.syncStationSwitches(this.lastKnownPresetKey);
|
|
305
|
-
}
|
|
240
|
+
// Keep UI consistent with the last selection we know about
|
|
241
|
+
this.syncStationSwitchesFromPresetKey(this.lastKnownPresetKey);
|
|
306
242
|
}
|
|
243
|
+
};
|
|
307
244
|
|
|
308
|
-
|
|
309
|
-
|
|
245
|
+
FrontierSiliconAccessory.prototype.syncStationSwitchesFromPresetKey = function (presetKey) {
|
|
246
|
+
if (!this.stationServices || this.stationServices.length === 0) return;
|
|
310
247
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
this.isUpdatingStationSwitches = false;
|
|
248
|
+
this.isUpdatingStationSwitches = true;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
for (const s of this.stationServices) {
|
|
252
|
+
const shouldBeOn = presetKey === s.presetKey;
|
|
253
|
+
s.service.getCharacteristic(Characteristic.On).updateValue(shouldBeOn);
|
|
318
254
|
}
|
|
255
|
+
} finally {
|
|
256
|
+
this.isUpdatingStationSwitches = false;
|
|
319
257
|
}
|
|
258
|
+
};
|
|
320
259
|
|
|
321
|
-
|
|
322
|
-
|
|
260
|
+
FrontierSiliconAccessory.prototype.startPolling = function () {
|
|
261
|
+
if (!this.ip) return;
|
|
323
262
|
|
|
324
|
-
|
|
263
|
+
const intervalMs = Math.max(2, this.pollIntervalSeconds) * 1000;
|
|
325
264
|
|
|
326
|
-
|
|
265
|
+
const tick = async () => {
|
|
266
|
+
try {
|
|
267
|
+
const power = await this.client.getPower();
|
|
268
|
+
if (this.lastKnownPower !== power) {
|
|
269
|
+
this.lastKnownPower = power;
|
|
270
|
+
this.switchService.getCharacteristic(Characteristic.On).updateValue(power);
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (this.log.debug) this.log.debug("Polling power failed.", toMsg(err));
|
|
274
|
+
}
|
|
327
275
|
|
|
328
|
-
|
|
276
|
+
if (this.enableVolume) {
|
|
329
277
|
try {
|
|
330
|
-
const
|
|
331
|
-
if (this.
|
|
332
|
-
this.
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
} catch (e) {
|
|
336
|
-
}
|
|
278
|
+
const radioVol = await this.client.getVolume();
|
|
279
|
+
if (this.lastKnownRadioVolume !== radioVol) {
|
|
280
|
+
this.lastKnownRadioVolume = radioVol;
|
|
281
|
+
const homekitVol = radioToHomekitVolume(radioVol);
|
|
337
282
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (this.lastKnownRadioVolume !== radioVol) {
|
|
342
|
-
this.lastKnownRadioVolume = radioVol;
|
|
343
|
-
const hk = radioToHomekitVolume(radioVol);
|
|
283
|
+
if (this.speakerService) {
|
|
284
|
+
this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(homekitVol);
|
|
285
|
+
}
|
|
344
286
|
|
|
345
|
-
|
|
346
|
-
|
|
287
|
+
if (this.volumeSliderService) {
|
|
288
|
+
this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(homekitVol);
|
|
347
289
|
}
|
|
348
|
-
} catch (e) {
|
|
349
290
|
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (this.log.debug) this.log.debug("Polling volume failed.", toMsg(err));
|
|
350
293
|
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
tick();
|
|
354
|
-
this.pollTimer = setInterval(tick, intervalMs);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
294
|
+
}
|
|
357
295
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.pin = pin;
|
|
362
|
-
this.timeoutMs = timeoutMs || 2500;
|
|
363
|
-
}
|
|
296
|
+
// Preset readback is not implemented for this firmware
|
|
297
|
+
// We keep station switch states based on the last selected presetKey via HomeKit
|
|
298
|
+
};
|
|
364
299
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
300
|
+
tick();
|
|
301
|
+
this.pollTimer = setInterval(tick, intervalMs);
|
|
302
|
+
};
|
|
368
303
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
304
|
+
function FsApiClient({ ip, pin, log }) {
|
|
305
|
+
this.ip = ip;
|
|
306
|
+
this.pin = pin;
|
|
307
|
+
this.log = log;
|
|
373
308
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
309
|
+
this.baseUrl = "http://" + ip;
|
|
310
|
+
this.timeoutMs = 2500;
|
|
311
|
+
}
|
|
377
312
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
313
|
+
FsApiClient.prototype.getPower = async function () {
|
|
314
|
+
const text = await this.fetchText("/fsapi/GET/netRemote.sys.power");
|
|
315
|
+
const value = parseFsapiValue(text);
|
|
316
|
+
return value === 1;
|
|
317
|
+
};
|
|
381
318
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
319
|
+
FsApiClient.prototype.setPower = async function (on) {
|
|
320
|
+
const v = on ? 1 : 0;
|
|
321
|
+
await this.fetchText("/fsapi/SET/netRemote.sys.power?value=" + v);
|
|
322
|
+
};
|
|
385
323
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
324
|
+
FsApiClient.prototype.getVolume = async function () {
|
|
325
|
+
const text = await this.fetchText("/fsapi/GET/netRemote.sys.audio.volume");
|
|
326
|
+
const value = parseFsapiValue(text);
|
|
327
|
+
return clampInt(Number(value), 0, 100);
|
|
328
|
+
};
|
|
391
329
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
330
|
+
FsApiClient.prototype.setVolume = async function (volume) {
|
|
331
|
+
const v = clampInt(Number(volume), 0, 100);
|
|
332
|
+
await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
|
|
333
|
+
};
|
|
396
334
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
return true;
|
|
402
|
-
}
|
|
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");
|
|
403
339
|
|
|
404
|
-
|
|
405
|
-
|
|
340
|
+
await this.fetchText("/fsapi/SET/netRemote.nav.state?value=1");
|
|
341
|
+
await this.fetchText("/fsapi/SET/netRemote.nav.action.selectPreset?value=" + encodeURIComponent(String(p)));
|
|
342
|
+
await this.fetchText("/fsapi/SET/netRemote.nav.state?value=0");
|
|
343
|
+
};
|
|
406
344
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
res.setEncoding("utf8");
|
|
411
|
-
res.on("data", (c) => (data += c));
|
|
412
|
-
res.on("end", () => resolve(data));
|
|
413
|
-
});
|
|
345
|
+
FsApiClient.prototype.fetchText = async function (pathAndQuery) {
|
|
346
|
+
const joiner = pathAndQuery.includes("?") ? "&" : "?";
|
|
347
|
+
const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
|
|
414
348
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
});
|
|
349
|
+
const controller = new AbortController();
|
|
350
|
+
const t = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
418
351
|
|
|
419
|
-
|
|
420
|
-
});
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
354
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
355
|
+
return await res.text();
|
|
356
|
+
} finally {
|
|
357
|
+
clearTimeout(t);
|
|
421
358
|
}
|
|
422
|
-
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
function parseFsapiValue(body) {
|
|
362
|
+
if (!body) return null;
|
|
423
363
|
|
|
424
|
-
|
|
425
|
-
if (!xml) return null;
|
|
364
|
+
const trimmed = String(body).trim();
|
|
426
365
|
|
|
427
|
-
const
|
|
366
|
+
const xmlMatch =
|
|
367
|
+
trimmed.match(/<value>\s*<u8>(\d+)<\/u8>\s*<\/value>/i) ||
|
|
368
|
+
trimmed.match(/<value>\s*<u16>(\d+)<\/u16>\s*<\/value>/i) ||
|
|
369
|
+
trimmed.match(/<value>\s*<u32>(\d+)<\/u32>\s*<\/value>/i) ||
|
|
370
|
+
trimmed.match(/<value>\s*<s16>(-?\d+)<\/s16>\s*<\/value>/i) ||
|
|
371
|
+
trimmed.match(/<value>\s*<c8_array>(.*?)<\/c8_array>\s*<\/value>/i);
|
|
428
372
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
373
|
+
if (xmlMatch) {
|
|
374
|
+
const raw = xmlMatch[1];
|
|
375
|
+
const num = Number(raw);
|
|
376
|
+
return Number.isFinite(num) ? num : raw;
|
|
377
|
+
}
|
|
434
378
|
|
|
435
|
-
|
|
379
|
+
const okMatch = trimmed.match(/FS_OK\s+(.+)$/i);
|
|
380
|
+
if (okMatch) {
|
|
381
|
+
const raw = okMatch[1].trim();
|
|
382
|
+
const num = Number(raw);
|
|
383
|
+
return Number.isFinite(num) ? num : raw;
|
|
384
|
+
}
|
|
436
385
|
|
|
437
|
-
const
|
|
438
|
-
if (
|
|
386
|
+
const tailNum = trimmed.match(/(-?\d+)\s*$/);
|
|
387
|
+
if (tailNum) return Number(tailNum[1]);
|
|
439
388
|
|
|
440
|
-
return
|
|
389
|
+
return trimmed;
|
|
441
390
|
}
|
|
442
391
|
|
|
443
392
|
function clampInt(n, min, max) {
|
|
444
|
-
const v = Number.isFinite(
|
|
393
|
+
const v = Number.isFinite(n) ? Math.round(n) : min;
|
|
445
394
|
if (v < min) return min;
|
|
446
395
|
if (v > max) return max;
|
|
447
396
|
return v;
|
|
448
397
|
}
|
|
449
398
|
|
|
399
|
+
function toMsg(err) {
|
|
400
|
+
if (!err) return "";
|
|
401
|
+
if (err instanceof Error) return err.message;
|
|
402
|
+
return String(err);
|
|
403
|
+
}
|
|
404
|
+
|
|
450
405
|
function homekitToRadioVolume(homekitValue) {
|
|
451
406
|
const x = clampInt(Number(homekitValue), 0, 100) / 100;
|
|
452
407
|
return clampInt(Math.round(Math.pow(x, 2) * 100), 0, 100);
|
package/package.json
CHANGED
|
@@ -1,55 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-frontier-silicon-plugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Homebridge plugin for Frontier Silicon
|
|
5
|
-
"
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Homebridge plugin for Frontier Silicon FSAPI devices, power and volume with safe polling",
|
|
5
|
+
"icon": "icon.png",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"homepage": "https://github.com/boikedamhuis/homebridge-frontier-silicon#readme",
|
|
6
9
|
"keywords": [
|
|
7
10
|
"homebridge-plugin",
|
|
8
11
|
"homebridge",
|
|
9
|
-
"frontier
|
|
12
|
+
"frontier",
|
|
13
|
+
"silicon",
|
|
10
14
|
"fsapi",
|
|
11
|
-
"internet-radio"
|
|
12
|
-
"dab",
|
|
13
|
-
"dab+",
|
|
14
|
-
"undok",
|
|
15
|
-
"reciva"
|
|
15
|
+
"internet-radio"
|
|
16
16
|
],
|
|
17
|
-
"homepage": "https://github.com/boikedamhuis/homebridge-frontier-silicon#readme",
|
|
18
17
|
"repository": {
|
|
19
18
|
"type": "git",
|
|
20
|
-
"url": "https://github.com/boikedamhuis/homebridge-frontier-silicon
|
|
19
|
+
"url": "https://github.com/boikedamhuis/homebridge-frontier-silicon"
|
|
21
20
|
},
|
|
22
21
|
"bugs": {
|
|
23
22
|
"url": "https://github.com/boikedamhuis/homebridge-frontier-silicon/issues"
|
|
24
23
|
},
|
|
25
|
-
"license": "MIT",
|
|
26
24
|
"author": {
|
|
27
|
-
"name": "Boike Damhuis"
|
|
28
|
-
"url": "https://github.com/boikedamhuis"
|
|
25
|
+
"name": "Boike Damhuis"
|
|
29
26
|
},
|
|
30
|
-
"main": "index.js",
|
|
31
27
|
"engines": {
|
|
32
28
|
"node": ">=18.0.0",
|
|
33
29
|
"homebridge": ">=1.6.0"
|
|
34
|
-
},
|
|
35
|
-
"icon": "icon.png",
|
|
36
|
-
"files": [
|
|
37
|
-
"index.js",
|
|
38
|
-
"config.schema.json",
|
|
39
|
-
"README.md",
|
|
40
|
-
"CHANGELOG.md",
|
|
41
|
-
"icon.png"
|
|
42
|
-
],
|
|
43
|
-
"scripts": {
|
|
44
|
-
"lint": "echo \"No lint configured\"",
|
|
45
|
-
"test": "echo \"No tests configured\""
|
|
46
|
-
},
|
|
47
|
-
"homebridge": {
|
|
48
|
-
"platforms": [
|
|
49
|
-
{
|
|
50
|
-
"platform": "frontier-silicon",
|
|
51
|
-
"name": "Frontier Silicon Radios"
|
|
52
|
-
}
|
|
53
|
-
]
|
|
54
30
|
}
|
|
55
31
|
}
|