homebridge-frontier-silicon-plugin 0.0.10 → 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 +119 -29
- package/index.js +224 -292
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -3,44 +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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
Most internet radios from brands such as Roberts, Ruark, Revo, Hama and similar use Frontier Silicon firmware and are compatible.
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
- Status Updates
|
|
38
|
-
- Channel Change
|
|
39
|
-
- Clean up the code
|
|
40
|
-
|
|
33
|
+
## Installation
|
|
41
34
|
|
|
42
|
-
|
|
43
|
-
----
|
|
35
|
+
Install the plugin globally using npm
|
|
44
36
|
|
|
45
|
-
|
|
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
|
package/index.js
CHANGED
|
@@ -1,312 +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);
|
|
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
|
|
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
|
}
|