homebridge-frontier-silicon-plugin 0.0.10 → 1.0.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.
Files changed (4) hide show
  1. package/README.md +119 -29
  2. package/config.schema.json +29 -0
  3. package/index.js +224 -292
  4. package/package.json +11 -5
package/README.md CHANGED
@@ -3,44 +3,134 @@
3
3
 
4
4
  [![Build Status](https://travis-ci.org/boikedamhuis/homebridge-frontier-silicon.svg?branch=master)](https://travis-ci.org/boikedamhuis/homebridge-frontier-silicon)
5
5
 
6
- # homebridge-frontier-silicon-plugin
6
+ # Homebridge Frontier Silicon Plugin
7
7
 
8
- A Frontier Silicon plugin for homebridge (https://github.com/nfarina/homebridge) which integrates Frontier Silicon enabled devices with Homekit.
9
- Plugin updates the status once you open the app, working on real time status updates. Stay tuned :)
8
+ This Homebridge plugin adds basic but reliable HomeKit support for Frontier Silicon based internet radios using the native FSAPI interface.
10
9
 
11
- # Installation
10
+ It focuses on stability, simplicity and long term maintainability.
11
+ Power control and volume control are supported, with safe polling and graceful error handling when a device is temporarily unreachable.
12
12
 
13
- 1. Install homebridge using: `npm install -g homebridge`
14
- 2. Install the package request using: `npm install request -g`
15
- 3. Install the package polling to event using: `npm install polling-to-event -g`
16
- 4. install the package WiFi Radio `npm install wifiradio --save`
17
- 5. Install this plugin: `sudo npm i homebridge-frontier-silicon-plugin`
18
- 6. Update your `config.json` configuration file
13
+ This plugin is designed as a modern replacement for older Frontier Silicon Homebridge integrations.
19
14
 
15
+ ## Features
20
16
 
17
+ - Power on and off control through HomeKit
18
+ - Volume control exposed as a HomeKit slider
19
+ - Direct FSAPI communication over HTTP
20
+ - No external or native dependencies
21
+ - Crash safe behaviour when the radio is offline
22
+ - Configurable polling interval
23
+ - Compatible with Raspberry Pi and other low power systems
21
24
 
22
- ```
23
- "accessories": [
24
- {
25
- "accessory": "frontier-silicon",
26
- "name": "Radio",
27
- "ip": "192.168.1.10"
28
- }
29
- ]
30
- ```
25
+ ## Requirements
31
26
 
32
- Code is based on this repo: https://github.com/rudders/homebridge-http
27
+ - Node.js version 18 or higher
28
+ - Homebridge version 1.6 or higher
29
+ - A Frontier Silicon based radio with FSAPI enabled
33
30
 
34
- ### Todos
31
+ Most internet radios from brands such as Roberts, Ruark, Revo, Hama and similar use Frontier Silicon firmware and are compatible.
35
32
 
36
- - Volume Change
37
- - Status Updates
38
- - Channel Change
39
- - Clean up the code
40
-
33
+ ## Installation
41
34
 
42
- License
43
- ----
35
+ Install the plugin globally using npm
44
36
 
45
- MIT
37
+ npm install -g homebridge-frontier-silicon-plugin
46
38
 
39
+ After installation, restart Homebridge.
40
+
41
+ ## Configuration
42
+
43
+ Add the accessory to your Homebridge configuration.
44
+
45
+ ### Example configuration
46
+
47
+ {
48
+ "accessory": "frontier-silicon",
49
+ "name": "Living Room Radio",
50
+ "ip": "192.168.1.50",
51
+ "pin": "1234",
52
+ "pollIntervalSeconds": 5,
53
+ "enableVolume": true
54
+ }
55
+
56
+ ## Configuration options
57
+
58
+ name
59
+ Displayed name in HomeKit
60
+
61
+ ip
62
+ IP address of the radio
63
+
64
+ pin
65
+ FSAPI PIN code
66
+ Default is 1234 on most devices
67
+
68
+ pollIntervalSeconds
69
+ Polling interval in seconds
70
+ Minimum value is 2
71
+ Default value is 5
72
+
73
+ enableVolume
74
+ Enable volume control
75
+ Default is true
76
+
77
+ ## HomeKit behaviour
78
+
79
+ - The radio power state appears as a Switch accessory
80
+ - Volume control is exposed as a separate slider using a Lightbulb Brightness characteristic
81
+
82
+ If the radio becomes unreachable, HomeKit will continue to show the last known state.
83
+ Homebridge will not crash or hang.
84
+
85
+ When the radio becomes reachable again, the state is updated automatically.
86
+
87
+ ## How it works
88
+
89
+ This plugin communicates directly with the Frontier Silicon FSAPI using HTTP requests.
90
+
91
+ Power state
92
+ netRemote.sys.power
93
+
94
+ Volume
95
+ netRemote.sys.audio.volume
96
+
97
+ All requests are executed with timeouts and error handling to ensure Homebridge stability.
98
+
99
+ ## Troubleshooting
100
+
101
+ The radio does not respond
102
+
103
+ - Check the IP address
104
+ - Verify the FSAPI PIN
105
+ - Make sure the radio is connected to the same network as Homebridge
106
+
107
+ HomeKit does not update immediately
108
+
109
+ - Increase the polling interval
110
+ - Wait for the next poll cycle
111
+
112
+ Enable debug logging in Homebridge for more insight.
113
+
114
+ ## Compatibility notes
115
+
116
+ Some radios require FSAPI to be enabled in their settings menu.
117
+ Some models expose volume in a different range, but most work correctly with values from 0 to 100.
118
+
119
+ If your radio behaves differently, feel free to open an issue.
120
+
121
+ ## Development
122
+
123
+ This plugin is intentionally kept simple and dependency free.
124
+
125
+ Possible future improvements include
126
+
127
+ - Platform plugin support
128
+ - Homebridge UI configuration schema
129
+ - Preset or station switching
130
+ - Mute support
131
+
132
+ Contributions and pull requests are welcome.
133
+
134
+ ## License
135
+
136
+ ISC License
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "homebridge-frontier-silicon-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Homebridge plugin for Frontier Silicon FSAPI devices, power and volume with safe polling",
5
+ "license": "ISC",
6
+ "main": "index.js",
7
+ "keywords": [
8
+ "homebridge-pluginconfig.schema.json",
9
+ "homebridge",
10
+ "frontier",
11
+ "silicon",
12
+ "fsapi",
13
+ "internet-radio"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/boikedamhuis/homebridge-frontier-silicon"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/boikedamhuis/homebridge-frontier-silicon/issues"
21
+ },
22
+ "author": {
23
+ "name": "Boike Damhuis"
24
+ },
25
+ "engines": {
26
+ "node": ">=18.0.0",
27
+ "homebridge": ">=1.6.0"
28
+ }
29
+ }
package/index.js CHANGED
@@ -1,312 +1,244 @@
1
- var Service, Characteristic;
2
- var request = require("request");
3
- var pollingtoevent = require("polling-to-event");
1
+ "use strict";
4
2
 
5
- const wifiradio = require('wifiradio');
3
+ let Service;
4
+ let Characteristic;
6
5
 
6
+ module.exports = function (homebridge) {
7
+ Service = homebridge.hap.Service;
8
+ Characteristic = homebridge.hap.Characteristic;
9
+
10
+ homebridge.registerAccessory(
11
+ "homebridge-frontier-silicone",
12
+ "frontier-silicon",
13
+ FrontierSiliconAccessory
14
+ );
15
+ };
7
16
 
17
+ function FrontierSiliconAccessory(log, config) {
18
+ this.log = log;
19
+
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);
24
+ this.enableVolume = config.enableVolume !== false;
25
+
26
+ this.lastKnownPower = null;
27
+ this.lastKnownVolume = null;
28
+
29
+ if (!this.ip) {
30
+ this.log.warn("No ip configured, accessory will not work.");
31
+ }
32
+
33
+ this.client = new FsApiClient({
34
+ ip: this.ip,
35
+ pin: this.pin,
36
+ log: this.log
37
+ });
38
+
39
+ this.switchService = new Service.Switch(this.name);
40
+
41
+ this.switchService
42
+ .getCharacteristic(Characteristic.On)
43
+ .on("get", this.handleGetPower.bind(this))
44
+ .on("set", this.handleSetPower.bind(this));
45
+
46
+ if (this.enableVolume) {
47
+ this.volumeService = new Service.Lightbulb(this.name + " Volume");
48
+ this.volumeService
49
+ .getCharacteristic(Characteristic.On)
50
+ .on("get", (cb) => cb(null, true))
51
+ .on("set", (val, cb) => cb(null));
52
+
53
+ this.volumeService
54
+ .getCharacteristic(Characteristic.Brightness)
55
+ .on("get", this.handleGetVolume.bind(this))
56
+ .on("set", this.handleSetVolume.bind(this));
57
+ }
58
+
59
+ this.informationService = new Service.AccessoryInformation()
60
+ .setCharacteristic(Characteristic.Manufacturer, "Frontier Silicon")
61
+ .setCharacteristic(Characteristic.Model, "FSAPI Radio")
62
+ .setCharacteristic(Characteristic.SerialNumber, this.ip || "unknown");
63
+
64
+ this.startPolling();
65
+ }
8
66
 
9
- module.exports = function (homebridge) {
10
- Service = homebridge.hap.Service;
11
- Characteristic = homebridge.hap.Characteristic;
12
- homebridge.registerAccessory("homebridge-frontier-silicone", "frontier-silicon", HttpAccessory);
67
+ FrontierSiliconAccessory.prototype.getServices = function () {
68
+ const services = [this.informationService, this.switchService];
69
+ if (this.enableVolume && this.volumeService) services.push(this.volumeService);
70
+ return services;
13
71
  };
14
72
 
73
+ FrontierSiliconAccessory.prototype.handleGetPower = async function (callback) {
74
+ try {
75
+ const power = await this.client.getPower();
76
+ this.lastKnownPower = power;
77
+ callback(null, power);
78
+ } catch (err) {
79
+ this.log.warn("Power get failed, returning last known state.", toMsg(err));
80
+ callback(null, this.lastKnownPower ?? false);
81
+ }
82
+ };
15
83
 
16
- function HttpAccessory(log, config) {
17
- this.log = log;
18
-
19
- // url info
20
- this.ip = config["ip"];
21
- this.on_url = this.ip;
22
- this.on_body = this.ip;
23
- this.off_url = this.ip;
24
- this.off_body = this.ip;
25
- this.status_url = "/fsapi/GET/netRemote.sys.power?pin=1234";
26
- this.status_on = "FS_OK 1";
27
- this.status_off = "FS_OK 0";
28
- this.brightness_url = config["brightness_url"];
29
- this.brightnesslvl_url = config["brightnesslvl_url"];
30
- this.http_method = config["http_method"] || "GET";
31
- this.http_brightness_method = config["http_brightness_method"] || this.http_method;
32
- this.username = config["username"] || "";
33
- this.password = config["password"] || "";
34
- this.sendimmediately = config["sendimmediately"] || "";
35
- this.service = config["service"] || "Switch";
36
- this.name = config["name"];
37
- this.brightnessHandling = config["brightnessHandling"] || "no";
38
- this.switchHandling = "yes";
39
-
40
-
41
- //realtime polling info
42
- this.state = false;
43
- this.currentlevel = 0;
44
- this.enableSet = true;
45
- var that = this;
46
-
47
- // Status Polling, if you want to add additional services that don't use switch handling you can add something like this || (this.service=="Smoke" || this.service=="Motion"))
48
- if (this.status_url && this.switchHandling === "realtime") {
49
- var powerurl = this.status_url;
50
- var statusemitter = pollingtoevent(function (done) {
51
-
52
- }, { longpolling: true, interval: 300, longpollEventName: "statuspoll" });
53
-
54
- function compareStates(customStatus, stateData) {
55
- var objectsEqual = true;
56
- for (var param in customStatus) {
57
- if (!stateData.hasOwnProperty(param) || customStatus[param] !== stateData[param]) {
58
- objectsEqual = false;
59
- break;
60
- }
61
- }
62
- // that.log("Equal", objectsEqual);
63
- return objectsEqual;
64
- }
84
+ FrontierSiliconAccessory.prototype.handleSetPower = async function (value, callback) {
85
+ const target = !!value;
86
+
87
+ try {
88
+ await this.client.setPower(target);
89
+ this.lastKnownPower = target;
90
+ callback(null);
91
+ } catch (err) {
92
+ this.log.warn("Power set failed, keeping last known state.", toMsg(err));
93
+ callback(null);
94
+ }
95
+ };
65
96
 
66
- statusemitter.on("statuspoll", function (responseBody) {
67
- var binaryState;
68
- this.log("Status Config On", this.status_on);
69
- var customStatusOn = this.status_on;
70
- var customStatusOff = this.status_off;
71
- var statusOn, statusOff;
72
-
73
- // Check to see if custom states are a json object and if so compare to see if either one matches the state response
74
- const radio = new wifiradio(this.ip, "1234");
75
-
76
-
77
- radio.getPower() .then(function(response) {
78
- if (response == "1") {
79
- binaryState = 1;
80
-
81
- }
82
- // else binaryState = 0;
83
- if (response == "0") {
84
- binaryState = 0;
85
-
86
- }
87
- console.log(response);
88
- callback(null, binaryState);
89
- })
90
- that.state = binaryState > 0;
91
- that.log(that.service, "received power", that.status_url, "state is currently", binaryState);
92
- // switch used to easily add additonal services
93
- that.enableSet = false;
94
- switch (that.service) {
95
- case "Switch":
96
- if (that.switchService) {
97
- that.switchService.getCharacteristic(Characteristic.On)
98
- .setValue(that.state);
99
- }
100
- break;
101
- case "Light":
102
- if (that.lightbulbService) {
103
- that.lightbulbService.getCharacteristic(Characteristic.On)
104
- .setValue(that.state);
105
- }
106
- break;
107
- }
108
- that.enableSet = true;
109
- });
97
+ FrontierSiliconAccessory.prototype.handleGetVolume = async function (callback) {
98
+ try {
99
+ const vol = await this.client.getVolume();
100
+ this.lastKnownVolume = vol;
101
+ callback(null, vol);
102
+ } catch (err) {
103
+ this.log.warn("Volume get failed, returning last known level.", toMsg(err));
104
+ callback(null, this.lastKnownVolume ?? 0);
105
+ }
106
+ };
110
107
 
111
- }
112
- // Brightness Polling
113
- if (this.brightnesslvl_url && this.brightnessHandling === "realtime") {
114
- var brightnessurl = this.brightnesslvl_url;
115
- var levelemitter = pollingtoevent(function (done) {
116
- that.httpRequest(brightnessurl, "", "GET", that.username, that.password, that.sendimmediately, function (error, response, responseBody) {
117
- if (error) {
118
- that.log("HTTP get power function failed: %s", error.message);
119
- return;
120
- } else {
121
- done(null, responseBody);
122
- }
123
- }) // set longer polling as slider takes longer to set value
124
- }, { longpolling: true, interval: 300, longpollEventName: "levelpoll" });
125
-
126
- levelemitter.on("levelpoll", function (responseBody) {
127
- that.currentlevel = parseInt(responseBody);
128
-
129
- that.enableSet = false;
130
- if (that.lightbulbService) {
131
- that.log(that.service, "received brightness", that.brightnesslvl_url, "level is currently", that.currentlevel);
132
- that.lightbulbService.getCharacteristic(Characteristic.Brightness)
133
- .setValue(that.currentlevel);
134
- }
135
- that.enableSet = true;
136
- });
137
- }
138
- }
108
+ FrontierSiliconAccessory.prototype.handleSetVolume = async function (value, callback) {
109
+ const vol = clampInt(Number(value), 0, 100);
110
+
111
+ try {
112
+ await this.client.setVolume(vol);
113
+ this.lastKnownVolume = vol;
114
+ callback(null);
115
+ } catch (err) {
116
+ this.log.warn("Volume set failed, keeping last known level.", toMsg(err));
117
+ callback(null);
118
+ }
119
+ };
120
+
121
+ FrontierSiliconAccessory.prototype.startPolling = function () {
122
+ if (!this.ip) return;
139
123
 
140
- HttpAccessory.prototype = {
141
-
142
-
143
-
144
- setPowerState: function (powerState, callback) {
145
- this.log("Power On", powerState);
146
-
147
- if (this.enableSet === true) {
148
-
149
- var url;
150
- var body;
151
-
152
- if (!this.on_url || !this.off_url) {
153
- this.log.warn("No IP adress defined");
154
- callback(new Error("No power IP defined."));
155
- return;
156
- }
157
- const radio = new wifiradio(this.ip, "1234");
158
-
159
- if (powerState) {
160
-
161
-
162
- radio.setPower(1);
163
-
164
- body = this.on_body;
165
- this.log("Setting power state to on");
166
- } else {
167
- radio.setPower(0);
168
- this.log("Setting power state to off");
169
- }
170
-
171
- callback();
124
+ const intervalMs = Math.max(2, this.pollIntervalSeconds) * 1000;
125
+
126
+ const tick = async () => {
127
+ try {
128
+ const power = await this.client.getPower();
129
+ if (this.lastKnownPower !== power) {
130
+ this.lastKnownPower = power;
131
+ this.switchService.getCharacteristic(Characteristic.On).updateValue(power);
132
+ }
133
+ } catch (err) {
134
+ this.log.debug ? this.log.debug("Polling power failed.", toMsg(err)) : this.log("Polling power failed.");
172
135
  }
173
-
174
- },
175
-
176
- getPowerState: function (callback) {
177
- if (!this.status_url) {
178
- this.log.warn("Ignoring request; No status url defined.");
179
- callback(new Error("No status url defined."));
180
- return;
181
- }
182
136
 
183
- var url = this.status_url;
184
- this.log("Getting power state");
185
-
186
-
187
- var binaryState;
188
- this.log("Status Config On", this.status_on);
189
- var customStatusOn = this.status_on;
190
- var customStatusOff = this.status_off;
191
- var statusOn, statusOff;
192
-
193
- // Check to see if custom states are a json object and if so compare to see if either one matches the state response
194
- const radio = new wifiradio(this.ip, "1234");
195
-
196
-
197
- radio.getPower() .then(function(response) {
198
- if (response == "1") {
199
- binaryState = 1;
200
-
201
- }
202
- // else binaryState = 0;
203
- if (response == "0") {
204
- binaryState = 0;
205
-
206
- }
207
- console.log(response);
208
- callback(null, binaryState);
209
- })
210
-
211
-
212
-
213
-
214
-
215
-
216
- //var powerOn = binaryState = 0;
217
- this.log("Power state is currently %s", binaryState);
218
- },
219
-
220
- identify: function (callback) {
221
- this.log("Identify requested!");
222
- callback(); // success
223
- },
224
-
225
- getServices: function () {
226
-
227
- var that = this;
228
-
229
- // you can OPTIONALLY create an information service if you wish to override
230
- // the default values for things like serial number, model, etc.
231
- var informationService = new Service.AccessoryInformation();
232
-
233
- informationService
234
- .setCharacteristic(Characteristic.Manufacturer, "Boike Damhuis")
235
- .setCharacteristic(Characteristic.Model, "0.0.8")
236
- .setCharacteristic(Characteristic.SerialNumber, "8PC00CAM4B");
237
-
238
- switch (this.service) {
239
- case "Switch":
240
- this.switchService = new Service.Switch(this.name);
241
- switch (this.switchHandling) {
242
- //Power Polling
243
- case "yes":
244
- this.switchService
245
- .getCharacteristic(Characteristic.On)
246
- .on("get", this.getPowerState.bind(this))
247
- .on("set", this.setPowerState.bind(this));
248
- break;
249
- case "realtime":
250
- this.switchService
251
- .getCharacteristic(Characteristic.On)
252
- .on("get", function (callback) {
253
- callback(null, that.state)
254
- })
255
- .on("set", this.setPowerState.bind(this));
256
- break;
257
- default :
258
- this.switchService
259
- .getCharacteristic(Characteristic.On)
260
- .on("set", this.setPowerState.bind(this));
261
- break;
262
- }
263
- return [this.switchService];
264
- case "Light":
265
- this.lightbulbService = new Service.Lightbulb(this.name);
266
- switch (this.switchHandling) {
267
- //Power Polling
268
- case "yes" :
269
- this.lightbulbService
270
- .getCharacteristic(Characteristic.On)
271
- .on("get", this.getPowerState.bind(this))
272
- .on("set", this.setPowerState.bind(this));
273
- break;
274
- case "realtime":
275
- this.lightbulbService
276
- .getCharacteristic(Characteristic.On)
277
- .on("get", function (callback) {
278
- callback(null, that.state)
279
- })
280
- .on("set", this.setPowerState.bind(this));
281
- break;
282
- default:
283
- this.lightbulbService
284
- .getCharacteristic(Characteristic.On)
285
- .on("set", this.setPowerState.bind(this));
286
- break;
287
- }
288
- // Brightness Polling
289
- if (this.brightnessHandling === "realtime") {
290
- this.lightbulbService
291
- .addCharacteristic(new Characteristic.Brightness())
292
- .on("get", function (callback) {
293
- callback(null, that.currentlevel)
294
- })
295
- .on("set", this.setBrightness.bind(this));
296
- } else if (this.brightnessHandling === "yes") {
297
- this.lightbulbService
298
- .addCharacteristic(new Characteristic.Brightness())
299
- .on("get", this.getBrightness.bind(this))
300
- .on("set", this.setBrightness.bind(this));
301
- }
302
-
303
- return [informationService, this.lightbulbService];
304
- break;
137
+ if (this.enableVolume && this.volumeService) {
138
+ try {
139
+ const vol = await this.client.getVolume();
140
+ if (this.lastKnownVolume !== vol) {
141
+ this.lastKnownVolume = vol;
142
+ this.volumeService.getCharacteristic(Characteristic.Brightness).updateValue(vol);
305
143
  }
144
+ } catch (err) {
145
+ this.log.debug ? this.log.debug("Polling volume failed.", toMsg(err)) : this.log("Polling volume failed.");
146
+ }
306
147
  }
148
+ };
149
+
150
+ tick();
151
+
152
+ this.pollTimer = setInterval(tick, intervalMs);
307
153
  };
308
154
 
155
+ function FsApiClient({ ip, pin, log }) {
156
+ this.ip = ip;
157
+ this.pin = pin;
158
+ this.log = log;
309
159
 
160
+ this.baseUrl = "http://" + ip;
161
+ this.timeoutMs = 2500;
162
+ }
310
163
 
164
+ FsApiClient.prototype.getPower = async function () {
165
+ const text = await this.fetchText("/fsapi/GET/netRemote.sys.power");
166
+ const value = parseFsapiValue(text);
167
+ return value === 1;
168
+ };
311
169
 
170
+ FsApiClient.prototype.setPower = async function (on) {
171
+ const v = on ? 1 : 0;
172
+ await this.fetchText("/fsapi/SET/netRemote.sys.power?value=" + v);
173
+ };
312
174
 
175
+ FsApiClient.prototype.getVolume = async function () {
176
+ const text = await this.fetchText("/fsapi/GET/netRemote.sys.audio.volume");
177
+ const value = parseFsapiValue(text);
178
+ return clampInt(Number(value), 0, 100);
179
+ };
180
+
181
+ FsApiClient.prototype.setVolume = async function (volume) {
182
+ const v = clampInt(Number(volume), 0, 100);
183
+ await this.fetchText("/fsapi/SET/netRemote.sys.audio.volume?value=" + v);
184
+ };
185
+
186
+ FsApiClient.prototype.fetchText = async function (pathAndQuery) {
187
+ const joiner = pathAndQuery.includes("?") ? "&" : "?";
188
+ const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
189
+
190
+ const controller = new AbortController();
191
+ const t = setTimeout(() => controller.abort(), this.timeoutMs);
192
+
193
+ try {
194
+ const res = await fetch(url, { signal: controller.signal });
195
+ if (!res.ok) throw new Error("HTTP " + res.status);
196
+ return await res.text();
197
+ } finally {
198
+ clearTimeout(t);
199
+ }
200
+ };
201
+
202
+ function parseFsapiValue(body) {
203
+ if (!body) return null;
204
+
205
+ const trimmed = String(body).trim();
206
+
207
+ const xmlMatch =
208
+ trimmed.match(/<value>\s*<u8>(\d+)<\/u8>\s*<\/value>/i) ||
209
+ trimmed.match(/<value>\s*<u16>(\d+)<\/u16>\s*<\/value>/i) ||
210
+ trimmed.match(/<value>\s*<u32>(\d+)<\/u32>\s*<\/value>/i) ||
211
+ trimmed.match(/<value>\s*<s16>(-?\d+)<\/s16>\s*<\/value>/i) ||
212
+ trimmed.match(/<value>\s*<c8_array>(.*?)<\/c8_array>\s*<\/value>/i);
213
+
214
+ if (xmlMatch) {
215
+ const raw = xmlMatch[1];
216
+ const num = Number(raw);
217
+ return Number.isFinite(num) ? num : raw;
218
+ }
219
+
220
+ const okMatch = trimmed.match(/FS_OK\s+(.+)$/i);
221
+ if (okMatch) {
222
+ const raw = okMatch[1].trim();
223
+ const num = Number(raw);
224
+ return Number.isFinite(num) ? num : raw;
225
+ }
226
+
227
+ const tailNum = trimmed.match(/(-?\d+)\s*$/);
228
+ if (tailNum) return Number(tailNum[1]);
229
+
230
+ return trimmed;
231
+ }
232
+
233
+ function clampInt(n, min, max) {
234
+ const v = Number.isFinite(n) ? Math.round(n) : min;
235
+ if (v < min) return min;
236
+ if (v > max) return max;
237
+ return v;
238
+ }
239
+
240
+ function toMsg(err) {
241
+ if (!err) return "";
242
+ if (err instanceof Error) return err.message;
243
+ return String(err);
244
+ }
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "homebridge-frontier-silicon-plugin",
3
- "version": "0.0.10",
4
- "description": "Plugin for frontier silicon devices",
3
+ "version": "1.0.1",
4
+ "description": "Homebridge plugin for Frontier Silicon FSAPI devices, power and volume with safe polling",
5
5
  "license": "ISC",
6
+ "main": "index.js",
6
7
  "keywords": [
7
- "homebridge-plugin"
8
+ "homebridge-plugin",
9
+ "homebridge",
10
+ "frontier",
11
+ "silicon",
12
+ "fsapi",
13
+ "internet-radio"
8
14
  ],
9
15
  "repository": {
10
16
  "type": "git",
@@ -17,7 +23,7 @@
17
23
  "name": "Boike Damhuis"
18
24
  },
19
25
  "engines": {
20
- "node": ">=0.12.0",
21
- "homebridge": ">=0.2.0"
26
+ "node": ">=18.0.0",
27
+ "homebridge": ">=1.6.0"
22
28
  }
23
29
  }