node-red-contrib-myhome-bticino-v2
Version:
Control Bticino / Legrand MyHome components from Node-RED
334 lines (314 loc) • 21 kB
JavaScript
/*jshint esversion: 7, strict: implied, node: true */
module.exports = function (RED) {
let mhutils = require ('./myhome-utils');
function MyHomeThermoCentralNode (config) {
RED.nodes.createNode (this, config);
var node = this;
let gateway = RED.nodes.getNode (config.gateway);
let runningMonitor = new mhutils.eventsMonitor (gateway);
// All current zone received values stored in memory from the moment node is loaded
let payloadInfo = node.payloadInfo = {};
payloadInfo.state = '?';
payloadInfo.remoteControl = '?';
payloadInfo.operationMode = '?';
node.status_icon = ['grey','ring'];
node.lastPayloadInfo = JSON.stringify (payloadInfo); // SmartFilter : kept in memory to be able to compare whether an update occurred while processing msg
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Register node for updates
node.on ('input', function (msg) {
node.processInput (msg);
});
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Add listener on node linked to a dedicated function call to be able to remove it on close
const listenerFunction = function (ownFamilyName , frame) {
let msg = {};
node.processReceivedBUSFrames (msg, frame);
};
runningMonitor.addMonitoredEvent ('OWN_TEMPERATURE', listenerFunction);
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Function which returns all operation modes params / key info (used in both 'processReceivedBUSFrames' and 'processInput' functions)
function operationModesParams () {
let operationMode_Params = [];
operationMode_Params.push ({state:'ANTIFREEZE', own:'102', mode:RED._('mh-thcentral.node.status-opmode-antifreeze'), icon:['yellow','ring']});
operationMode_Params.push ({state:'OFF_HEATING', own:'103', mode:RED._('mh-thcentral.node.status-opmode-off-heating'), icon:['grey','dot']});
operationMode_Params.push ({state:'MANUAL_HEATING:(....)', own:'110', mode:RED._('mh-thcentral.node.status-opmode-manual-heating'), icon:['yellow','dot'], addField:'setTemperature'});
operationMode_Params.push ({state:'PROGRAM_HEATING:(.)', own:'110(.)', mode:RED._('mh-thcentral.node.status-opmode-prog-heating'), icon:['yellow','dot'], addField:'curProgram'});
operationMode_Params.push ({state:'SCENARIO_HEATING:(..)', own:'12(..)', mode:RED._('mh-thcentral.node.status-opmode-scen-heating'), icon:['yellow','dot'], addField:'curScenario'});
operationMode_Params.push ({state:'THERMAL_PROTECT', own:'202', mode:RED._('mh-thcentral.node.status-opmode-thermal-protect'), icon:['blue','ring']});
operationMode_Params.push ({state:'OFF_CONDITIONING', own:'203', mode:RED._('mh-thcentral.node.status-opmode-off-conditioning'), icon:['grey','dot']});
operationMode_Params.push ({state:'MANUAL_CONDITIONING:(....)', own:'210', mode:RED._('mh-thcentral.node.status-opmode-manual-conditioning'), icon:['blue','dot'], addField:'setTemperature'});
operationMode_Params.push ({state:'PROGRAM_CONDITIONING:(.)', own:'210(.)', mode:RED._('mh-thcentral.node.status-opmode-prog-conditioning'), icon:['blue','dot'], addField:'curProgram'});
operationMode_Params.push ({state:'SCENARIO_CONDITIONING:(..)', own:'22(..)', mode:RED._('mh-thcentral.node.status-opmode-scen-conditioning'), icon:['blue','dot'], addField:'curScenario'});
operationMode_Params.push ({state:'OFF', own:'303', mode:RED._('mh-thcentral.node.status-opmode-off-generic'), icon:['grey','dot']});
operationMode_Params.push ({state:'MANUAL:(....)', own:'310', mode:RED._('mh-thcentral.node.status-opmode-manual-generic'), icon:['yellow','dot'], addField:'setTemperature'});
operationMode_Params.push ({state:'PROGRAM:(.)', own:'310(.)', mode:RED._('mh-thcentral.node.status-opmode-prog-generic'), icon:['yellow','dot'], addField:'curProgram'});
operationMode_Params.push ({state:'SCENARIO:(..)', own:'32(..)', mode:RED._('mh-thcentral.node.status-opmode-scen-generic'), icon:['yellow','dot'], addField:'curScenario'});
return operationMode_Params;
}
let operationModes_Params = operationModesParams ();
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Function called when a MyHome BUS frame is received ///////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
this.processReceivedBUSFrames = function (msg, frame) {
if (typeof (msg.payload) === 'undefined') {
msg.payload = {};
}
let payload = msg.payload;
// When the msg contains a topic with specified 'state/', it means function was called internally (from 'processInput') to refresh values.
// In this case, even if no frame is found to update something, node is refreshed and msg are sent
let forceRefreshAndMsg = (msg.topic === 'state/' + config.topic);
// Check whether received command is linked to current configured Zone
let processedFrames = 0;
for (let curFrame of (typeof(frame) === 'string') ? [frame] : frame) {
let frameMatch;
// Checks 1 : current central unit status info frames (OpenWebNet doc) : reading '*4*what*#0##' with what in 2 digits mode
// what: 20 = Remote control disabled / 21 = Remote control enabled / 22 = At least one probe OFF /
// 23 = At least one probe in protection / 24 = At least one probe in manual / 30 = Failure discovered / 31 = Central Unit battery KO
// TechNote : we only manage the remote control status
frameMatch = curFrame.match ('^\\*4\\*(20|21)\\*#0##');
if (frameMatch !== null) {
if (frameMatch[1] === '20') {
payloadInfo.remoteControl = false;
} else if (frameMatch[1] === '21') {
payloadInfo.remoteControl = true;
}
}
// Checks 2 : Central Unit’s operation mode : '*4*what*#0##' with what in 3-4 digits (and sub info)
// what: 110#T = Manual Heating / 210#T = Manual Conditioning
// 103 = Off Heating / 203 = Off Conditioning / 102 = Antifreeze / 202 = Thermal Protection
// [1101-1103] = Memo program in Heating mode / [2101-2103] = Memo program in Conditioning mode
// [1201-1216] = Memo scenario in Heating mode / [2201-2216] = Memo scenario in Conditioning mode
// 115#[1101-1103] = Holiday Heating [program on return] / 215#[2101-2103] = Holiday Conditioning [program on return]
// [13001-13255] = Holiday days in Heating mode / [23001-23255] = Holiday days in Conditioning mode
// The T field is composed from 4 digits c1c2c3c4, included between “0020” (2°temperature) and “0430” (43°temperature).
// c1 is always equal to 0, it indicates a positive temperature. The c2c3 couple indicates the temperature values between [02° - 43°].
if (frameMatch === null) {
frameMatch = curFrame.match ('^\\*4\\*(\\d{3,5})((#(\\d+))|)\\*#0##');
// RegEx results using groups -> array :
// [1] = what : first 3 to max 5 digits (before a possible #)
// [2] = -not used-
// [3] = -not used-, only there if there was a #
// [4] = what : all digits after the #, if there was a #
if (frameMatch !== null) {
payloadInfo.operationMode_ownValue = frameMatch[1];
// First remove all associated 'sub info'
delete payloadInfo.operationMode_setTemperature;
delete payloadInfo.operationMode_curProgram;
delete payloadInfo.operationMode_curScenario;
// Add main & associated sub info if any
for (let i = 0; i < operationModes_Params.length; i++) {
// Use a regex to see if the current 'what' matches with this mode definition ('^' and '$' added to ensure it encloses start & end of string, partial match is nok)
let ownMatch = payloadInfo.operationMode_ownValue.match('^' + operationModes_Params[i].own + '$');
if (ownMatch !== null) {
payloadInfo.operationMode = operationModes_Params[i].mode;
node.status_icon = operationModes_Params[i].icon;
let addFieldInfo = '';
if (operationModes_Params[i].addField !== undefined) {
// A sub value is defined for this mode, add it to payload
if (ownMatch[1] !== undefined) {
// Additional field info is a part of the WHAT command
addFieldInfo = ownMatch[1];
} else {
// The value is not to be found in OWN WHAT command itself. In this case, it means it is TEMPERATURE info from after the WHAT#
addFieldInfo = parseInt((frameMatch[4] || '').slice(-3))/10;
}
payloadInfo['operationMode_' + operationModes_Params[i].addField] = addFieldInfo;
}
// Build the summary state part of payload
payloadInfo.state = operationModes_Params[i].state;
let countPoints = payloadInfo.state.split('.').length-1;
if (countPoints) {
let whatSource = '(' + '.'.repeat(countPoints) + ')';
payloadInfo.state = payloadInfo.state.replace (whatSource , addFieldInfo);
}
break;
}
}
}
}
// If we reached here with a non null match, it means command was useful for node
if (frameMatch !== null) {
processedFrames++;
}
}
// Checks : all done, if nothing was processed, abord (no node / flow update detected), excepted when refresh is 'forced'
if (processedFrames === 0 && !forceRefreshAndMsg) {
return;
}
// Update node status payloadInfo
let nodeStatusText = payloadInfo.operationMode;
if (payloadInfo.operationMode_curProgram !== undefined) {
nodeStatusText = nodeStatusText + ' #' + payloadInfo.operationMode_curProgram;
} else if (payloadInfo.operationMode_curScenario !== undefined) {
nodeStatusText = nodeStatusText + ' #' + payloadInfo.operationMode_curScenario;
} else if (payloadInfo.operationMode_setTemperature !== undefined) {
nodeStatusText = nodeStatusText + ' @' + payloadInfo.operationMode_setTemperature + '°C';
}
node.status ({fill: node.status_icon[0], shape: node.status_icon[1], text: nodeStatusText});
// Send msg back as new flow : only send update as new flow when something changed after having received this new BUS info
// (but always send it when SmartFilter is disabled or when running in 'state/' mode, i.e. read-only mode)
if (!config.skipevents || forceRefreshAndMsg) {
let newPayloadinfo = JSON.stringify (payloadInfo);
if (!config.smartfilter || newPayloadinfo !== node.lastPayloadInfo || forceRefreshAndMsg) {
// MSG1 : Build primary msg
// MSG1 : Received command info : only include source command when was provided as string (when is an array, it comes from .processInput redirected here)
if (!Array.isArray(frame)) {
payload.command_received = frame;
}
// MSG1 : add major node configuration info on both returned message
msg.mh_nodeConfigInfo = {
'name' : config.name ,
'topic' : config.topic ,
'gateway' : {
'name' : gateway.name ,
'host' : gateway.host ,
'port' : gateway.port
}
};
// MSG1 : Add all current node stored values to payload
payload.state = payloadInfo.state;
payload.remoteControl = payloadInfo.remoteControl;
payload.operationMode = payloadInfo.operationMode;
payload.operationMode_ownValue = payloadInfo.operationMode_ownValue;
// MSG1 : Add all current node stored values to payload (but only if these were defined)
if (payloadInfo.operationMode_setTemperature !== undefined) {
payload.operationMode_setTemperature = payloadInfo.operationMode_setTemperature;
}
if (payloadInfo.operationMode_curProgram !== undefined) {
payload.operationMode_curProgram = payloadInfo.operationMode_curProgram;
}
if (payloadInfo.operationMode_curScenario !== undefined) {
payload.operationMode_curScenario = payloadInfo.operationMode_curScenario;
}
// MSG1 : Add misc info
msg.topic = 'state/' + config.topic;
// MSG2 : Build secondary payload
let msg2 = mhutils.buildSecondaryOutput (RED.util.cloneMessage (msg), payloadInfo, config, 'On', '', '');
// Store last sent payload info & send both msg to output1 and output2
node.lastPayloadInfo = newPayloadinfo;
node.send ([msg, msg2]);
}
}
};
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Function called when a message (payload) is received from the node-RED flow ///////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
this.processInput = function (msg) {
if (typeof (msg) === 'string') {
try {msg = JSON.parse(msg);} catch(error){}
}
// Only process input received from flow when the topic matches with configuration of nodes
let isReadOnly = config.isstatusrequest || false;
if (msg.topic === 'state/' + config.topic) {
// Running in 'state/' mode, force read-only regardless of node config mode
isReadOnly = true;
} else if (msg.topic !== 'cmd/' + config.topic) {
return;
}
// Get payload and apply conversions (asked state can be set in 'msg.payload' or 'msg.payload.state'
if (msg.payload === undefined) {
msg.payload = {};
} else if (typeof(msg.payload) === 'string') {
try {msg.payload = JSON.parse(msg.payload);} catch(error){}
}
if (typeof(msg.payload) === 'string' || typeof(msg.payload) === 'number') {
msg.payload = {'state': msg.payload.toString()};
}
let payload = msg.payload;
// Build the OpenWebNet command(s) to be sent
let commands = [];
if (!isReadOnly) {
// Running in Write mode
let cmd_what = '';
let isManualTemp = 0;
// Add main & associated sub info if any
for (let i = 0; i < operationModes_Params.length; i++) {
// Use a regex to see if the current 'state' matches with this mode definition ('^' and '$' added to ensure it encloses start & end of string, partial match is nok)
let stateMatch = payload.state.match('^' + operationModes_Params[i].state + '$');
if (stateMatch !== null || payload.operationMode === operationModes_Params[i].mode) {
cmd_what = operationModes_Params[i].own;
// If this command as a dynamic part in it (i.e. '(.)' or '(..)'), process this
let cmd_whatParam = '';
if (stateMatch === null) {
cmd_whatParam = payload['operationMode_' + operationModes_Params[i].addField];
} else {
cmd_whatParam = stateMatch[1];
}
let countPoints = cmd_what.split('.').length-1;
if (cmd_whatParam && countPoints) {
let whatSource = '(' + '.'.repeat(countPoints) + ')';
let whatReplacer = ('0' + cmd_whatParam).slice(-countPoints);
cmd_what = cmd_what.replace(whatSource , whatReplacer);
}
isManualTemp = (operationModes_Params[i].state.includes('MANUAL')) ? cmd_whatParam : 0;
break;
}
}
// After having parsed commands, apply final command : MANUAL mode a different one, while other ar 'simply' a WHAT command
if (isManualTemp || parseFloat(payload.state)) {
// Temperature to be set in MANUAL mode, command is *#4*where*#14*T*M##
// - where = [#1 - #99] Setup zone by Central Unit
// - The T field is composed from 4 digits c1c2c3c4, included between “0020” (2°temperature) and “0430” (43°temperature).
// c1 is always equal to 0, it indicates a positive temperature. The c2c3 couple indicates the temperature values between [02° - 43°].
// - M = operation mode : 1 = heating mode / 2 = conditional mode / 3 = generic mode
let tempSet = parseInt((isManualTemp || payload.state)*10);
if (tempSet < 20 || isNaN(tempSet)) {
tempSet = 20;
} else if (tempSet > 430) {
tempSet = 430;
}
tempSet = ('0000' + tempSet.toString()).slice(-4);
commands.push ('*#4*#0*#14*' + tempSet + '*' + (cmd_what + '3').slice(0,1) + '##');
} else if (cmd_what) {
commands.push ('*4*' + cmd_what + '*#0##');
}
}
if (isReadOnly || commands.length) {
// Working in read-only mode: build a status enquiry request (no status update sent)
// In theory (based on OpenWebNet doc), only 1 call is required
// Command #1 : *#4*#0## (where = #0 here) which returns multiple '*4*what*#0##' with different 'what' based on curtrent state
// BUT, during test phase, it appears not all gateways react the same, as for zone
// - F455 : does provide the listed ones but also much more (2 on F459 -> 17 (!!) on F455. Also quite long delay before responses are all sent...). But OK, they are there
// - MH202 : does provide the listed ones but always returned 3 frame (what=22, 23 and 24) even if not probe OFF / Protection / manual at all...
// - F459 & MyHOMEServer1 : works as it should
commands.push ('*#4*#0##');
}
if (commands.length === 0) {
return;
}
// Send the command on the BUS through the MyHome gateway
mhutils.executeCommand (node, commands, gateway, 0, true,
function (commands, cmd_responses, cmd_failed) {
// Build main payload to return payloads to outputs
payload.command_sent = commands; // Include initial SCS/BUS message which was sent in main payload
payload.command_responses = cmd_responses; // include the BUS responses when emitted command provides a result (can hold multiple values)
if (cmd_failed.length) {
// Also add failed requests, but only if some failed
payload.command_failed = cmd_failed;
}
// Once commands were sent, call internal function to force node info refresh (using 'state/') and msg outputs
msg.topic = 'state/' + config.topic;
node.processReceivedBUSFrames (msg, cmd_responses);
}, function (cmd_failed, nodeStatusErrorMsg) {
// Error, only update node state
node.status ({fill: 'red', shape: 'dot', text: nodeStatusErrorMsg});
});
};
// When node is enabled to refresh current state on node load, build a fake msg with necessary topic to force node to refresh itself
// TechNote : the command is delayed by a few seconds. During tests, without such delay, the gateway did not respond (or only partially) is if it was too busy
if (config.thermo_onconnect_refreshstate) {
mhutils.logNodeEvent (node, 'debug', gateway.log_config.log_in_temperature, "Asking for Thermo Central status refresh for '" + node.name + "' within a few seconds...");
setTimeout (function() {
mhutils.logNodeEvent (node, 'debug', gateway.log_config.log_in_temperature, "Asking for Thermo Central status refresh for '" + node.name + "' started.");
node.processInput ({'topic':'state/' + config.topic});
} , 1500);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
node.on ('close', function (done) {
// If any listener was defined, removed it now otherwise node will remain active in memory and keep responding to Gateway incoming calls
runningMonitor.clearAllMonitoredEvents ();
done();
});
}
RED.nodes.registerType ('myhome-thermo-central', MyHomeThermoCentralNode);
};