homebridge-frontier-silicon-plugin 0.0.9 → 1.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/README.md +118 -29
- package/index.js +223 -292
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -3,45 +3,134 @@
|
|
|
3
3
|
|
|
4
4
|
[](https://travis-ci.org/boikedamhuis/homebridge-frontier-silicon)
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Homebridge Frontier Silicon Plugin
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
This Homebridge plugin adds basic but reliable HomeKit support for Frontier Silicon based internet radios using the native FSAPI interface.
|
|
9
9
|
|
|
10
|
-
|
|
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.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
This plugin is designed as a modern replacement for older Frontier Silicon Homebridge integrations.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
2. Install the package request using: `npm install request -g`
|
|
16
|
-
3. Install the package polling to event using: `npm install polling-to-event -g`
|
|
17
|
-
4. install the package WiFi Radio `npm install wifiradio --save`
|
|
18
|
-
5. Install this plugin: `sudo npm i homebridge-frontier-silicon-plugin`
|
|
19
|
-
6. Update your `config.json` configuration file
|
|
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
|
|
|
25
|
+
## Requirements
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"accessory": "homebridge-frontier-silicon",
|
|
27
|
-
"name": "Radio",
|
|
28
|
-
"ip": "192.168.1.10"
|
|
29
|
-
}
|
|
30
|
-
]
|
|
31
|
-
```
|
|
27
|
+
- Node.js version 18 or higher
|
|
28
|
+
- Homebridge version 1.6 or higher
|
|
29
|
+
- A Frontier Silicon based radio with FSAPI enabled
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
Most internet radios from brands such as Roberts, Ruark, Revo, Hama and similar use Frontier Silicon firmware and are compatible.
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
## Installation
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
- Status Updates
|
|
39
|
-
- Channel Change
|
|
40
|
-
- Clean up the code
|
|
41
|
-
|
|
35
|
+
Install the plugin globally using npm
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
----
|
|
37
|
+
npm install -g homebridge-frontier-silicon-plugin
|
|
45
38
|
|
|
46
|
-
|
|
39
|
+
After installation, restart Homebridge.
|
|
47
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
|
package/index.js
CHANGED
|
@@ -1,313 +1,244 @@
|
|
|
1
|
-
|
|
2
|
-
var request = require("request");
|
|
3
|
-
var pollingtoevent = require("polling-to-event");
|
|
1
|
+
"use strict";
|
|
4
2
|
|
|
5
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.
|
|
21
|
-
this.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function FsApiClient({ ip, pin, log }) {
|
|
156
|
+
this.ip = ip;
|
|
157
|
+
this.pin = pin;
|
|
158
|
+
this.log = log;
|
|
159
|
+
|
|
160
|
+
this.baseUrl = "http://" + ip;
|
|
161
|
+
this.timeoutMs = 2500;
|
|
162
|
+
}
|
|
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
|
+
};
|
|
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);
|
|
307
173
|
};
|
|
308
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
|
+
};
|
|
309
185
|
|
|
186
|
+
FsApiClient.prototype.fetchText = async function (pathAndQuery) {
|
|
187
|
+
const joiner = pathAndQuery.includes("?") ? "&" : "?";
|
|
188
|
+
const url = this.baseUrl + pathAndQuery + joiner + "pin=" + encodeURIComponent(this.pin);
|
|
310
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
|
+
};
|
|
311
201
|
|
|
202
|
+
function parseFsapiValue(body) {
|
|
203
|
+
if (!body) return null;
|
|
312
204
|
|
|
205
|
+
const trimmed = String(body).trim();
|
|
313
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
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
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.
|
|
21
|
-
"homebridge": ">=
|
|
26
|
+
"node": ">=18.0.0",
|
|
27
|
+
"homebridge": ">=1.6.0"
|
|
22
28
|
}
|
|
23
29
|
}
|