klf-200-api
Version:
This module provides a wrapper to the socket API of a Velux KLF-200 interface. You will need at least firmware 0.2.0.0.71 on your KLF interface for this library to work.
414 lines • 17 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Connection = void 0;
const debug_1 = require("debug");
const fs_1 = require("fs");
const path_1 = require("path");
const promise_timeout_1 = require("promise-timeout");
const tls_1 = require("tls");
const GW_ERROR_NTF_js_1 = require("./KLF200-API/GW_ERROR_NTF.js");
const GW_GET_STATE_REQ_js_1 = require("./KLF200-API/GW_GET_STATE_REQ.js");
const KLF200SocketProtocol_js_1 = require("./KLF200-API/KLF200SocketProtocol.js");
const common_js_1 = require("./KLF200-API/common.js");
const index_js_1 = require("./index.js");
const TypedEvent_js_1 = require("./utils/TypedEvent.js");
const debug = (0, debug_1.default)(`klf-200-api:connection`);
const FINGERPRINT = "02:8C:23:A0:89:2B:62:98:C4:99:00:5B:D2:E7:2E:0A:70:3D:71:6A";
const ca = (0, fs_1.readFileSync)((0, path_1.join)(__dirname, "../velux-cert.pem"));
/**
* The Connection class is used to handle the communication with the Velux KLF interface.
* It provides login and logout functionality and provides methods to run other commands
* on the socket API.
*
* ```
* const Connection = require('velux-api').Connection;
*
* let conn = new Connection('velux-klf-12ab');
* conn.loginAsync('velux123')
* .then(() => {
* ... do some other stuff ...
* return conn.logoutAsync();
* })
* .catch((err) => { // always close the connection
* return conn.logoutAsync().reject(err);
* });
* ```
*
* @export
* @class Connection
*/
class Connection {
sckt;
klfProtocol;
host;
CA = ca;
fingerprint = FINGERPRINT;
connectionOptions;
constructor(host, CAorConnectionOptions, fingerprint) {
this.host = host;
if (CAorConnectionOptions !== undefined) {
if (Buffer.isBuffer(CAorConnectionOptions)) {
this.CA = CAorConnectionOptions;
}
else {
this.connectionOptions = CAorConnectionOptions;
}
}
if (fingerprint !== undefined) {
this.fingerprint = fingerprint;
}
}
/**
* Gets the [[KLF200SocketProtocol]] object used by this connection.
* This property has a value after calling [[loginAsync]], only.
*
* @readonly
* @memberof Connection
*/
get KLF200SocketProtocol() {
return this.klfProtocol;
}
/**
* This method implements the login process without timeout.
* The [[loginAsync]] function wraps this into a timed promise.
*
* @private
* @param {string} password The password needed for login. The factory default password is velux123.
* @returns {Promise<void>} Returns a promise that resolves to true on success or rejects with the errors.
* @memberof Connection
*/
async _loginAsync(password, timeout) {
try {
await this.initSocketAsync();
this.klfProtocol = new KLF200SocketProtocol_js_1.KLF200SocketProtocol(this.sckt);
const passwordCFM = (await this.sendFrameAsync(new index_js_1.GW_PASSWORD_ENTER_REQ(password), timeout));
if (passwordCFM.Status !== common_js_1.GW_COMMON_STATUS.SUCCESS) {
return Promise.reject(new Error("Login failed."));
}
else {
return Promise.resolve();
}
}
catch (error) {
return Promise.reject(error);
}
}
/**
* Logs in to the KLF interface by sending the GW_PASSWORD_ENTER_REQ.
*
* @param {string} password The password needed for login. The factory default password is velux123.
* @param {number} [timeout=60] A timeout in seconds. After the timeout the returned promise will be rejected.
* @returns {Promise<void>} Returns a promise that resolves to true on success or rejects with the errors.
* @memberof Connection
*/
async loginAsync(password, timeout = 60) {
try {
await this._loginAsync(password, timeout);
}
catch (error) {
return Promise.reject(error);
}
}
/**
* Logs out from the KLF interface and closes the socket.
*
* @param {number} [timeout=10] A timeout in seconds. After the timeout the returned promise will be rejected.
* @returns {Promise<void>} Returns a promise that resolves to true on successful logout or rejects with the errors.
* @memberof Connection
*/
async logoutAsync(timeout = 10) {
try {
if (this.sckt) {
if (this.klfProtocol) {
this.klfProtocol = undefined;
}
return (0, promise_timeout_1.timeout)(new Promise((resolve, reject) => {
try {
// Close socket
this.sckt?.end("", resolve);
}
catch (error) {
reject(error);
}
}), timeout * 1000);
}
else {
return Promise.resolve();
}
}
catch (error) {
Promise.reject(error);
}
}
/**
* Sends a request frame to the KLF interface.
*
* @param {IGW_FRAME_REQ} frame The frame that should be sent to the KLF interface.
* @param {number} [timeout=10] A timeout in seconds. After the timeout the returned promise will be rejected.
* @returns {Promise<IGW_FRAME_RCV>} Returns a promise with the corresponding confirmation message as value.
* In case of an error frame the promise will be rejected with the error number.
* If the request frame is a command (with a SessionID) than the promise will be
* resolved by the corresponding confirmation frame with a matching session ID.
* @memberof Connection
*/
async sendFrameAsync(frame, timeout = 10) {
try {
debug(`sendFrameAsync called with frame: ${stringifyFrame(frame)}, timeout: ${timeout}.`);
const frameName = common_js_1.GatewayCommand[frame.Command];
const expectedConfirmationFrameName = (frameName.slice(0, -3) +
"CFM");
const expectedConfirmationFrameCommand = common_js_1.GatewayCommand[expectedConfirmationFrameName];
const sessionID = frame instanceof common_js_1.GW_FRAME_COMMAND_REQ ? frame.SessionID : undefined;
debug(`Expected confirmation frame is ${expectedConfirmationFrameName} (${expectedConfirmationFrameCommand}). Session ID: ${sessionID}`);
// Setup the event handlers first to prevent a race condition
// where we don't see the events.
let resolve, reject;
const notificationHandler = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
try {
let cfmHandler = undefined;
const errHandler = this.klfProtocol.onError((error) => {
debug(`sendFrameAsync protocol error handler: ${error}.`);
errHandler.dispose();
cfmHandler?.dispose();
reject(error);
});
cfmHandler = this.klfProtocol.on((notificationFrame) => {
try {
debug(`sendFrameAsync frame recieved: ${stringifyFrame(notificationFrame)}.`);
if (notificationFrame instanceof GW_ERROR_NTF_js_1.GW_ERROR_NTF) {
debug(`sendFrameAsync GW_ERROR_NTF recieved: ${stringifyFrame(notificationFrame)}.`);
errHandler.dispose();
cfmHandler?.dispose();
reject(new Error(notificationFrame.getError(), { cause: notificationFrame }));
}
else if (notificationFrame.Command === expectedConfirmationFrameCommand &&
(typeof sessionID === "undefined" ||
sessionID === notificationFrame.SessionID)) {
debug(`sendFrameAsync expected frame recieved: ${stringifyFrame(notificationFrame)}.`);
errHandler.dispose();
cfmHandler?.dispose();
resolve(notificationFrame);
}
}
catch (error) {
errHandler.dispose();
cfmHandler?.dispose();
reject(error);
}
});
this.shiftKeepAlive();
await this.klfProtocol.write(frame.Data);
await this.notifyFrameSent(frame);
}
catch (error) {
reject(error);
}
return await (0, promise_timeout_1.timeout)(notificationHandler, timeout * 1000);
}
catch (error) {
debug(`sendFrameAsync error occurred: ${error} with frame sent: ${stringifyFrame(frame)}.`);
return Promise.reject(error);
}
function stringifyFrame(frame) {
return JSON.stringify(frame, (key, value) => {
if (key.match(/password/i)) {
return "**********";
}
else {
return value;
}
});
}
}
/**
* Add a handler to listen for confirmations and notification.
* You can provide an optional filter to listen only to
* specific events.
*
* @param {Listener<IGW_FRAME_RCV>} handler Callback functions that is called for an event
* @param {GatewayCommand[]} [filter] Array of GatewayCommand entries you want to listen to. Optional.
* @returns {Disposable} Returns a Disposable that you can call to remove the handler.
* @memberof Connection
*/
on(handler, filter) {
if (typeof filter === "undefined") {
return this.klfProtocol.on(handler);
}
else {
return this.klfProtocol.on(async (frame) => {
if (filter.indexOf(frame.Command) >= 0) {
await Promise.resolve(handler(frame));
}
});
}
}
_onFrameSent = new TypedEvent_js_1.TypedEvent();
/**
* Add a handler to listen for sent frames.
* You can provide an optional filter to listen only to
* specific events.
*
* @param {Listener<IGW_FRAME_REQ>} handler Callback functions that is called for an event
* @param {GatewayCommand[]} [filter] Array of GatewayCommand entries you want to listen to. Optional.
* @returns {Disposable} Returns a Disposable that you can call to remove the handler.
* @memberof Connection
*/
onFrameSent(handler, filter) {
if (typeof filter === "undefined") {
return this._onFrameSent.on(handler);
}
else {
return this._onFrameSent.on((frame) => {
if (filter.indexOf(frame.Command) >= 0) {
handler(frame);
}
});
}
}
async notifyFrameSent(frame) {
await this._onFrameSent.emit(frame);
}
keepAliveTimer;
keepAliveInterval = 10 * 60 * 1000;
/**
* Start a keep-alive timer to send a message
* at least every [[interval]] minutes to the interface.
* The KLF-200 interface will close the connection
* after 15 minutes of inactivity.
*
* @param {number} [interval=600000] Keep-alive interval in minutes. Defaults to 10 min.
* @memberof Connection
*/
startKeepAlive(interval = 10 * 60 * 1000) {
this.keepAliveInterval = interval;
this.keepAliveTimer = setInterval(() => {
this.sendKeepAlive();
}, interval);
}
/**
* Stops the keep-alive timer.
* If not timer is set nothing happens.
*
* @memberof Connection
*/
stopKeepAlive() {
if (this.keepAliveTimer) {
clearInterval(this.keepAliveTimer);
this.keepAliveTimer = undefined;
}
}
/**
* Sends a keep-alive message to the interface
* to keep the socket connection open.
*
* @private
* @returns {Promise<void>} Resolves if successful, otherwise reject
* @memberof Connection
*/
async sendKeepAlive() {
await this.sendFrameAsync(new GW_GET_STATE_REQ_js_1.GW_GET_STATE_REQ());
return;
}
/**
* Shifts the keep-alive timer to restart its counter.
* If no keep-alive timer is active nothing happens.
*
* @private
* @memberof Connection
*/
shiftKeepAlive() {
if (this.keepAliveTimer) {
clearInterval(this.keepAliveTimer);
this.startKeepAlive(this.keepAliveInterval);
}
}
async initSocketAsync() {
try {
if (this.sckt === undefined) {
return new Promise((resolve, reject) => {
try {
const loginErrorHandler = (error) => {
console.error(`loginErrorHandler: ${JSON.stringify(error)}`);
this.sckt = undefined;
reject(error);
};
this.sckt = (0, tls_1.connect)(common_js_1.KLF200_PORT, this.host, this.connectionOptions
? this.connectionOptions
: {
rejectUnauthorized: true,
ca: [this.CA],
checkServerIdentity: (host, cert) => this.checkServerIdentity(host, cert),
}, () => {
// Callback on event "secureConnect":
// Resolve promise if connection is authorized, otherwise reject it.
if (this.sckt?.authorized) {
// Remove login error handler
this.sckt?.off("error", loginErrorHandler);
resolve();
}
else {
const err = this.sckt?.authorizationError;
this.sckt = undefined;
console.error(`AuthorizationError: ${err}`);
reject(err);
}
});
// Add error handler to reject the promise on login problems
this.sckt?.on("error", loginErrorHandler);
this.sckt?.on("close", () => {
// Socket has been closed -> clean up everything
this.socketClosedEventHandler();
});
// Add additional error handler for the lifetime of the socket
this.sckt?.on("error", () => {
// Socket has an error -> clean up everything
this.socketClosedEventHandler();
});
// React to end events:
this.sckt?.on("end", () => {
if (this.sckt?.allowHalfOpen) {
this.sckt?.end(() => {
this.socketClosedEventHandler();
});
}
});
// Timeout of socket:
this.sckt?.on("timeout", () => {
this.sckt?.end(() => {
this.socketClosedEventHandler();
});
});
}
catch (error) {
console.error(`initSocketAsync inner catch: ${JSON.stringify(error)}`);
reject(error);
}
});
}
else {
return Promise.resolve();
}
}
catch (error) {
console.error(`initSocketAsync outer catch: ${JSON.stringify(error)}`);
return Promise.reject(error);
}
}
socketClosedEventHandler() {
// Socket has been closed -> clean up everything
this.stopKeepAlive();
this.klfProtocol = undefined;
this.sckt = undefined;
}
checkServerIdentity(host, cert) {
if (cert.fingerprint === this.fingerprint)
return undefined;
else
return (0, tls_1.checkServerIdentity)(host, cert);
}
}
exports.Connection = Connection;
//# sourceMappingURL=connection.js.map