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.
Files changed (3) hide show
  1. package/config.schema.json +77 -100
  2. package/index.js +300 -345
  3. package/package.json +11 -35
@@ -1,134 +1,111 @@
1
1
  {
2
2
  "pluginAlias": "frontier-silicon",
3
- "pluginType": "platform",
4
- "headerDisplay": "Frontier Silicon FSAPI Radios",
5
- "footerDisplay": "Configure one or more Frontier Silicon based radios. Presets must be saved on the radio first.",
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": ["platform", "name", "accessories"],
9
+ "required": ["name", "ip"],
9
10
  "properties": {
10
- "platform": {
11
+ "name": {
12
+ "title": "Name",
11
13
  "type": "string",
12
- "const": "frontier-silicon"
14
+ "default": "Living Room Radio",
15
+ "description": "The name shown in HomeKit."
13
16
  },
14
- "name": {
15
- "title": "Platform Name",
17
+ "ip": {
18
+ "title": "IP Address",
16
19
  "type": "string",
17
- "default": "Frontier Silicon Radios",
18
- "description": "A label for this platform instance in Homebridge."
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
- "accessories": {
21
- "title": "Radios",
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", "ip"],
68
+ "required": ["name", "preset"],
26
69
  "properties": {
27
70
  "name": {
28
- "title": "Name",
71
+ "title": "Station name",
29
72
  "type": "string",
30
- "default": "Living Room Radio",
31
- "description": "The name shown in HomeKit."
73
+ "default": "Radio 2"
32
74
  },
33
- "ip": {
34
- "title": "IP Address",
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": 5,
49
- "minimum": 2,
50
- "maximum": 120,
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": "accessories",
98
+ "key": "stations",
107
99
  "type": "array",
108
- "title": "Radios",
100
+ "title": "Stations",
109
101
  "items": [
110
- "accessories[].name",
111
- "accessories[].ip",
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 Frontier Silicon / FSAPI based radios including power, volume and presets."
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
- const PLUGIN_NAME = "homebridge-frontier-silicon-plugin"; // must match package.json "name"
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.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, FrontierSiliconPlatform);
10
+ homebridge.registerAccessory(
11
+ "homebridge-frontier-silicone",
12
+ "frontier-silicon",
13
+ FrontierSiliconAccessory
14
+ );
16
15
  };
17
16
 
18
- class FrontierSiliconPlatform {
19
- constructor(log, config, api) {
20
- this.log = log;
21
- this.api = api;
17
+ function FrontierSiliconAccessory(log, config) {
18
+ this.log = log;
22
19
 
23
- this.config = config || {};
24
- this.accessoriesByUUID = new Map();
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
- if (!this.api || typeof this.api.on !== "function") {
27
- this.log.warn("Homebridge API not available, platform will not start.");
28
- return;
29
- }
25
+ this.enableVolume = config.enableVolume !== false;
30
26
 
31
- this.api.on("didFinishLaunching", () => {
32
- try {
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
- configureAccessory(accessory) {
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
- for (const radioCfg of radios) {
50
- if (!radioCfg || typeof radioCfg !== "object") continue;
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
- const name = String(radioCfg.name || "Frontier Silicon Radio");
53
- const ip = String(radioCfg.ip || "").trim();
54
- if (!ip) continue;
36
+ this.lastKnownPower = null;
37
+ this.lastKnownRadioVolume = null;
55
38
 
56
- const uuid = this.api.hap.uuid.generate(`frontier-silicon:${ip}`);
57
- desiredUUIDs.add(uuid);
39
+ // 0 based FSAPI preset key that we last selected via HomeKit
40
+ this.lastKnownPresetKey = null;
58
41
 
59
- const existing = this.accessoriesByUUID.get(uuid);
42
+ this.isUpdatingStationSwitches = false;
60
43
 
61
- if (existing) {
62
- existing.context.config = radioCfg;
63
- existing.displayName = name;
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
- this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
75
- this.accessoriesByUUID.set(uuid, acc);
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
- const toRemove = [];
80
- for (const [uuid, acc] of this.accessoriesByUUID.entries()) {
81
- if (!desiredUUIDs.has(uuid)) toRemove.push(acc);
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
- if (toRemove.length > 0) {
85
- for (const acc of toRemove) this.accessoriesByUUID.delete(acc.UUID);
86
- this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toRemove);
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
- class FrontierSiliconRadioAccessory {
92
- constructor(log, accessory, config) {
93
- this.log = log;
94
- this.accessory = accessory;
97
+ this.stationServices = [];
98
+ this.buildStationServices();
95
99
 
96
- this.name = String(config.name || accessory.displayName || "Frontier Silicon Radio");
97
- this.ip = String(config.ip || "").trim();
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
- this.autoPowerOnOnPreset = config.autoPowerOnOnPreset !== false;
103
+ FrontierSiliconAccessory.prototype.buildStationServices = function () {
104
+ if (!Array.isArray(this.stations) || this.stations.length === 0) return;
107
105
 
108
- // stations: [{ name: "Radio 2", preset: 2 }, ...] where preset is 1 based
109
- this.stations = Array.isArray(config.stations) ? config.stations : [];
106
+ const seenSubtypes = new Set();
110
107
 
111
- this.lastKnownPower = null;
112
- this.lastKnownRadioVolume = null;
113
- this.lastKnownPresetKey = null;
108
+ for (const s of this.stations) {
109
+ if (!s || typeof s !== "object") continue;
114
110
 
115
- this.isUpdatingStationSwitches = false;
111
+ const stationName = String(s.name ?? "").trim();
112
+ const presetUi = Number(s.preset);
116
113
 
117
- this.client = new FsApiClient({
118
- ip: this.ip,
119
- pin: this.pin,
120
- timeoutMs: 2500
121
- });
114
+ if (!stationName) continue;
115
+ if (!Number.isFinite(presetUi)) continue;
122
116
 
123
- this.setupServices();
124
- this.startPolling();
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
- setupServices() {
128
- const info = this.accessory.getService(Service.AccessoryInformation) ||
129
- this.accessory.addService(Service.AccessoryInformation);
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
- if (this.enableVolume && this.exposeVolumeSlider) {
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
- // Rebuild station services
186
- this.removeOldStationServices();
187
- this.stationServices = [];
188
- this.buildStationServices();
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
- removeOldStationServices() {
192
- for (const s of this.accessory.services || []) {
193
- if (s.UUID === Service.Switch.UUID && typeof s.subtype === "string" && s.subtype.startsWith("station_")) {
194
- this.accessory.removeService(s);
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
- buildStationServices() {
200
- if (!Array.isArray(this.stations) || this.stations.length === 0) return;
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
- if (!stationName) continue;
211
- if (!Number.isFinite(presetUi)) continue;
147
+ if (this.enableVolume && this.speakerService) services.push(this.speakerService);
148
+ if (this.enableVolume && this.volumeSliderService) services.push(this.volumeSliderService);
212
149
 
213
- const presetKey = Math.trunc(presetUi) - 1; // 1 based UI to 0 based FSAPI
214
- if (presetKey < 0) continue;
150
+ for (const s of this.stationServices) services.push(s.service);
215
151
 
216
- const subtype = `station_${presetKey}`;
217
- if (seen.has(subtype)) continue;
218
- seen.add(subtype);
152
+ return services;
153
+ };
219
154
 
220
- const sw = this.accessory.addService(Service.Switch, stationName, subtype);
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
- const ch = sw.getCharacteristic(Characteristic.On);
223
- ch.removeAllListeners("get");
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
- this.stationServices.push({ presetKey, name: stationName, service: sw });
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
- async handleGetPower(callback) {
233
- try {
234
- const power = await this.client.getPower();
235
- this.lastKnownPower = power;
236
- callback(null, power);
237
- } catch (e) {
238
- callback(null, this.lastKnownPower ?? false);
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
- async handleSetPower(value, callback) {
243
- try {
244
- const target = !!value;
245
- await this.client.setPower(target);
246
- this.lastKnownPower = target;
247
- callback(null);
248
- } catch (e) {
249
- callback(null);
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
- async handleGetVolume(callback) {
254
- try {
255
- const radioVol = await this.client.getVolume();
256
- this.lastKnownRadioVolume = radioVol;
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
- async handleSetVolume(value, callback) {
265
- try {
266
- const radioVol = homekitToRadioVolume(value);
267
- await this.client.setVolume(radioVol);
268
- this.lastKnownRadioVolume = radioVol;
269
- callback(null);
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
- async handleSetStationPreset(presetKey, turnOn, callback) {
276
- if (this.isUpdatingStationSwitches) {
277
- callback(null);
278
- return;
279
- }
218
+ // Optimistic UI update so previous station immediately turns off
219
+ this.lastKnownPresetKey = presetKey;
220
+ this.syncStationSwitchesFromPresetKey(presetKey);
280
221
 
281
- if (!turnOn) {
282
- callback(null);
283
- this.syncStationSwitches(this.lastKnownPresetKey);
284
- return;
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.lastKnownPresetKey = presetKey;
288
- this.syncStationSwitches(presetKey);
233
+ await this.client.setPresetKey(presetKey);
289
234
 
290
- try {
291
- if (this.autoPowerOnOnPreset) {
292
- try {
293
- await this.client.setPower(true);
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
- await this.client.selectPreset(presetKey);
301
- callback(null);
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
- syncStationSwitches(presetKey) {
309
- if (!this.stationServices || this.stationServices.length === 0) return;
245
+ FrontierSiliconAccessory.prototype.syncStationSwitchesFromPresetKey = function (presetKey) {
246
+ if (!this.stationServices || this.stationServices.length === 0) return;
310
247
 
311
- this.isUpdatingStationSwitches = true;
312
- try {
313
- for (const s of this.stationServices) {
314
- s.service.getCharacteristic(Characteristic.On).updateValue(presetKey === s.presetKey);
315
- }
316
- } finally {
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
- startPolling() {
322
- if (!this.ip) return;
260
+ FrontierSiliconAccessory.prototype.startPolling = function () {
261
+ if (!this.ip) return;
323
262
 
324
- if (this.pollTimer) clearInterval(this.pollTimer);
263
+ const intervalMs = Math.max(2, this.pollIntervalSeconds) * 1000;
325
264
 
326
- const intervalMs = Math.max(2, this.pollIntervalSeconds) * 1000;
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
- const tick = async () => {
276
+ if (this.enableVolume) {
329
277
  try {
330
- const power = await this.client.getPower();
331
- if (this.lastKnownPower !== power) {
332
- this.lastKnownPower = power;
333
- this.powerService.getCharacteristic(Characteristic.On).updateValue(power);
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
- if (this.enableVolume) {
339
- try {
340
- const radioVol = await this.client.getVolume();
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
- if (this.speakerService) this.speakerService.getCharacteristic(Characteristic.Volume).updateValue(hk);
346
- if (this.volumeSliderService) this.volumeSliderService.getCharacteristic(Characteristic.Brightness).updateValue(hk);
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
- class FsApiClient {
359
- constructor({ ip, pin, timeoutMs }) {
360
- this.ip = ip;
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
- getPower() {
366
- return this.getNodeNumber("netRemote.sys.power").then((v) => v === 1);
367
- }
300
+ tick();
301
+ this.pollTimer = setInterval(tick, intervalMs);
302
+ };
368
303
 
369
- setPower(on) {
370
- const v = on ? 1 : 0;
371
- return this.setNodeNumber("netRemote.sys.power", v);
372
- }
304
+ function FsApiClient({ ip, pin, log }) {
305
+ this.ip = ip;
306
+ this.pin = pin;
307
+ this.log = log;
373
308
 
374
- getVolume() {
375
- return this.getNodeNumber("netRemote.sys.audio.volume").then((v) => clampInt(v, 0, 100));
376
- }
309
+ this.baseUrl = "http://" + ip;
310
+ this.timeoutMs = 2500;
311
+ }
377
312
 
378
- setVolume(v) {
379
- return this.setNodeNumber("netRemote.sys.audio.volume", clampInt(v, 0, 100));
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
- async selectPreset(presetKey) {
383
- const p = Math.trunc(Number(presetKey));
384
- if (!Number.isFinite(p)) throw new Error("Invalid presetKey");
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
- // navigation based FSAPI required by many firmwares
387
- await this.setNodeNumber("netRemote.nav.state", 1);
388
- await this.setNodeNumber("netRemote.nav.action.selectPreset", p);
389
- await this.setNodeNumber("netRemote.nav.state", 0);
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
- async getNodeNumber(node) {
393
- const xml = await this.request(`/fsapi/GET/${node}`);
394
- return parseFsapiValue(xml);
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
- async setNodeNumber(node, value) {
398
- const xml = await this.request(`/fsapi/SET/${node}?value=${encodeURIComponent(String(value))}`);
399
- const ok = String(xml).includes("<status>FS_OK</status>");
400
- if (!ok) throw new Error("FSAPI SET failed");
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
- request(path) {
405
- const url = `http://${this.ip}${path}${path.includes("?") ? "&" : "?"}pin=${encodeURIComponent(this.pin)}`;
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
- return new Promise((resolve, reject) => {
408
- const req = http.get(url, { timeout: this.timeoutMs }, (res) => {
409
- let data = "";
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
- req.on("timeout", () => {
416
- req.destroy(new Error("timeout"));
417
- });
349
+ const controller = new AbortController();
350
+ const t = setTimeout(() => controller.abort(), this.timeoutMs);
418
351
 
419
- req.on("error", reject);
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
- function parseFsapiValue(xml) {
425
- if (!xml) return null;
364
+ const trimmed = String(body).trim();
426
365
 
427
- const s = String(xml);
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
- const m =
430
- s.match(/<value>\s*<u8>(-?\d+)<\/u8>\s*<\/value>/i) ||
431
- s.match(/<value>\s*<u16>(-?\d+)<\/u16>\s*<\/value>/i) ||
432
- s.match(/<value>\s*<u32>(-?\d+)<\/u32>\s*<\/value>/i) ||
433
- s.match(/<value>\s*<s16>(-?\d+)<\/s16>\s*<\/value>/i);
373
+ if (xmlMatch) {
374
+ const raw = xmlMatch[1];
375
+ const num = Number(raw);
376
+ return Number.isFinite(num) ? num : raw;
377
+ }
434
378
 
435
- if (m) return Number(m[1]);
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 t = s.match(/<value>\s*<c8_array>(.*?)<\/c8_array>\s*<\/value>/i);
438
- if (t) return t[1];
386
+ const tailNum = trimmed.match(/(-?\d+)\s*$/);
387
+ if (tailNum) return Number(tailNum[1]);
439
388
 
440
- return null;
389
+ return trimmed;
441
390
  }
442
391
 
443
392
  function clampInt(n, min, max) {
444
- const v = Number.isFinite(Number(n)) ? Math.round(Number(n)) : min;
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": "1.2.9",
4
- "description": "Homebridge plugin for Frontier Silicon (FSAPI / NetRemote) based internet and DAB+ radios",
5
- "displayName": "Frontier Silicon Radio",
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-silicon",
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.git"
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
  }