node-red-contrib-myhome-bticino-v2
Version:
Control Bticino / Legrand MyHome components from Node-RED
394 lines (373 loc) • 24.2 kB
JavaScript
/*jshint esversion: 7, strict: implied, node: true */
module.exports = function (RED) {
let mhutils = require ('./myhome-utils');
function MyHomeThermoZoneNode (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.setTemperature = '?';
payloadInfo.localOffset_ownValue = '?';
payloadInfo.localOffset = '?';
payloadInfo.operationType_ownValue = '?';
payloadInfo.operationType = '?';
payloadInfo.actuatorStates = {};
payloadInfo.actuatorStates.On = false;
payloadInfo.operationMode_ownValue = '?';
payloadInfo.operationMode = '?';
node.status_icon = ['grey','ring'];
node.status_needTempInfo = false;
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 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 temperature and set objective frames (OpenWebNet doc) :
// - current temperature (master probe) : *#4*where*0*T## (or *#4*where*0*T*3## if local offset included)
// - current temperature goal set (included adjust by local offset) : *#4*where*14*T*3##
// 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°].
frameMatch = curFrame.match ('^\\*\\#4\\*' + config.zoneid + '\\*(0|14)\\*\\d(\\d{3})(\\*3|)##');
if (frameMatch !== null) {
if (frameMatch[1] === '0') {
// Current temperature from master probe
payloadInfo.state = parseInt (frameMatch[2]) / 10;
} else if (frameMatch[1] === '14') {
// Current temperature objective set
payloadInfo.setTemperature = parseInt (frameMatch[2]) / 10;
}
}
// Checks 2 : Zone operation type frames (OpenWebNet doc) : *4*what*where##
// where being : 0 = Conditioning / 1 = Heating / 102 = Antifreeze / 202 = Thermal Protection / 303 = Generic OFF
if (frameMatch === null) {
frameMatch = curFrame.match ('^\\*4\\*(\\d+)\\*' + config.zoneid + '##');
if (frameMatch !== null) {
payloadInfo.operationType_ownValue = frameMatch[1];
let operationType_List = [];
operationType_List.push (['0' , RED._('mh-thzone.node.status-optype-conditioning')]);
operationType_List.push (['1' , RED._('mh-thzone.node.status-optype-heating')]);
operationType_List.push (['102' , RED._('mh-thzone.node.status-optype-antifreeze')]);
operationType_List.push (['202' , RED._('mh-thzone.node.status-optype-thermalprotect')]);
operationType_List.push (['303' , RED._('mh-thzone.node.status-optype-genoff')]);
for (let i = 0; i < operationType_List.length; i++) {
if (operationType_List[i][0] == payloadInfo.operationType_ownValue) {
payloadInfo.operationType = operationType_List[i][1];
break;
}
}
}
}
// Checks 3 : Actuator status for current zone frames (OpenWebNet doc) : *#4*where*20*Val##
// where : Actuators N of zone Z [Z#N] -> [0-99#1-9] / All the actuators of zone Z [Z#0] / All the actuators [0#0]
// val : 0 = OFF / 1 = ON / 2 = Opened / 3 = Closed / 4 = Stop / 5 = OFF Fan Coil / 6 = ON Vel 1 / 7 = ON Vel 2 / 8 = ON Vel 3 / 9 = ON Fan Coil
if (frameMatch === null) {
frameMatch = curFrame.match ('^\\*\\#4\\*' + config.zoneid + '\\#(\\d)\\*20\\*(\\d)##');
if (frameMatch !== null) {
let curActuatorid = frameMatch[1];
let curActuatorState = payloadInfo.actuatorStates['actuator_' + curActuatorid] = {};
curActuatorState.state_ownValue = frameMatch[2];
let actuatorStates_List = [];
actuatorStates_List.push (['0' , false , 'OFF']);
actuatorStates_List.push (['1' , true , 'ON']);
actuatorStates_List.push (['2' , true , 'Opened']);
actuatorStates_List.push (['3' , false , 'Closed']);
actuatorStates_List.push (['4' , false , 'Stop']);
actuatorStates_List.push (['5' , false , 'OFF Fan Coil']);
actuatorStates_List.push (['6' , true , 'ON Vel 1']);
actuatorStates_List.push (['7' , true , 'ON Vel 2']);
actuatorStates_List.push (['8' , true , 'ON Vel 3']);
actuatorStates_List.push (['9' , true , 'ON Fan Coil']);
for (let i = 0; i < actuatorStates_List.length; i++) {
if (actuatorStates_List[i][0] == curActuatorState.state_ownValue) {
curActuatorState.On = actuatorStates_List[i][1];
curActuatorState.state = actuatorStates_List[i][2];
payloadInfo.actuatorStates.On = curActuatorState.On; // Easy way (but not 100% correct : the global state in taken from the last read actuator)
break;
}
}
}
}
// Checks 4 : Local offset acquire frames (OpenWebNet doc) : *#4*where*13*OL## with OL = Local Offset (knob status):
// 00 = 0 / 01 = +1° / 02 = +2° / 03 = +3° / 11 = -1° / 12 = -2° / 13 = -3° / 04 = Local OFF / 05 = Local protection
if (frameMatch === null) {
frameMatch = curFrame.match ('^\\*\\#4\\*' + config.zoneid + '\\*13\\*(\\d{2})##');
if (frameMatch !== null) {
payloadInfo.localOffset_ownValue = frameMatch[1];
let localOffset_List = [];
localOffset_List.push (['00' , 0]); // +0°C
localOffset_List.push (['01' , 1]); // +1°C
localOffset_List.push (['02' , 2]); // +2°C
localOffset_List.push (['03' , 3]); // +3°C
localOffset_List.push (['11' , -1]); // -1°C
localOffset_List.push (['12' , -2]); // -2°C
localOffset_List.push (['13' , -3]); // -3°C
localOffset_List.push (['04' , RED._('mh-thzone.node.status-localoffset-off')]);
localOffset_List.push (['05' , RED._('mh-thzone.node.status-localoffset-protect')]);
for (let i = 0; i < localOffset_List.length; i++) {
if (localOffset_List[i][0] == payloadInfo.localOffset_ownValue) {
payloadInfo.localOffset = localOffset_List[i][1];
break;
}
}
}
}
// Checks 5 : Zone operation mode request of central unit (OpenWebNet doc) : *4*what*#where##
// what : 110 = Manual Heating / 210 = Manual Conditioning / 111 = Automatic Heating / 211 = Automatic Conditioning / 103 = Off Heating / 203 = Off Conditioning / 102 = Antifreeze / 202 = Thermal Protection
// where : [#1 - #99] Request zone by Central Unit.
if (frameMatch === null) {
frameMatch = curFrame.match ('^\\*4\\*(\\d{3})\\*\\#' + config.zoneid + '##');
if (frameMatch !== null) {
payloadInfo.operationMode_ownValue = frameMatch[1];
let operationMode_List = [];
operationMode_List.push ({own:'102', mode:RED._('mh-thzone.node.status-opmode-antifreeze'), icon:['yellow','ring'], needTempInfo:false});
operationMode_List.push ({own:'103', mode:RED._('mh-thzone.node.status-opmode-off-heating'), icon:['grey','dot'], needTempInfo:false});
operationMode_List.push ({own:'110', mode:RED._('mh-thzone.node.status-opmode-manual-heating'), icon:['yellow','dot'], needTempInfo:true});
operationMode_List.push ({own:'111', mode:RED._('mh-thzone.node.status-opmode-auto-heating'), icon:['yellow','dot'], needTempInfo:true});
operationMode_List.push ({own:'202', mode:RED._('mh-thzone.node.status-opmode-thermal-protect'), icon:['blue','ring'], needTempInfo:false});
operationMode_List.push ({own:'203', mode:RED._('mh-thzone.node.status-opmode-off-conditioning'), icon:['grey','dot'], needTempInfo:false});
operationMode_List.push ({own:'210', mode:RED._('mh-thzone.node.status-opmode-manual-conditioning'), icon:['blue','dot'], needTempInfo:true});
operationMode_List.push ({own:'211', mode:RED._('mh-thzone.node.status-opmode-auto-conditioning'), icon:['blue','dot'], needTempInfo:true});
// Add main & associated sub info if any
for (let i = 0; i < operationMode_List.length; i++) {
if (operationMode_List[i].own == payloadInfo.operationMode_ownValue) {
payloadInfo.operationMode = operationMode_List[i].mode;
node.status_icon = operationMode_List[i].icon;
node.status_needTempInfo = operationMode_List[i].needTempInfo;
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
// Icon : when colo is not grey (i.e. blue or yellow), we revert to grey if actuators are not On
let nodeStatusColor = node.status_icon[0];
if (nodeStatusColor !== 'grey' && !payloadInfo.actuatorStates.On) {
nodeStatusColor = 'grey';
}
// Text : append temperatur set point info if needed
let nodeStatusText = payloadInfo.state + '°C (' + payloadInfo.operationMode + ((node.status_needTempInfo) ? ' @' + payloadInfo.setTemperature + '°C' : '') + ')';
node.status ({fill: nodeStatusColor , 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 ,
'zoneid' : config.zoneid ,
'gateway' : {
'name' : gateway.name ,
'host' : gateway.host ,
'port' : gateway.port
}
};
// MSG1 : Add all current node stored values to payload
payload.state = payloadInfo.state;
payload.setTemperature = payloadInfo.setTemperature;
payload.operationType = payloadInfo.operationType;
payload.operationType_ownValue = payloadInfo.operationType_ownValue;
payload.localOffset = payloadInfo.localOffset;
payload.localOffset_ownValue = payloadInfo.localOffset_ownValue;
payload.actuatorStates = payloadInfo.actuatorStates;
payload.operationMode_ownValue = payloadInfo.operationMode_ownValue;
payload.operationMode = payloadInfo.operationMode;
// 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};
}
let payload = msg.payload;
// Build the OpenWebNet command(s) to be sent
let commands = [];
let interCommandsDelay = 0;
if (!isReadOnly) {
// Running in Write mode
if (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(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*#' + config.zoneid + '*#14*' + tempSet + '*3##');
commands.push ('*#4*' + config.zoneid + '*14##'); // since the set command does not return the value, get it by adding a status command right after
} else {
let cmd_what = '';
if (payload.state === 'ANTIFREEZE') {
// Zone to be switched to Antifreeze mode (only valid in 'Heating mode') : *4*102*where##
// - where = [#1 - #99] Setup zone by Central Unit
cmd_what = '102';
} else if (payload.state === 'THERMAL_PROTECT') {
// Zone to be switched to thermal protection mode (only valid in 'Conditioning mode') : *4*202*where##
// - where = [#1 - #99] Setup zone by Central Unit
cmd_what = '202';
} else if (payload.state === 'PROTECT') {
// Zone to be switched to protection mode (Generic, works for both Heating & Conditioning modes) : *4*302*where##
// - where = [#1 - #99] Setup zone by Central Unit
cmd_what = '302';
} else if (payload.state === 'OFF') {
// Zone to be switched to off : *4*303*where##
// - where = [#1 - #99] Setup zone by Central Unit
cmd_what = '303';
} else if (payload.state === 'AUTO') {
// Zone to be switched to auto : *4*311*#where##
// - where = [#1 - #99] Setup zone by Central Unit
cmd_what = '311';
}
if (cmd_what) {
commands.push ('*4*' + cmd_what + '*#' + config.zoneid + '##');
}
}
}
if (isReadOnly || commands.length) {
// In Read-Only mode : build a status enquiry request (no status update sent)
// In Write mode : Since the gateway does not 'respond' when changing point state, we also add a second call to ask for point status after update.
// -> Which commands ??? In theory (based on OpenWebNet doc), only 2 calls are required
// Command #1 : *#4*where## (where = Zone) which returns :
// 1: *4*what*where## : what = Zone operation mode
// 2: *#4*where*0*T## : T = Zone operation temperature not adjust by local offset
// 3: *#4*where*12*T*3## : T = Zone operation temperature with adjust by local offset
// 4: *#4*where*13*OL## : OL = Local Offset (knob status)
// 5: *#4*where*14*T*3## : T = Zone Set-point temperature
// BUT, during test phase, it appears not all gateways are able to repond to otherwise
// - F455 : does not respond anything to 'Command #1', but all responses are sent on the bus (=indirect fetch)
// - MH202 : does not return on 'Command #1' the 1.5: (but is sent on the BUS), returns '*#4*where*14*T##' instead
// - F459 & MyHOMEServer1 : all responses are received, and even more (15 actually ...)
// Therefore, using a mode with more calls was the most stable way to go...
// commands.push ('*#4*' + config.zoneid + '##'); // theory, not stable enough :-P, quite heavy to also include (actually, only MH202 is missing something without this)
commands.push ('*#4*' + config.zoneid + '*12##'); // to fetch Command #1.1 + #1.3 Works OK on F455 / F459 / MyHOMEServer1 but MH202 does not return #1.1
commands.push ('*#4*' + config.zoneid + '*0##'); // to fetch Command #1.2 Works OK on F455 / MH202 / F459 / MyHOMEServer1
commands.push ('*#4*' + config.zoneid + '*13##'); // to fetch Command #1.4 Works OK on F455 / MH202 / F459 / MyHOMEServer1
commands.push ('*#4*' + config.zoneid + '*14##'); // to fetch Command #1.5 Works OK on F455 / MH202 (but without the '*3') / F459 / MyHOMEServer1
// Command #2 : *#4*where#0*20## (where = Zone) which returns :
// 1: *#4*where#[1-9]*20*Val## : Actuator #[1-9] status for current zone
// BUT, during test phase : fails on F455 & MH202 (NACK), or return other results (?). Commands are NOT seen on the BUS at all...
// Therefore, added a second call to at least gather actuator 1 status (most systems have the first actuator assigned before the others...)
commands.push ('*#4*' + config.zoneid + '#0*20##'); // no alternative found to ask for these :-/
commands.push ('*#4*' + config.zoneid + '#1*20##');
// Command #3 : *#4*#where## (where = Zone) which returns :
// 1: *4*what*#where# : what = Zone operation mode request of Central Unit
// Test Phase : OK on all gateways tested (F455 / MH202 / F459 / MyHOMEServer1)
commands.push ('*#4*#' + config.zoneid + '##');
// During tests, last command (to gather zone operation mode of central unit) failed. It seems it cannot be processed as long as the first one
// is being processed / emitting content to the BUS. There must be a total delay of >=1.5 seconds between first command (operation mode change) and last (operation mode request)
if (!isReadOnly) {
interCommandsDelay = parseInt(1500/(commands.length-1));
}
}
if (commands.length === 0) {
return;
}
// Send the command on the BUS through the MyHome gateway
mhutils.executeCommand (node, commands, gateway, interCommandsDelay, 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 Zone status refresh for '" + node.name + "' within a few seconds...");
setTimeout (function() {
mhutils.logNodeEvent (node, 'debug', gateway.log_config.log_in_temperature, "Asking for Thermo Zone 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-zone', MyHomeThermoZoneNode);
};