@hoobs/wink
Version:
Wink integration for HOOBS
363 lines (311 loc) • 9.31 kB
JavaScript
const fs = require("fs");
const http = require("http");
const path = require("path");
const url = require("url");
const ip = require("ip");
const request = require("request-promise-native");
const debounce = require("./debounce");
module.exports = class WinkClient {
constructor({ config, log, updateConfig }) {
this.config = config;
this.log = log;
this.updateConfig = updateConfig;
this.direct_access = config.direct_access;
this.hubs = {};
this.nonce = 1000000;
this.updateDevice = debounce({
func: this.updateDevice.bind(this),
key: accessory => accessory.context.uuid,
reduceArgs: (oldArgs, newArgs) => [
newArgs[0],
{ ...oldArgs[1], ...newArgs[1] }
]
});
}
request(options, hub) {
const accessToken = hub ? hub.access_token : this.config.access_token;
const headers = {
"User-Agent":
"Manufacturer/Apple-iPhone10_1 iOS/11.2.6 WinkiOS/6.7.0.19-production-release (Scale/2.00)"
};
if (accessToken && options.uri !== "/oauth2/token") {
headers.Authorization = `Bearer ${accessToken}`;
}
return request({
baseUrl: hub
? `https://${hub.device.last_reading.ip_address}:8888`
: "https://api.wink.com",
strictSSL: !hub,
json: true,
...options,
headers: {
...headers,
...options.headers
}
}).catch(err => {
if (err.statusCode === 401 && this.config.refresh_token) {
return this.refreshToken().then(() => this.request(options, hub));
}
return Promise.reject(err);
});
}
getToken(data) {
return request({
method: "POST",
baseUrl: "https://api.wink.com",
uri: "/oauth2/token",
body: data,
json: true
}).then(response => {
this.updateConfig({
access_token: response.access_token,
refresh_token: response.refresh_token
});
});
}
refreshToken() {
this.log("Refreshing access token...");
return this.getToken({
grant_type: "refresh_token",
client_id: this.config.client_id,
client_secret: this.config.client_secret,
refresh_token: this.config.refresh_token
})
.then(response => {
this.log("Refreshed access token");
return response;
})
.catch(err => {
if (err.statusCode === 400) {
this.updateConfig({
access_token: undefined,
refresh_token: undefined
});
return Promise.reject(
"Restart Homebridge, Wink needs to be re-authenticated."
);
}
return Promise.reject(err);
});
}
getOauthGrant() {
return new Promise(resolve => {
if (this.config.username && this.config.password) {
return resolve({
grant_type: "password",
client_id: this.config.client_id,
client_secret: this.config.client_secret,
username: this.config.username,
password: this.config.password
});
}
const ipAddress = ip.address("public", "ipv4");
const redirectUri = `http://${ipAddress}:8888`;
const state = Date.now().toString();
this.log.warn(
`To authenticate, go here using a web browser: ${redirectUri}`
);
const server = http.createServer((request, response) => {
const { query } = url.parse(request.url, true);
if (query.code && query.state === state) {
resolve({
grant_type: "code",
client_secret: this.config.client_secret,
code: query.code
});
const filePath = path.join(__dirname, "../src/authenticated.html");
const file = fs.readFileSync(filePath);
response.writeHead(200, { "Content-Type": "text/html" });
response.end(file);
server.close();
} else {
response.writeHead(302, {
Location: `https://api.wink.com/oauth2/authorize?response_type=code&client_id=${
this.config.client_id
}&redirect_uri=${redirectUri}&state=${state}`
});
return response.end();
}
});
server.listen(8888);
});
}
async authenticate() {
if (this.config.refresh_token) {
// Already autenticated
return true;
}
const data = await this.getOauthGrant();
try {
await this.getToken(data);
this.log("Authenticated with wink.com");
return true;
} catch (e) {
this.log.error("Could not authenticate with wink.com", e);
return false;
}
}
getUser() {
return this.request({
method: "GET",
uri: "/users/me",
headers: {
"User-Agent": Date.now().toString()
}
});
}
getDevices(object_type = "wink_device") {
return this.request({
method: "GET",
uri: `/users/me/${object_type}s`
});
}
processHubs(hubs) {
if (!this.direct_access) {
return;
}
hubs.forEach((device, index) => {
setTimeout(async () => {
const hub = this.addOrUpdateHub(device);
const isReachable = await this.isHubReachable(hub);
if (isReachable) {
this.authenticateHub(hub);
}
// Wink API is throttled, too many hubs and we'll hit a HTTP 429 error
}, 1000 * index);
});
}
addOrUpdateHub(hub) {
if (!this.hubs[hub.hub_id]) {
this.hubs[hub.hub_id] = { authenticated: false, reachable: false };
}
this.hubs[hub.hub_id].device = hub;
return this.hubs[hub.hub_id];
}
async isHubReachable(hub) {
const hubName = `${hub.device.name}, ${hub.device.last_reading.ip_address}`;
this.log(`Checking if hub is reachable (${hubName})...`);
try {
const response = await this.request(
{
method: "GET",
uri: "/",
json: false,
timeout: 5000
},
hub
);
hub.reachable = response.indexOf("wink.com") !== -1;
if (hub.reachable) {
this.log(`Hub is reachable locally (${hubName})`);
} else {
this.log.warn(
`Hub is reachable locally, but did not return expected response (${hubName})`
);
}
} catch (e) {
hub.reachable = false;
this.log.warn(
`Hub is not reachable locally (${hubName}).`,
(!this.config.debug && e.message) || e
);
}
if (!hub.reachable) {
this.log.warn(`Will continue without local control for hub (${hubName})`);
}
return hub.reachable;
}
async authenticateHub(hub, force = false) {
if (!force && hub.access_token) {
return;
}
const hubName = `${hub.device.name}, ${hub.device.last_reading.ip_address}`;
const errorMessage = `Could not authenticate with local hub (${hubName})`;
let authenticated = false;
try {
const response = await this.request({
method: "POST",
uri: "/oauth2/token",
body: {
local_control_id: hub.device.last_reading.local_control_id,
scope: "local_control",
grant_type: "refresh_token",
refresh_token: this.config.refresh_token,
client_id: this.config.client_id,
client_secret: this.config.client_secret
}
});
if (response.errors && response.errors.length) {
this.log.warn(errorMessage, response.errors);
return;
}
authenticated = true;
hub.access_token = response.access_token;
this.log(`Authenticated with local hub (${hubName})`);
} catch (e) {
this.log.warn(errorMessage, e);
} finally {
hub.authenticated = authenticated;
if (!authenticated) {
delete hub.access_token;
}
}
}
updateDevice(accessory, state) {
this.log(
`Sending update: ${accessory.context.name} (${
accessory.context.object_type
}/${accessory.context.object_id})`,
state
);
this.nonce += 5;
const remote = this.request({
method: "PUT",
uri: `/${accessory.definition.group}/${
accessory.context.object_id
}/desired_state`,
body: {
desired_state: state,
nonce: this.nonce
}
});
const requests = [remote];
const hub = this.hubs[accessory.context.hub_id];
if (hub && hub.authenticated) {
const local = this.request(
{
method: "PUT",
uri: `/${accessory.definition.group}/${
accessory.context.local_id
}/desired_state`,
body: {
desired_state: state,
nonce: this.nonce
}
},
hub
).catch(e => {
hub.authenticated = false;
delete hub.access_token;
const hubName = `${hub.device.name}, ${
hub.device.last_reading.ip_address
}`;
this.log(
"warn",
`Local control failed, falling back to remote control (${hubName})`,
e
);
return remote;
});
requests.push(local);
}
return Promise.race(requests).then(response => {
this.log(
`Update sent successfully: ${accessory.context.name} (${
accessory.context.object_type
}/${accessory.context.object_id})`
);
return response;
});
}
};