node-red-contrib-deconz
Version:
deCONZ connectivity nodes for node-red
977 lines (906 loc) • 30.7 kB
JavaScript
const got = require("got");
const dotProp = require("dot-prop");
const DeviceList = require("../src/runtime/DeviceList");
const DeconzAPI = require("../src/runtime/DeconzAPI");
const DeconzSocket = require("../src/runtime/DeconzSocket");
const ConfigMigration = require("../src/migration/ConfigMigration");
const Query = require("../src/runtime/Query");
const Utils = require("../src/runtime/Utils");
const {
setIntervalAsync,
clearIntervalAsync,
} = require("set-interval-async/fixed");
module.exports = function (RED) {
class ServerNode {
constructor(config) {
RED.nodes.createNode(this, config);
let node = this;
node.config = config;
node.state = {
ready: false,
startFailed: false,
isStopping: false,
pooling: {
isValid: false,
reachable: false,
discoverProcessRunning: false,
lastPooling: undefined,
failCount: 0,
errorTriggered: false,
},
websocket: {
isValid: false,
reachable: false,
lastConnected: undefined,
lastEvent: undefined,
lastDisconnected: undefined,
eventCount: 0,
},
};
// Config migration
let configMigration = new ConfigMigration(
"deconz-server",
node.config,
this
);
let migrationResult = configMigration.applyMigration(node.config, node);
if (
Array.isArray(migrationResult.errors) &&
migrationResult.errors.length > 0
) {
migrationResult.errors.forEach((error) =>
node.error(
`Error with migration of node ${node.type} with id ${node.id}`,
error
)
);
}
node.device_list = new DeviceList();
node.api = new DeconzAPI({
ip: node.config.ip,
port: node.config.port,
apikey: node.credentials.secured_apikey,
});
// Example : ["ea9cd132.08f36"]
node.nodesWithQuery = [];
node.nodesEvent = [];
node.nodesByDevicePath = {};
node.setMaxListeners(255);
node.refreshDiscoverTimer = null;
node.refreshDiscoverInterval =
node.config.polling >= 3 ? node.config.polling * 1000 : 15000;
node.on("close", () => this.onClose());
(async () => {
//TODO make the delay configurable
await Utils.sleep(1500);
let pooling = async () => {
let result = await node.discoverDevices({ forceRefresh: true });
if (result === true) {
if (node.state.pooling.isValid === false) {
node.state.pooling.isValid = true;
node.state.ready = true;
this.setupDeconzSocket(node);
node.emit("onStart");
}
node.state.pooling.reachable = true;
node.state.pooling.lastPooling = Date.now();
node.state.pooling.failCount = 0;
if (node.state.pooling.errorTriggered === true) {
node.log(`discoverDevices: Connected to deconz API.`);
}
node.state.pooling.errorTriggered = false;
} else if (node.state.pooling.isValid === false) {
if (node.state.startFailed) return;
node.state.pooling.failCount++;
let code = RED._(
"node-red-contrib-deconz/server:status.deconz_not_reachable"
);
let reason =
"discoverDevices: Can't connect to deconz API since starting. " +
"Please check server configuration.";
if (node.state.pooling.errorTriggered === false) {
node.state.pooling.errorTriggered = true;
await node.propagateErrorNews(code, reason, true);
}
if (node.state.pooling.failCount % 4 === 2) {
node.error(reason);
}
} else {
node.state.pooling.failCount++;
let code = RED._(
"node-red-contrib-deconz/server:status.deconz_not_reachable"
);
let reason = "discoverDevices: Can't connect to deconz API.";
if (node.state.pooling.errorTriggered === false) {
node.state.pooling.errorTriggered = true;
await node.propagateErrorNews(code, reason, true);
}
if (node.state.pooling.failCount % 4 === 2) {
node.error(reason);
}
}
};
await pooling();
if (node.state.startFailed !== true) {
this.refreshDiscoverTimer = setIntervalAsync(
pooling,
node.refreshDiscoverInterval
);
}
})()
.then()
.catch((error) => {
node.state.ready = false;
node.error("Deconz Server node error " + error.toString());
console.log("Error from server node #1", error);
});
}
setupDeconzSocket(node) {
node.socket = new DeconzSocket({
hostname: node.config.ip,
port: node.config.ws_port,
secure: node.config.secure || false,
});
node.socket.on("open", () => {
node.log(`WebSocket opened`);
node.state.websocket.isValid = true;
node.state.websocket.reachable = true;
node.state.websocket.lastConnected = Date.now();
// This is used only on websocket reconnect, not the initial connection.
if (node.state.ready) node.propagateStartNews();
});
node.socket.on("message", (payload) => this.onSocketMessage(payload));
node.socket.on("error", (err) => {
let node = this;
node.state.websocket.reachable = false;
node.state.websocket.lastDisconnected = Date.now();
// don't bother the user unless there's a reason or if the server is stopping.
if (err && node.state.isStopping === false) {
node.error(`WebSocket error: ${err}`);
}
});
node.socket.on("close", (code, reason) => {
node.state.websocket.reachable = false;
node.state.websocket.lastDisconnected = Date.now();
// don't bother the user unless there's a reason or if the server is stopping.
if (reason && node.state.isStopping === false) {
node.warn(`WebSocket disconnected: ${code} - ${reason}`);
}
if (node.state.ready) node.propagateErrorNews(code, reason);
});
node.socket.on("pong-timeout", () => {
let node = this;
node.state.websocket.reachable = false;
node.state.websocket.lastDisconnected = Date.now();
node.warn("WebSocket connection timeout, reconnecting");
});
node.socket.on("unauthorized", () => () => {
let node = this;
node.state.websocket.isValid = false;
node.state.websocket.lastDisconnected = Date.now();
node.warn("WebSocket authentication failed");
});
}
async discoverDevices(opt) {
let node = this;
let options = Object.assign(
{
forceRefresh: false,
callback: () => {},
},
opt
);
if (
options.forceRefresh === false ||
node.state.pooling.discoverProcessRunning === true
) {
//node.log('discoverDevices: Using cached devices');
return;
}
node.state.pooling.discoverProcessRunning = true;
try {
let mainConfig = await got(node.api.url.main(), {
retry: 1,
timeout: 2000,
}).json();
try {
let group0 = await got(
node.api.url.main() + node.api.url.groups.main(0),
{
retry: 1,
timeout: 2000,
}
).json();
node.device_list.all_group_real_id = group0.id;
mainConfig.groups["0"] = group0;
} catch (e) {
node.log(
`discoverDevices: Could not get group 0 ${e.toString()}. This should not happen, ` +
`please open an issue on https://github.com/dresden-elektronik/deconz-rest-plugin`
);
}
node.device_list.parse(mainConfig);
//node.log(`discoverDevices: Updated ${node.device_list.count}`);
node.state.pooling.discoverProcessRunning = false;
return true;
} catch (e) {
if (e.response !== undefined && e.response.statusCode === 403) {
node.state.startFailed = true;
let code = RED._(
"node-red-contrib-deconz/server:status.invalid_api_key"
);
let reason =
"discoverDevices: Can't use to deconz API, invalid api key. " +
"Please check server configuration.";
node.error(reason);
await node.propagateErrorNews(code, reason, true);
node.onClose();
}
//node.error(`discoverDevices: Can't connect to deconz API.`);
node.state.pooling.discoverProcessRunning = false;
return false;
}
}
async propagateStartNews(whitelistNodes) {
let node = this;
// Node with device selected
let filterMethod;
if (Array.isArray(whitelistNodes)) {
filterMethod = (id) => whitelistNodes.includes(id);
}
for (let [device_path, nodeIDs] of Object.entries(
node.nodesByDevicePath
)) {
if (filterMethod) nodeIDs = nodeIDs.filter(filterMethod);
node.propagateNews(nodeIDs, {
type: "start",
node_type: "device_path",
device: node.device_list.getDeviceByPath(device_path),
});
}
// Node with quety
for (let nodeID of node.nodesWithQuery) {
if (filterMethod && filterMethod(nodeID) === false) continue;
let target = RED.nodes.getNode(nodeID);
if (!target) {
node.warn(
"ERROR: cant get " +
nodeID +
" node for start news, removed from list NodeWithQuery"
);
node.unregisterNodeWithQuery(nodeID);
continue;
}
// TODO Cache JSONata expresssions ?
const querySrc = await new Promise((resolve, reject) => {
RED.util.evaluateJSONataExpression(
RED.util.prepareJSONataExpression(target.config.query, target),
{},
(err, value) => {
if (err) reject(err);
else resolve(value);
}
);
});
try {
let devices = node.device_list.getDevicesByQuery(querySrc);
if (devices.matched.length === 0) continue;
for (let device of devices.matched) {
node.propagateNews(nodeID, {
type: "start",
node_type: "query",
device: device,
});
}
} catch (e) {
node.status({
fill: "red",
shape: "dot",
text: "node-red-contrib-deconz/server:status.query_error",
});
node.error(
e.toString() +
"\nNode ID : " +
nodeID +
"\nQuery: " +
JSON.stringify(querySrc)
);
}
}
}
async propagateErrorNews(code, reason, isGlobalError = false) {
let node = this;
if (!reason) return;
if (node.state.ready === false) {
RED.nodes.eachNode((target) => {
if (
[
"deconz-input",
"deconz-battery",
"deconz-get",
"deconz-out",
"deconz-event",
].includes(target.type)
) {
let targetNode = RED.nodes.getNode(target.id);
if (targetNode)
targetNode.status({
fill: "red",
shape: "dot",
text: code,
});
}
});
return;
}
// Node with device selected
for (let [device_path, nodeIDs] of Object.entries(
node.nodesByDevicePath
)) {
node.propagateNews(nodeIDs, {
type: "error",
node_type: "device_path",
device: node.device_list.getDeviceByPath(device_path),
errorCode: code,
errorMsg: reason || "Unknown error",
isGlobalError,
});
}
// Node with quety
for (let nodeID of node.nodesWithQuery) {
let target = RED.nodes.getNode(nodeID);
if (!target) {
node.warn(
"ERROR: cant get " +
nodeID +
" node for error news, removed from list NodeWithQuery"
);
node.unregisterNodeWithQuery(nodeID);
continue;
}
// TODO Cache JSONata expresssions ?
const querySrc = await new Promise((resolve, reject) => {
RED.util.evaluateJSONataExpression(
RED.util.prepareJSONataExpression(target.config.query, target),
{},
(err, value) => {
if (err) reject(err);
else resolve(value);
}
);
});
try {
let devices = node.device_list.getDevicesByQuery(querySrc);
if (devices.matched.length === 0) continue;
for (let device of devices.matched) {
node.propagateNews(nodeID, {
type: "error",
node_type: "query",
device: device,
errorCode: code,
errorMsg: reason || "Unknown error",
isGlobalError,
});
}
} catch (e) {
node.status({
fill: "red",
shape: "dot",
text: "node-red-contrib-deconz/server:status.query_error",
});
node.error(
e.toString() +
"\nNode ID : " +
nodeID +
"\nQuery: " +
JSON.stringify(querySrc)
);
}
}
}
/**
*
* @param nodeIDs List of nodes [nodeID1, nodeID2]
* @param news Object what kind of news need to be sent
* {type: 'start|event|error', eventData:{}, errorCode: "", errorMsg: "", device: {}, changed: {}}
*/
propagateNews(nodeIDs, news) {
//TODO add the event type in the msg
let node = this;
// Make sure that we have node to send the message to
if (
nodeIDs === undefined ||
(Array.isArray(nodeIDs) && nodeIDs.length === 0)
)
return;
if (!Array.isArray(nodeIDs)) nodeIDs = [nodeIDs];
for (const nodeID of nodeIDs) {
let target = RED.nodes.getNode(nodeID);
// Check if device exist
if (news.device === undefined) {
target.handleDeconzEvent(
news.device,
[],
{},
{
errorEvent: true,
errorCode: "DEVICE_NOT_FOUND",
errorMsg: "Device not found, please check server configuration",
}
);
continue;
}
// If the target does not exist we remove it from the node list
if (!target) {
switch (news.node_type) {
case "device_path":
node.warn(
"ERROR: cant get " +
nodeID +
" node, removed from list nodesByDevicePath"
);
node.unregisterNodeByDevicePath(nodeID, news.device.device_path);
break;
case "query":
node.warn(
"ERROR: cant get " +
nodeID +
" node, removed from list nodesWithQuery"
);
node.unregisterNodeWithQuery(nodeID);
break;
case "event_node":
node.warn(
"ERROR: cant get " +
nodeID +
" node, removed from list nodesEvent"
);
node.unregisterEventNode(nodeID);
break;
}
return;
}
switch (news.type) {
case "start":
switch (target.type) {
case "deconz-input":
case "deconz-battery":
target.handleDeconzEvent(news.device, [], news.device, {
initialEvent: true,
});
break;
}
break;
case "event":
let dataParsed = news.eventData;
switch (dataParsed.t) {
case "event":
if (target.type === "deconz-event") {
target.handleDeconzEvent(
news.device,
news.changed,
dataParsed
);
target.status({
fill: "green",
shape: "dot",
text: RED._(
"node-red-contrib-deconz/server:status.event_count"
).replace(
"{{event_count}}",
node.state.websocket.eventCount
),
});
} else {
switch (dataParsed.e) {
case "added":
case "deleted":
node
.discoverDevices({
forceRefresh: true,
})
.then();
break;
case "changed":
if (
["deconz-input", "deconz-battery"].includes(target.type)
) {
target.handleDeconzEvent(
news.device,
news.changed,
dataParsed
);
} else {
node.warn(
"WTF this is used : We tried to send a msg to a non input node."
);
continue;
}
break;
case "scene-called":
if (target.type === "deconz-input") {
target.handleDeconzEvent(
news.device,
news.changed,
dataParsed
);
}
break;
default:
node.warn(
"Unknown event of type '" +
dataParsed.e +
"'. " +
JSON.stringify(dataParsed)
);
break;
}
}
break;
default:
node.warn(
"Unknown message of type '" +
dataParsed.t +
"'. " +
JSON.stringify(dataParsed)
);
break;
}
break;
case "error":
switch (target.type) {
case "deconz-input":
case "deconz-battery":
target.handleDeconzEvent(
news.device,
[],
{},
{
errorEvent: true,
errorCode: news.errorCode || "Unknown Error",
errorMsg: news.errorMsg || "Unknown Error",
}
);
break;
//TODO Implement other node types
}
break;
}
}
}
registerEventNode(nodeID) {
let node = this;
if (!node.nodesEvent.includes(nodeID)) node.nodesEvent.push(nodeID);
}
unregisterEventNode(nodeID) {
let node = this;
let index = node.nodesEvent.indexOf(nodeID);
if (index !== -1) node.nodesEvent.splice(index, 1);
}
registerNodeByDevicePath(nodeID, device_path) {
let node = this;
if (!(device_path in node.nodesByDevicePath))
node.nodesByDevicePath[device_path] = [];
if (!node.nodesByDevicePath[device_path].includes(nodeID))
node.nodesByDevicePath[device_path].push(nodeID);
}
unregisterNodeByDevicePath(nodeID, device_path) {
let node = this;
let index = node.nodesByDevicePath[device_path].indexOf(nodeID);
if (index !== -1) node.nodesByDevicePath[device_path].splice(index, 1);
}
registerNodeWithQuery(nodeID) {
let node = this;
if (!node.nodesWithQuery.includes(nodeID))
node.nodesWithQuery.push(nodeID);
}
unregisterNodeWithQuery(nodeID) {
let node = this;
let index = node.nodesWithQuery.indexOf(nodeID);
if (index !== -1) node.nodesWithQuery.splice(index, 1);
}
onClose() {
let node = this;
node.state.isStopping = true;
node.log("Shutting down deconz server node.");
(async () => {
if (node.refreshDiscoverTimer)
await clearIntervalAsync(node.refreshDiscoverTimer);
})()
.then(() => {
node.state.ready = false;
if (node.socket !== undefined) {
node.socket.close();
node.socket = undefined;
}
node.log("Deconz server stopped!");
node.emit("onClose");
})
.catch((error) => {
console.error("Error on Close", error);
});
}
updateDevice(device, dataParsed) {
let node = this;
let changed = [];
if (dotProp.has(dataParsed, "name")) {
device.name = dotProp.get(dataParsed, "name");
changed.push("name");
}
["config", "state"].forEach(function (key) {
if (dotProp.has(dataParsed, key)) {
Object.keys(dotProp.get(dataParsed, key)).forEach(function (
state_name
) {
let valuePath = key + "." + state_name;
let newValue = dotProp.get(dataParsed, valuePath);
let oldValue = dotProp.get(device, valuePath);
if (newValue !== oldValue) {
changed.push(`${key}.${state_name}`);
dotProp.set(device, valuePath, newValue);
}
});
}
});
return changed;
}
async onSocketMessage(dataParsed) {
let node = this;
node.state.websocket.lastEvent = Date.now();
node.state.websocket.isValid = true;
node.state.websocket.reachable = true;
if (node.state.websocket.eventCount >= Number.MAX_SAFE_INTEGER)
node.state.websocket.eventCount = 0;
node.state.websocket.eventCount++;
// Drop websocket msgs if the pooling don't work
if (node.state.pooling.isValid === false)
return node.error(
"Got websocket msg but the pooling is invalid. This should not happen."
);
// There is an issue with the id of all lights magic group. The valid ID is 0.
if (
dataParsed.r === "groups" &&
node.device_list.all_group_real_id !== undefined &&
dataParsed.id === node.device_list.all_group_real_id
)
dataParsed.id = "0";
node.emit("onSocketMessage", dataParsed); //Used by event node, TODO Really used ?
let device;
if (dataParsed.e === "scene-called") {
device = node.device_list.getDeviceByDomainID("groups", dataParsed.gid);
} else {
device = node.device_list.getDeviceByDomainID(
dataParsed.r,
dataParsed.id
);
}
// TODO handle case if device is not found
if (device === undefined)
return node.error(
"Got websocket msg but the device does not exist. " +
JSON.stringify(dataParsed)
);
let changed = node.updateDevice(device, dataParsed);
// Node with device selected
node.propagateNews(node.nodesByDevicePath[device.device_path], {
type: "event",
node_type: "device_path",
eventData: dataParsed,
device: device,
changed: changed,
});
// Node with quety
let matched = [];
for (let nodeID of node.nodesWithQuery) {
let target = RED.nodes.getNode(nodeID);
if (!target) {
node.warn(
"ERROR: cant get " +
nodeID +
" node for socket message news, removed from list NodeWithQuery"
);
node.unregisterNodeWithQuery(nodeID);
continue;
}
// TODO Cache JSONata expresssions ?
const querySrc = await new Promise((resolve, reject) => {
RED.util.evaluateJSONataExpression(
RED.util.prepareJSONataExpression(target.config.query, target),
{},
(err, value) => {
if (err) reject(err);
else resolve(value);
}
);
});
try {
let query = new Query(querySrc);
if (query.match(device)) {
matched.push(nodeID);
}
} catch (e) {
node.status({
fill: "red",
shape: "dot",
text: "node-red-contrib-deconz/server:status.query_error",
});
node.error(
e.toString() +
"\nNode ID : " +
nodeID +
"\nQuery: " +
JSON.stringify(querySrc)
);
}
}
if (matched.length > 0)
node.propagateNews(matched, {
type: "event",
node_type: "query",
eventData: dataParsed,
device: device,
changed: changed,
});
// Event Nodes
node.propagateNews(node.nodesEvent, {
type: "event",
node_type: "event_node",
eventData: dataParsed,
device: device,
changed: changed,
});
}
getDefaultMsg(nodeType) {
switch (nodeType) {
case "deconz-input":
return "node-red-contrib-deconz/server:status.connected";
case "deconz-get":
return "node-red-contrib-deconz/server:status.received";
case "deconz-output":
return "node-red-contrib-deconz/server:status.done";
}
}
async updateNodeStatus(node, msgToSend) {
if (node.server.ready === false) {
node.status({
fill: "red",
shape: "dot",
text: "node-red-contrib-deconz/server:status.server_node_error",
});
return;
}
if (
node.config.search_type === "device" &&
node.config.device_list.length === 0 &&
// Check if the commands do not contain only scene call
!(
Array.isArray(node.config.commands) &&
node.config.commands.every(
(c) =>
(c.type === "deconz_state" && c.domain === "scene_call") ||
(c.type === "custom" &&
!["attribute", "state", "config"].includes(c.arg.target.type))
)
)
) {
node.status({
fill: "red",
shape: "dot",
text: "node-red-contrib-deconz/server:status.device_not_set",
});
return;
}
if (msgToSend === null) {
node.status({
fill: "green",
shape: "dot",
text: this.getDefaultMsg(node.type),
});
return;
}
let firstmsg = msgToSend[0];
if (firstmsg === undefined) return;
if (
dotProp.get(firstmsg, "meta.state.reachable") === false ||
dotProp.get(firstmsg, "meta.config.reachable") === false
) {
node.status({
fill: "red",
shape: "ring",
text: "node-red-contrib-deconz/server:status.device_not_reachable",
});
return;
}
switch (node.config.statustext_type) {
case "msg":
case "jsonata":
node.status({
fill: "green",
shape: "dot",
text: await Utils.getNodeProperty(
{
type: node.config.statustext_type,
value: node.config.statustext,
},
node,
firstmsg
),
});
break;
case "auto":
switch (node.type) {
case "deconz-input":
case "deconz-get":
let firstOutputRule = node.config.output_rules[0];
if (firstOutputRule === undefined) return;
if (
Array.isArray(firstOutputRule.payload) &&
firstOutputRule.payload.length === 1 &&
!["__complete__", "__each__", "__auto__"].includes(
firstOutputRule.payload[0]
) &&
typeof firstmsg.payload !== "object"
) {
node.status({
fill: "green",
shape: "dot",
text: firstmsg.payload,
});
} else {
node.status({
fill: "green",
shape: "dot",
text: this.getDefaultMsg(node.type),
});
}
break;
case "deconz-battery":
let battery = dotProp.get(firstmsg, "meta.config.battery");
if (battery === undefined)
battery = dotProp.get(firstmsg, "meta.state.battery");
if (battery === undefined) return;
node.status({
fill:
battery >= 20 ? (battery >= 50 ? "green" : "yellow") : "red",
shape: "dot",
text: battery + "%",
});
break;
}
break;
}
}
migrateNodeConfiguration(node) {
let configMigration = new ConfigMigration(node.type, node.config, this);
let migrationResult = configMigration.applyMigration(node.config, node);
if (
Array.isArray(migrationResult.errors) &&
migrationResult.errors.length > 0
) {
migrationResult.errors.forEach((error) =>
console.error(
`Error with migration of node ${node.type} with id ${node.id}`,
error
)
);
node.error(
`Error with migration of node ${node.type} with id ${node.id}\n` +
migrationResult.errors.join("\n") +
"\nPlease open the node settings and update the configuration"
);
node.status({
fill: "red",
shape: "dot",
text: "node-red-contrib-deconz/server:status.migration_error",
});
return false;
}
return true;
}
}
RED.nodes.registerType("deconz-server", ServerNode, {
credentials: {
secured_apikey: { type: "text" },
},
});
};