shellies-ds9
Version:
Handles communication with the next generation of Shelly devices
357 lines • 12.6 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebSocketRpcHandlerFactory = exports.WebSocketRpcHandler = void 0;
const ws_1 = __importDefault(require("ws"));
const auth_1 = require("./auth");
const base_1 = require("./base");
/**
* Makes remote procedure calls (RPCs) over WebSockets.
*/
class WebSocketRpcHandler extends base_1.RpcHandler {
/**
* @param hostname - The hostname of the Shelly device to connect to.
* @param opts - Configuration options for this handler.
*/
constructor(hostname, options) {
super('websocket');
this.hostname = hostname;
this.options = options;
/**
* Timeout used to schedule connection attempts and to send periodic ping requests.
*/
this.timeout = null;
/**
* Indicates which value in the `reconnectInterval` option is currently being used.
*/
this.reconnectIntervalIndex = 0;
/**
* Event handlers bound to `this`.
*/
this.openHandler = this.handleOpen.bind(this);
this.closeHandler = this.handleClose.bind(this);
this.messageHandler = this.handleMessage.bind(this);
this.pongHandler = this.handlePong.bind(this);
this.errorHandler = this.handleError.bind(this);
this.socket = this.createSocket(`ws://${hostname}/rpc`);
this.client = new auth_1.JSONRPCClientWithAuthentication((req) => this.handleRequest(req), options.password);
}
get connected() {
return this.socket.readyState === ws_1.default.OPEN;
}
request(method, params) {
this.emit('request', method, params);
return this.client
.timeout(this.options.requestTimeout * 1000)
.request(method, params);
}
destroy() {
// clear any timeout
this.clearTimeout();
// reject all pending requests
this.client.rejectAllPendingRequests('Connection closed');
// disconnect the socket
return this.disconnect();
}
/**
* Creates a new websocket and registers event handlers.
* @param url - The URL to connect to.
*/
createSocket(url) {
return new ws_1.default(url)
.on('open', this.openHandler)
.on('close', this.closeHandler)
.on('message', this.messageHandler)
.on('pong', this.pongHandler)
.on('error', this.errorHandler);
}
/**
* Connects the websocket.
* Creates a new socket if the current is closed.
*/
async connect() {
switch (this.socket.readyState) {
case ws_1.default.CLOSED:
case ws_1.default.CLOSING:
// the current socket is closed, disconnect and create a new one
await this.disconnect();
this.socket = this.createSocket(this.socket.url);
// fall through
case ws_1.default.CONNECTING:
// wait for the socket to be connected
await this.awaitConnect();
}
}
/**
* Returns a Promise that will be fulfilled once the socket is connected.
*/
awaitConnect() {
const s = this.socket;
if (s.readyState === ws_1.default.CONNECTED) {
// we're already connected
return Promise.resolve();
}
else if (s.readyState !== ws_1.default.CONNECTING) {
// reject if the socket isn't currently connecting
return Promise.reject(new Error('WebSocket is not connecting'));
}
return new Promise((resolve, reject) => {
// reject if the socket fails to connect
const closeHandler = (code, reason) => {
const msg = reason.length > 0 ? reason.toString() : `code: ${code}`;
reject(new Error(`Error connecting to device (${msg})`));
};
s.once('close', closeHandler);
// resolve once the socket is connected
s.once('open', () => {
s.removeEventListener('close', closeHandler);
resolve();
});
});
}
/**
* Schedules a connection attempt after a time period specified by the `reconnectInterval` configuration option.
* @return The time, in milliseconds, that the next connection attempt will be made in; or `null` if none has been scheduled.
*/
scheduleConnect() {
const reconnectInterval = this.options.reconnectInterval;
const intervals = !Array.isArray(reconnectInterval) ? [reconnectInterval] : reconnectInterval;
// abort if no interval has been specified
if (intervals.length === 0) {
return null;
}
// get the current interval
const interval = intervals[this.reconnectIntervalIndex] * 1000;
// abort if the interval is a non-positive number
if (interval <= 0) {
return null;
}
// clear any timeout
this.clearTimeout();
// schedule a new connection attempt
this.timeout = setTimeout(async () => {
this.timeout = null;
if (this.reconnectIntervalIndex < intervals.length - 1) {
this.reconnectIntervalIndex++;
}
try {
await this.connect();
}
catch (e) {
this.emit('error', e);
}
}, interval);
return interval;
}
/**
* Disconnects the socket and unregisters event handlers.
*/
async disconnect() {
switch (this.socket.readyState) {
case ws_1.default.OPEN:
case ws_1.default.CONNECTING:
// close the socket
this.socket.close(1000, 'User request');
// fall through
case ws_1.default.CLOSING:
// wait for the socket to be closed
await this.awaitDisconnect();
}
}
/**
* Returns a Promise that will be fulfilled once the socket is disconnected.
*/
awaitDisconnect() {
const s = this.socket;
if (s.readyState === ws_1.default.CLOSED) {
// we're already disconnected
return Promise.resolve();
}
else if (s.readyState !== ws_1.default.CLOSING) {
// reject if the socket isn't closing
return Promise.reject(new Error('WebSocket is not disconnecting'));
}
return new Promise((resolve) => {
// resolve once the socket is disconnected
s.once('close', resolve);
});
}
/**
* Handles a request.
* @param payload - The request payload.
*/
async handleRequest(payload) {
// make sure we're connected
await this.connect();
// then send the request
await this.sendRequest(payload);
}
/**
* Sends a request over the websocket.
* @param payload - The request payload.
*/
sendRequest(payload) {
try {
// add our client ID to the payload
const data = { src: this.options.clientId, ...payload };
return new Promise((resolve, reject) => {
// send the request
this.socket.send(JSON.stringify(data), (error) => {
if (!error) {
resolve();
}
else {
reject(error);
}
});
});
}
catch (e) {
return Promise.reject(e);
}
}
/**
* Sends a ping over the websocket.
*/
sendPing() {
// abort if pings are disabled or the socket isn't open
if (this.options.pingInterval <= 0 || this.socket.readyState !== ws_1.default.OPEN) {
return;
}
// clear the timeout
this.clearTimeout();
// send the ping
this.socket.ping((error) => {
if (error) {
this.emit('error', error);
}
});
// wait for a pong
this.timeout = setTimeout(() => {
// no pong received, terminate the connection
this.socket.terminate();
}, this.options.requestTimeout * 1000);
}
/**
* Clears any currently pending timeout.
*/
clearTimeout() {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}
}
/**
* Handles 'open' events from the socket.
*/
handleOpen() {
// reset the reconnect index
this.reconnectIntervalIndex = 0;
this.emit('connect');
// clear any timeout
this.clearTimeout();
// start sending pings
if (this.options.pingInterval > 0) {
this.timeout = setTimeout(() => this.sendPing(), this.options.pingInterval * 1000);
}
}
/**
* Handles 'close' events from the socket.
* @param code - A status code.
* @param reason - A human-readable explanation why the connection was closed.
*/
handleClose(code, reason) {
// clear any timeout
this.clearTimeout();
// remove event handlers
this.socket
.off('open', this.openHandler)
.off('close', this.closeHandler)
.off('message', this.messageHandler)
.off('pong', this.pongHandler)
.off('error', this.errorHandler);
let reconnectIn = null;
// unless this was an intentional disconnect...
if (code !== 1000) {
// try to reconnect
reconnectIn = this.scheduleConnect();
}
this.emit('disconnect', code, reason.toString(), reconnectIn);
}
/**
* Handles incoming messages.
* @param data The message data, as a JSON encoded string.
*/
handleMessage(data) {
// parse the data
const d = JSON.parse(data.toString());
if (d.id) {
// this is a response, let the JSON RPC client handle it
this.client.receive(d);
}
else if (d.method === 'NotifyStatus' || d.method === 'NotifyFullStatus') {
// this is a status update
this.emit('statusUpdate', d.params);
}
else if (d.method === 'NotifyEvent') {
// this is an event
this.emit('event', d.params);
}
}
/**
* Handles pongs received from the device.
*/
handlePong() {
// clear the timeout
this.clearTimeout();
// schedule a new ping
if (this.options.pingInterval > 0) {
this.timeout = setTimeout(() => this.sendPing(), this.options.pingInterval * 1000);
}
}
/**
* Handles errors from the websocket.
* @param error - The error.
*/
handleError(error) {
this.emit('error', error);
}
}
exports.WebSocketRpcHandler = WebSocketRpcHandler;
/**
* Factory class used to create `WebSocketRpcHandler` instances.
*/
class WebSocketRpcHandlerFactory {
constructor() {
/**
* Default `WebSocketRpcHandler` options.
*/
this.defaultOptions = {
clientId: 'node-shellies-ds9-' + Math.round(Math.random() * 1000000),
requestTimeout: 10,
pingInterval: 60,
reconnectInterval: [
5,
10,
30,
60,
5 * 60,
10 * 60, // 10 minutes
],
};
}
/**
* Creates a new `WebSocketRpcHandler`.
* @param hostname - The hostname of the Shelly device to connect to.
* @param opts - Configuration options for the handler.
*/
create(hostname, opts) {
// get all options (with default values)
const options = { ...this.defaultOptions, ...(opts || {}) };
return new WebSocketRpcHandler(hostname, options);
}
}
exports.WebSocketRpcHandlerFactory = WebSocketRpcHandlerFactory;
//# sourceMappingURL=websocket.js.map