@pajn/node-tradfri-client
Version:
Library to talk to IKEA Trådfri Gateways without external binaries
948 lines (947 loc) • 54.4 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TradfriClient = void 0;
// load external modules
const events_1 = require("events");
const node_coap_client_1 = require("node-coap-client");
// load internal modules
const async_1 = require("alcalzone-shared/async");
const typeguards_1 = require("alcalzone-shared/typeguards");
const deferred_promise_1 = require("alcalzone-shared/deferred-promise");
const accessory_1 = require("./lib/accessory");
const array_extensions_1 = require("./lib/array-extensions");
const endpoints_1 = require("./lib/endpoints");
const gatewayDetails_1 = require("./lib/gatewayDetails");
const group_1 = require("./lib/group");
const logger_1 = require("./lib/logger");
const notification_1 = require("./lib/notification");
const scene_1 = require("./lib/scene");
const tradfri_error_1 = require("./lib/tradfri-error");
const utils_1 = require("./lib/utils");
const watcher_1 = require("./lib/watcher");
class TradfriClient extends events_1.EventEmitter {
// tslint:enable:unified-signatures
constructor(hostname, optionsOrLogger) {
super();
this.hostname = hostname;
/** dictionary of CoAP observers */
this.observedPaths = [];
/** dictionary of known devices */
this.devices = {};
/** dictionary of known groups */
this.groups = {};
/** Options regarding IPSO objects and serialization */
this.ipsoOptions = {};
/** A dictionary of the observer callbacks. Used to restore it after a soft reset */
this.rememberedObserveCallbacks = new Map();
// This avoids bugs when JS users don't pass a string
if (typeof hostname !== "string")
throw new Error("The hostname must be a string.");
this.requestBase = `coaps://${hostname}:5684/`;
if (typeof optionsOrLogger === "function") {
// Legacy version: 2nd parameter is a logger
logger_1.setCustomLogger(optionsOrLogger);
}
else if (typeof optionsOrLogger === "object") {
if (optionsOrLogger.customLogger != null)
logger_1.setCustomLogger(optionsOrLogger.customLogger);
if (optionsOrLogger.useRawCoAPValues === true)
this.ipsoOptions.skipValueSerializers = true;
if (optionsOrLogger.watchConnection != null && optionsOrLogger.watchConnection !== false) {
// true simply means "use default options" => don't pass a 2nd argument
const watcherOptions = optionsOrLogger.watchConnection === true ? undefined : optionsOrLogger.watchConnection;
this.watcher = new watcher_1.ConnectionWatcher(this, watcherOptions);
// in the first iteration of this feature, just pass all events through
const eventNames = [
"ping succeeded", "ping failed",
"connection alive", "connection lost",
"gateway offline",
"reconnecting",
"give up",
];
for (const event of eventNames) {
this.watcher.on(event, (...args) => this.emit(event, ...args));
}
}
}
}
/**
* Connect to the gateway
* @param identity A previously negotiated identity.
* @param psk The pre-shared key belonging to the identity.
*/
connect(identity, psk) {
return __awaiter(this, void 0, void 0, function* () {
const maxAttempts = (this.watcher != null && this.watcher.options.reconnectionEnabled) ?
this.watcher.options.maximumConnectionAttempts :
1;
const interval = this.watcher && this.watcher.options.connectionInterval;
const backoffFactor = this.watcher && this.watcher.options.failedConnectionBackoffFactor;
let lastFailureReason;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 0) {
// If the reconnection is not enabled, we don't hit this branch,
// so interval and backoffFactor are defined
const nextTimeout = Math.round(interval * Math.pow(backoffFactor, Math.min(5, attempt - 1)));
logger_1.log(`retrying connection in ${nextTimeout} ms`, "debug");
yield async_1.wait(nextTimeout);
}
const connectionResult = yield this.tryToConnect(identity, psk);
switch (connectionResult) {
case true: {
// start connection watching
if (this.watcher != null)
this.watcher.start();
return true;
}
case "auth failed": throw new tradfri_error_1.TradfriError("The provided credentials are not valid. Please re-authenticate!", tradfri_error_1.TradfriErrorCodes.AuthenticationFailed);
case "timeout": {
// retry if allowed
this.emit("connection failed", attempt + 1, maxAttempts);
lastFailureReason = "timeout";
continue;
}
default: {
if (connectionResult instanceof Error) {
// If an unexpected error occured, we might fix it by retrying the connection
this.emit("connection failed", attempt + 1, maxAttempts);
// Therefore remember the error
lastFailureReason = new tradfri_error_1.TradfriError(`An unexpected error occured while connecting to the gateway: ${connectionResult.message}`, tradfri_error_1.TradfriErrorCodes.ConnectionFailed);
// Use the original stack, we only re-throw as another error type
lastFailureReason.stack = connectionResult.stack;
// retry the connection
continue;
}
else {
// We want to know about unexpected responses though
throw new tradfri_error_1.TradfriError(`An unexpected response was received while trying to connect to the gateway: ${connectionResult}`, tradfri_error_1.TradfriErrorCodes.ConnectionFailed);
}
}
}
}
// Control-flow analysis doesn't check the loop body
// lastFailureReason is definitely assigned here
// https://github.com/Microsoft/TypeScript/issues/27239
lastFailureReason = lastFailureReason;
if (lastFailureReason === "timeout") {
throw new tradfri_error_1.TradfriError(`The gateway did not respond ${maxAttempts === 1 ? "in time" : `after ${maxAttempts} tries`}.`, tradfri_error_1.TradfriErrorCodes.ConnectionTimedOut);
}
else {
lastFailureReason.message =
`Could not connect to the gateway${maxAttempts === 1 ? "" : ` after ${maxAttempts} tries`}:\n`
+ lastFailureReason.message;
throw lastFailureReason;
}
});
}
/**
* Try to establish a connection to the configured gateway.
* @param identity The DTLS identity to use
* @param psk The pre-shared key to use
* @returns true if the connection attempt was successful, otherwise false.
*/
tryToConnect(identity, psk) {
return __awaiter(this, void 0, void 0, function* () {
// initialize CoAP client
node_coap_client_1.CoapClient.reset();
node_coap_client_1.CoapClient.setSecurityParams(this.hostname, {
psk: { [identity]: psk },
});
// Work around a bug in IKEA gateway firmware 1.15.x
node_coap_client_1.CoapClient.setCompatOptions(this.hostname, {
resetAntiReplayWindowBeforeServerHello: true,
});
logger_1.log(`Attempting connection. Identity = ${identity}, psk = ${psk}`, "debug");
const result = yield node_coap_client_1.CoapClient.tryToConnect(this.requestBase);
if (result === true) {
logger_1.log("Connection successful", "debug");
// Store this information to automatically reconnect
this.identity = identity;
this.psk = psk;
}
else {
logger_1.log("Connection failed. Reason: " + result, "debug");
}
return result;
});
}
/**
* Negotiates a new identity and psk with the gateway to use for connections
* @param securityCode The security code that is printed on the gateway
* @returns The identity and psk to use for future connections. Store these!
* @throws TradfriError
*/
authenticate(securityCode) {
return __awaiter(this, void 0, void 0, function* () {
// first, check try to connect with the security code
logger_1.log("authenticate() > trying to connect with the security code", "debug");
switch (yield this.tryToConnect("Client_identity", securityCode)) {
case true: break; // all good
case "auth failed": throw new tradfri_error_1.TradfriError("The security code is wrong", tradfri_error_1.TradfriErrorCodes.AuthenticationFailed);
case "timeout": throw new tradfri_error_1.TradfriError("The gateway did not respond in time.", tradfri_error_1.TradfriErrorCodes.ConnectionTimedOut);
case "error": throw new tradfri_error_1.TradfriError("An unknown error occured while connecting to the gateway", tradfri_error_1.TradfriErrorCodes.ConnectionFailed);
}
// generate a new identity
const identity = `tradfri_${Date.now()}`;
logger_1.log(`authenticating with identity "${identity}"`, "debug");
// request creation of new PSK
let payload = JSON.stringify({ 9090: identity });
payload = Buffer.from(payload);
const response = yield this.swallowInternalCoapRejections(node_coap_client_1.CoapClient.request(`${this.requestBase}${endpoints_1.endpoints.gateway(endpoints_1.GatewayEndpoints.Authenticate)}`, "post", payload));
// check the response
if (response.code.toString() !== "2.01") {
// that didn't work, so the code is wrong
throw new tradfri_error_1.TradfriError(`unexpected response (${response.code.toString()}) to getPSK().`, tradfri_error_1.TradfriErrorCodes.AuthenticationFailed);
}
// the response is a buffer containing a JSON object as a string
// TODO: check when payload is defined and when not
const pskResponse = JSON.parse(response.payload.toString("utf8"));
const psk = pskResponse["9091"];
// Remember the code to automatically reconnect
this.securityCode = securityCode;
return { identity, psk };
});
}
/**
* @internal
* This is used by the connection watcher internalle - DO NOT USE!
*/
reconnectHandler() {
return __awaiter(this, void 0, void 0, function* () {
this.reset(true);
// Try to immediately reconnect
logger_1.log(`trying to reconnect`, "debug");
const result = yield this.tryToConnect(this.identity, this.psk);
if (result === "auth failed") {
if (this.securityCode) {
logger_1.log(`invalid credentials, trying to re-authenticate`, "debug");
({
identity: this.identity,
psk: this.psk
} = yield this.authenticate(this.securityCode));
yield this.tryToConnect(this.identity, this.psk);
}
else {
logger_1.log(`invalid credentials, cannot reconnect`, "debug");
return false;
}
}
return true;
});
}
/**
* Observes a resource at the given url and calls the callback when the information is updated.
* Prefer the specialized versions if possible.
* @param path The path of the resource
* @param callback The callback to be invoked when the resource updates
* @returns true if the observer was set up, false otherwise (e.g. if it already exists)
*/
observeResource(path, callback) {
return __awaiter(this, void 0, void 0, function* () {
// check if we are already observing this resource
const observerUrl = this.getObserverUrl(path);
if (this.observedPaths.indexOf(observerUrl) > -1)
return false;
// start observing
this.observedPaths.push(observerUrl);
// and remember the callback to restore it after a soft-reset
this.rememberedObserveCallbacks.set(observerUrl, callback);
yield this.swallowInternalCoapRejections(node_coap_client_1.CoapClient.observe(observerUrl, "get", callback));
return true;
});
}
getObserverUrl(path) {
path = normalizeResourcePath(path);
return path.startsWith(this.requestBase) ? path : `${this.requestBase}${path}`;
}
/**
* Checks if a resource is currently being observed
* @param path The path of the resource
*/
isObserving(path) {
const observerUrl = this.getObserverUrl(path);
return this.observedPaths.indexOf(observerUrl) > -1;
}
/**
* Stops observing a resource that is being observed through `observeResource`
* Use the specialized version of this method for observers that were set up with the specialized versions of `observeResource`
* @param path The path of the resource
*/
stopObservingResource(path) {
// remove observer
const observerUrl = this.getObserverUrl(path);
const index = this.observedPaths.indexOf(observerUrl);
if (index === -1)
return;
node_coap_client_1.CoapClient.stopObserving(observerUrl);
this.observedPaths.splice(index, 1);
this.rememberedObserveCallbacks.delete(observerUrl);
}
/**
* Resets the underlying CoAP client and clears all observers.
* @param preserveObservers Whether the active observers should be remembered to restore them later
*/
reset(preserveObservers = false) {
node_coap_client_1.CoapClient.reset();
this.observedPaths = [];
if (!preserveObservers)
this.rememberedObserveCallbacks.clear();
}
/**
* Closes the underlying CoAP client and clears all observers.
*/
destroy() {
if (this.watcher != null)
this.watcher.stop();
this.reset();
}
/**
* Restores all previously remembered observers with their original callbacks
* Call this AFTER a dead connection was restored
*/
restoreObservers() {
return __awaiter(this, void 0, void 0, function* () {
logger_1.log("restoring previously used observers", "debug");
let devicesRestored = false;
const devicesPath = this.getObserverUrl(endpoints_1.endpoints.devices);
let groupsAndScenesRestored = false;
const groupsPath = this.getObserverUrl(endpoints_1.endpoints.groups);
const scenesPath = this.getObserverUrl(endpoints_1.endpoints.scenes);
for (const [path, callback] of this.rememberedObserveCallbacks.entries()) {
if (path.indexOf(devicesPath) > -1) {
if (!devicesRestored) {
// restore all device observers (with a new callback)
logger_1.log("restoring device observers", "debug");
yield this.observeDevices();
devicesRestored = true;
}
}
else if (path.indexOf(groupsPath) > -1 || path.indexOf(scenesPath) > -1) {
if (!groupsAndScenesRestored) {
// restore all group and scene observers (with a new callback)
logger_1.log("restoring groups and scene observers", "debug");
yield this.observeGroupsAndScenes();
groupsAndScenesRestored = true;
}
}
else {
// restore all custom observers with the old callback
logger_1.log(`restoring custom observer for path "${path}"`, "debug");
yield this.observeResource(path, callback);
}
}
});
}
/**
* Sets up an observer for all devices
* @returns A promise that resolves when the information about all devices has been received.
*/
observeDevices() {
return __awaiter(this, void 0, void 0, function* () {
if (this.isObserving(endpoints_1.endpoints.devices))
return;
this.observeDevicesPromise = deferred_promise_1.createDeferredPromise();
// We have a timing problem here, as the observeGatewayPromise might be
// rejected in the callback and set to null. Therefore return it before
// starting the observation
void this.observeResource(endpoints_1.endpoints.devices, (resp) => void this.observeDevices_callback(resp)).catch(e => {
// pass errors through
if (!!this.observeDevicesPromise)
this.observeDevicesPromise.reject(e);
});
return this.observeDevicesPromise;
});
}
observeDevices_callback(response) {
return __awaiter(this, void 0, void 0, function* () {
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeDevices()`, false))
return;
}
const newDevices = parsePayload(response);
logger_1.log(`got all devices: ${JSON.stringify(newDevices)}`);
// get old keys as int array
const oldKeys = Object.keys(this.devices).map(k => +k).sort();
// get new keys as int array
const newKeys = newDevices.sort();
// translate that into added and removed devices
const addedKeys = array_extensions_1.except(newKeys, oldKeys);
logger_1.log(`adding devices with keys ${JSON.stringify(addedKeys)}`, "debug");
const observeDevicePromises = newKeys.map(id => {
const handleResponse = (resp) => {
// first, try to parse the device information
const result = this.observeDevice_callback(id, resp);
// if we are still waiting to confirm the `observeDevices` call,
// check if we have received information about all devices
if (this.observeDevicesPromise != null) {
if (result) {
if (newKeys.every(k => k in this.devices)) {
this.observeDevicesPromise.resolve();
this.observeDevicesPromise = undefined;
}
}
else {
this.observeDevicesPromise.reject(new Error(`The device with the id ${id} could not be observed`));
this.observeDevicesPromise = undefined;
}
}
};
return this.observeResource(`${endpoints_1.endpoints.devices}/${id}`, handleResponse);
});
yield Promise.all(observeDevicePromises);
const removedKeys = array_extensions_1.except(oldKeys, newKeys);
logger_1.log(`removing devices with keys ${JSON.stringify(removedKeys)}`, "debug");
for (const id of removedKeys) {
// remove device from dictionary
delete this.devices[id];
// remove observer
this.stopObservingResource(`${endpoints_1.endpoints.devices}/${id}`);
// and notify all listeners about the removal
this.emit("device removed", id);
}
});
}
stopObservingDevices() {
const pathPrefix = `${this.requestBase}${endpoints_1.endpoints.devices}`;
// remove all observers pointing to a device related endpoint
this.observedPaths
.filter(p => p.startsWith(pathPrefix))
.forEach(p => this.stopObservingResource(p));
}
// gets called whenever "get /15001/<instanceId>" updates
// returns true when the device was received successfully
observeDevice_callback(instanceId, response) {
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeDevice(${instanceId})`))
return false;
}
const result = parsePayload(response);
logger_1.log(`observeDevice > ` + JSON.stringify(result), "debug");
// parse device info
const accessory = new accessory_1.Accessory(this.ipsoOptions)
.parse(result)
.fixBuggedProperties()
.createProxy();
// remember the device object, so we can later use it as a reference for updates
// store a clone, so we don't have to care what the calling library does
this.devices[instanceId] = accessory.clone();
// and notify all listeners about the update
this.emit("device updated", accessory.link(this));
return true;
}
/**
* Sets up an observer for all groups and scenes
* @returns A promise that resolves when the information about all groups and scenes has been received.
*/
observeGroupsAndScenes() {
return __awaiter(this, void 0, void 0, function* () {
if (this.isObserving(endpoints_1.endpoints.groups))
return;
this.observeGroupsPromise = deferred_promise_1.createDeferredPromise();
// We have a timing problem here, as the observeGatewayPromise might be
// rejected in the callback and set to null. Therefore return it before
// starting the observation
void this.observeResource(endpoints_1.endpoints.groups, (resp) => void this.observeGroups_callback(resp)).catch(e => {
// pass errors through
if (!!this.observeGroupsPromise)
this.observeGroupsPromise.reject(e);
});
return this.observeGroupsPromise;
});
}
// gets called whenever "get /15004" updates
observeGroups_callback(response) {
return __awaiter(this, void 0, void 0, function* () {
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeGroups()`, false))
return;
}
const newGroups = parsePayload(response);
logger_1.log(`got all groups: ${JSON.stringify(newGroups)}`);
// get old keys as int array
const oldKeys = Object.keys(this.groups).map(k => +k).sort();
// get new keys as int array
const newKeys = newGroups.sort();
// translate that into added and removed devices
const addedKeys = array_extensions_1.except(newKeys, oldKeys);
logger_1.log(`adding groups with keys ${JSON.stringify(addedKeys)}`, "debug");
// create a deferred promise for each group, so we can wait for them to be fulfilled
if (this.observeGroupsPromise != null && this.observeScenesPromises == null) {
this.observeScenesPromises = new Map(newKeys.map(id => [id, deferred_promise_1.createDeferredPromise()]));
}
const observeGroupPromises = newKeys.map(id => {
const handleResponse = (resp) => {
// first, try to parse the device information
const result = this.observeGroup_callback(id, resp);
// if we are still waiting to confirm the `observeDevices` call,
// check if we have received information about all devices
if (this.observeGroupsPromise != null) {
if (result) {
if (newKeys.every(k => k in this.groups)) {
// once we have all groups, wait for all scenes to be received
Promise
.all(this.observeScenesPromises.values())
.then(() => {
this.observeGroupsPromise.resolve();
this.observeGroupsPromise = undefined;
this.observeScenesPromises = undefined;
})
.catch(reason => {
// in some cases, the promises can be null here
if (this.observeGroupsPromise != null) {
this.observeGroupsPromise.reject(reason);
}
this.observeGroupsPromise = undefined;
this.observeScenesPromises = undefined;
});
}
}
else {
this.observeGroupsPromise.reject(new Error(`The group with the id ${id} could not be observed`));
this.observeGroupsPromise = undefined;
}
}
};
return this.observeResource(`${endpoints_1.endpoints.groups}/${id}`, handleResponse);
});
yield Promise.all(observeGroupPromises);
const removedKeys = array_extensions_1.except(oldKeys, newKeys);
logger_1.log(`removing groups with keys ${JSON.stringify(removedKeys)}`, "debug");
removedKeys.forEach((id) => {
// remove group from dictionary
delete this.groups[id];
// remove observers
this.stopObservingGroup(id);
// and notify all listeners about the removal
this.emit("group removed", id);
});
});
}
stopObservingGroups() {
for (const id of Object.keys(this.groups)) {
this.stopObservingGroup(+id);
}
this.stopObservingResource(endpoints_1.endpoints.groups);
}
stopObservingGroup(instanceId) {
this.stopObservingResource(`${endpoints_1.endpoints.groups}/${instanceId}`);
const scenesPrefix = this.getObserverUrl(`${endpoints_1.endpoints.scenes}/${instanceId}`);
const pathsToDelete = this.observedPaths.filter(path => path.startsWith(scenesPrefix));
for (const path of pathsToDelete) {
this.stopObservingResource(path);
}
}
// gets called whenever "get /15004/<instanceId>" updates
observeGroup_callback(instanceId, response) {
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeGroup(${instanceId})`))
return false;
}
const result = parsePayload(response);
// parse group info
const group = new group_1.Group(this.ipsoOptions)
.parse(result)
.fixBuggedProperties()
.createProxy();
// remember the group object, so we can later use it as a reference for updates
let groupInfo;
if (!(instanceId in this.groups)) {
// if there's none, create one
this.groups[instanceId] = {
group: undefined,
scenes: {},
};
}
groupInfo = this.groups[instanceId];
// remember the group object, so we can later use it as a reference for updates
// store a clone, so we don't have to care what the calling library does
groupInfo.group = group.clone();
// notify all listeners about the update
this.emit("group updated", group.link(this));
// load scene information
this.observeResource(`${endpoints_1.endpoints.scenes}/${instanceId}`, (resp) => void this.observeScenes_callback(instanceId, resp));
return true;
}
// gets called whenever "get /15005/<groupId>" updates
observeScenes_callback(groupId, response) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeScenes(${groupId})`, false))
return;
}
const groupInfo = this.groups[groupId];
const newScenes = parsePayload(response);
logger_1.log(`got all scenes in group ${groupId}: ${JSON.stringify(newScenes)}`);
// get old keys as int array
const oldKeys = Object.keys(groupInfo.scenes).map(k => +k).sort();
// get new keys as int array
const newKeys = newScenes.sort();
// translate that into added and removed devices
const addedKeys = array_extensions_1.except(newKeys, oldKeys);
logger_1.log(`adding scenes with keys ${JSON.stringify(addedKeys)} to group ${groupId}`, "debug");
if (newKeys.length > 0) {
const observeScenePromises = newKeys.map(id => {
const handleResponse = (resp) => {
// first, try to parse the device information
const result = this.observeScene_callback(groupId, id, resp);
// if we are still waiting to confirm the `observeDevices` call,
// check if we have received information about all devices
if (this.observeScenesPromises != null) {
const scenePromise = this.observeScenesPromises.get(groupId);
if (result) {
if (newKeys.every(k => k in groupInfo.scenes)) {
if (!!scenePromise)
scenePromise.resolve();
}
}
else {
if (!!scenePromise)
scenePromise.reject(new Error(`The scene with the id ${id} could not be observed`));
}
}
};
return this.observeResource(`${endpoints_1.endpoints.scenes}/${groupId}/${id}`, handleResponse);
});
yield Promise.all(observeScenePromises);
}
else {
(_b = (_a = this.observeScenesPromises) === null || _a === void 0 ? void 0 : _a.get(groupId)) === null || _b === void 0 ? void 0 : _b.resolve();
}
const removedKeys = array_extensions_1.except(oldKeys, newKeys);
logger_1.log(`removing scenes with keys ${JSON.stringify(removedKeys)} from group ${groupId}`, "debug");
removedKeys.forEach(id => {
// remove scene from dictionary
delete groupInfo.scenes[id];
// remove observers
this.stopObservingResource(`${endpoints_1.endpoints.scenes}/${groupId}/${id}`);
// and notify all listeners about the removal
this.emit("scene removed", groupId, id);
});
});
}
// gets called whenever "get /15005/<groupId>/<instanceId>" updates
observeScene_callback(groupId, instanceId, response) {
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeScene(${groupId}, ${instanceId})`))
return false;
}
const result = parsePayload(response);
// parse scene info
const scene = new scene_1.Scene(this.ipsoOptions)
.parse(result)
.fixBuggedProperties()
.createProxy();
// remember the scene object, so we can later use it as a reference for updates
// store a clone, so we don't have to care what the calling library does
this.groups[groupId].scenes[instanceId] = scene.clone();
// and notify all listeners about the update
this.emit("scene updated", groupId, scene.link(this));
return true;
}
/**
* Sets up an observer for the gateway
* @returns A promise that resolves when the gateway information has been received for the first time
*/
observeGateway() {
return __awaiter(this, void 0, void 0, function* () {
if (this.isObserving(endpoints_1.endpoints.gateway(endpoints_1.GatewayEndpoints.Details)))
return;
this.observeGatewayPromise = deferred_promise_1.createDeferredPromise();
// We have a timing problem here, as the observeGatewayPromise might be
// rejected in the callback and set to null. Therefore return it before
// starting the observation
void this.observeResource(endpoints_1.endpoints.gateway(endpoints_1.GatewayEndpoints.Details), (resp) => void this.observeGateway_callback(resp)).catch(e => {
// pass errors through
if (!!this.observeGatewayPromise)
this.observeGatewayPromise.reject(e);
});
return this.observeGatewayPromise;
});
}
observeGateway_callback(response) {
return __awaiter(this, void 0, void 0, function* () {
logger_1.log(`received response to observeGateway(): ${JSON.stringify(response, null, 4)}`);
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeGateway()`, false)) {
logger_1.log(` => not successful`);
if (this.observeGatewayPromise != null) {
this.observeGatewayPromise.reject(new Error(`The gateway could not be observed`));
this.observeGatewayPromise = undefined;
}
return;
}
}
logger_1.log(`got gateway information`);
const result = parsePayload(response);
// parse gw info
const gateway = new gatewayDetails_1.GatewayDetails(this.ipsoOptions)
.parse(result)
.fixBuggedProperties()
.createProxy();
// and notify all listeners about the update
this.emit("gateway updated", gateway.link(this));
if (this.observeGatewayPromise != null) {
this.observeGatewayPromise.resolve();
this.observeGatewayPromise = undefined;
}
});
}
stopObservingGateway() {
this.stopObservingResource(`${this.requestBase}${endpoints_1.endpoints.gateway(endpoints_1.GatewayEndpoints.Details)}`);
}
/**
* Sets up an observer for the notification
* @returns A promise that resolves when a notification has been received for the first time
*/
observeNotifications() {
return __awaiter(this, void 0, void 0, function* () {
if (this.isObserving(endpoints_1.endpoints.notifications))
return;
this.observeNotificationsPromise = deferred_promise_1.createDeferredPromise();
// We have a timing problem here, as the observeNotificationsPromise might be
// rejected in the callback and set to null. Therefore return it before
// starting the observation
void this.observeResource(endpoints_1.endpoints.notifications, (resp) => void this.observeNotifications_callback(resp)).catch(e => {
// pass errors through
if (!!this.observeNotificationsPromise)
this.observeNotificationsPromise.reject(e);
});
return this.observeNotificationsPromise;
});
}
observeNotifications_callback(response) {
return __awaiter(this, void 0, void 0, function* () {
logger_1.log(`received response to observeNotifications(): ${JSON.stringify(response, null, 4)}`);
// check response code
if (response.code.toString() !== "2.05") {
if (!this.handleNonSuccessfulResponse(response, `observeNotifications()`, false)) {
logger_1.log(` => not successful`);
if (this.observeNotificationsPromise != null) {
this.observeNotificationsPromise.reject(new Error(`The notifications could not be observed`));
this.observeNotificationsPromise = undefined;
}
return;
}
}
const notifications = parsePayload(response);
// emit all received notifications
for (const not of notifications) {
const notification = new notification_1.Notification().parse(not);
switch (notification.event) {
case notification_1.NotificationTypes.Reboot:
this.emit("rebooting", notification_1.GatewayRebootReason[notification.details.reason]);
break;
case notification_1.NotificationTypes.LossOfInternetConnectivity:
// the notification stands for connection loss, but we report if it's available
this.emit("internet connectivity changed", !notification.isActive);
break;
case notification_1.NotificationTypes.NewFirmwareAvailable: {
const details = notification.details;
this.emit("firmware update available", details.releaseNotes, gatewayDetails_1.UpdatePriority[details.priority]);
break;
}
// ignore all other notifications, we have no idea what they do
// TODO: find out!
}
}
if (this.observeNotificationsPromise != null) {
this.observeNotificationsPromise.resolve();
this.observeNotificationsPromise = undefined;
}
});
}
stopObservingNotifications() {
this.stopObservingResource(`${this.requestBase}${endpoints_1.endpoints.notifications}`);
}
// =================================================================================
// =================================================================================
// =================================================================================
/**
* Handles a non-successful response, e.g. by error logging
* @param resp The response with a code that indicates an unsuccessful request
* @param context Some logging context to identify where the error comes from
* @returns true if the calling method may proceed, false if it should break
*/
handleNonSuccessfulResponse(resp, context, ignore404 = true) {
// check response code
const code = resp.code.toString();
const payload = parsePayload(resp) || "";
if (code === "4.04" && ignore404) {
// not found
// An observed resource has been deleted - all good
// The observer will be removed soon
return false;
}
else {
this.emit("error", new Error(`unexpected response (${code}) to ${context}: ${payload}`));
return false;
}
}
/**
* Pings the gateway to check if it is alive
* @param timeout - (optional) Timeout in ms, after which the ping is deemed unanswered. Default: 5000ms
*/
ping(timeout) {
return node_coap_client_1.CoapClient.ping(this.requestBase, timeout);
}
/**
* Updates a device object on the gateway
* @param accessory The device to be changed
* @returns true if a request was sent, false otherwise
*/
updateDevice(accessory) {
// retrieve the original as a reference for serialization
if (!(accessory.instanceId in this.devices)) {
throw new Error(`The device with id ${accessory.instanceId} is not known and cannot be update!`);
}
const original = this.devices[accessory.instanceId];
return this.updateResource(`${endpoints_1.endpoints.devices}/${accessory.instanceId}`, accessory, original);
}
/**
* Updates a group object on the gateway
* @param group The group to be changed
* @returns true if a request was sent, false otherwise
*/
updateGroup(group) {
// retrieve the original as a reference for serialization
if (!(group.instanceId in this.groups)) {
throw new Error(`The group with id ${group.instanceId} is not known and cannot be update!`);
}
const original = this.groups[group.instanceId].group;
return this.updateResource(`${endpoints_1.endpoints.groups}/${group.instanceId}`, group, original);
}
/**
* Updates a generic resource on the gateway
* @param path The path where the resource is located
* @param newObj The new object for the resource
* @param reference The reference value to calculate the diff
* @returns true if a request was sent, false otherwise
*/
updateResource(path, newObj, reference) {
return __awaiter(this, void 0, void 0, function* () {
// ensure the ipso options were not lost on the user side
newObj.options = this.ipsoOptions;
logger_1.log(`updateResource(${path}) > comparing ${JSON.stringify(newObj)} with the reference ${JSON.stringify(reference)}`, "debug");
const serializedObj = newObj.serialize(reference);
// If the serialized object contains no properties, we don't need to send anything
if (!serializedObj || Object.keys(serializedObj).length === 0) {
logger_1.log(`updateResource(${path}) > empty object, not sending any payload`, "debug");
return false;
}
// get the payload
let payload = JSON.stringify(serializedObj);
logger_1.log(`updateResource(${path}) > sending payload: ${payload}`, "debug");
payload = Buffer.from(payload);
yield this.swallowInternalCoapRejections(node_coap_client_1.CoapClient.request(`${this.requestBase}${path}`, "put", payload));
return true;
});
}
/**
* Sets some properties on a group
* @param group The group to be updated
* @param operation The properties to be set
* @param force Include all properties of operation in the payload, even if the values are unchanged
* @returns true if a request was sent, false otherwise
*/
operateGroup(group, operation, force = false) {
// Ensure that the operation is an object (GH #323)
if (!typeguards_1.isObject(operation)) {
throw new Error(`The parameter "operation" must be an object!`);
}
const newGroup = group.clone().merge(operation, true /* all props */);
const reference = group.clone();
if (force) {
// to force the properties being sent, we need to reset them on the reference
reference.merge(utils_1.invertOperation(operation), true);
}
return this.updateResource(`${endpoints_1.endpoints.groups}/${group.instanceId}`, newGroup, reference);
}
/**
* Sets some properties on a lightbulb
* @param accessory The parent accessory of the lightbulb
* @param operation The properties to be set
* @param force Include all properties of operation in the payload, even if the values are unchanged
* @returns true if a request was sent, false otherwise
*/
operateLight(accessory, operation, force = false) {
if (accessory.type !== accessory_1.AccessoryTypes.lightbulb) {
throw new Error(`The parameter "accessory" must be a lightbulb!`);
}
// Ensure that the operation is an object (GH #323)
if (!typeguards_1.isObject(operation)) {
throw new Error(`The parameter "operation" must be an object!`);
}
const newAccessory = accessory.clone();
newAccessory.lightList[0].merge(operation, true);
const reference = accessory.clone();
if (force) {
// to force the properties being sent, we need to reset them on the reference
reference.lightList[0].merge(utils_1.invertOperation(operation), true);
}
return this.updateResource(`${endpoints_1.endpoints.devices}/${accessory.instanceId}`, newAccessory, reference);
}
/**
* Sets some properties on a plug
* @param accessory The parent accessory of the plug
* @param operation The properties to be set
* @param force Include all properties of operation in the payload, even if the values are unchanged
* @returns true if a request was sent, false otherwise
*/
operatePlug(accessory, operation, force = false) {
if (accessory.type !== accessory_1.AccessoryTypes.plug) {
throw new Error(`The parameter "accessory" must be a plug!`);
}
// Ensure that the operation is an object (GH #323)
if (!typeguards_1.isObject(operation)) {
throw new Error(`The parameter "operation" must be an object!`);
}
const newAccessory = accessory.clone();
newAccessory.plugList[0].merge(operation);
const reference = accessory.clone();
if (force) {
// to force the properties being sent, we need to reset them on the reference
reference.plugList[0].merge(utils_1.invertOperation(operation), true);
}
return this.updateResource(`${endpoints_1.endpoints.devices}/${accessory.instanceId}`, newAccessory, reference);
}
/**
* Sets some properties on a blind
* @param accessory The parent accessory of the blind
* @param operation The properties to be set
* @param force Include all properties of operation in the payload, even if the values are unchanged
* @returns true if a request was sent, false otherwise
*/
operateBlind(accessory, operation, force = false) {
if (accessory.type !== accessory_1.AccessoryTypes.blind) {
throw new Error(`The parameter "accessory" must be a blind!`);
}
// Ensure that the operation is an object (GH #323)
if (!typeguards_1.isObject(operation)) {
throw new Error(`The parameter "operation" must be an object!`);
}
const newAccessory = accessory.clone();
// Merge all properties, because trigger might not be defined
newAccessory.blindList[0].merge(operation, true);
const reference = accessory.clone();
if (force) {
// to force the properties being sent, we need to reset them on the reference
reference.blindList[0].merge(utils_1.invertOperation(operation), true);
}
return this.updateResource(`${endpoints_1.endpoints.devices}/${accessory.instanceId}`, newAccessory, reference);