node-red-contrib-dali-sensor
Version:
Node-RED node for parsing DALI sensor data and outputting motion and lux
272 lines (241 loc) • 12.1 kB
JavaScript
module.exports = function(RED) {
function DaliSensorNode(config) {
RED.nodes.createNode(this, config);
var node = this;
node.shortAddress = String(config.shortAddress);
node.motionInstance = parseInt(config.motionInstance, 10);
node.luxInstance = parseInt(config.luxInstance, 10);
node.luxSetpoint = parseInt(config.luxSetpoint, 10);
node.useBoolean = config.useBoolean === true || config.useBoolean === "true";
// Auto-Off configuration
node.enableAutoOff = !!config.enableAutoOff;
node.autoOffDelayMs = (Number(config.autoOffDelaySec) > 0 ? Number(config.autoOffDelaySec) : 30) * 1000;
var context = node.context();
// Sensor state (latched)
if (context.get('motionLatched') === undefined) context.set('motionLatched', false);
if (context.get('lastMotion') === undefined) context.set('lastMotion', null);
if (context.get('lastLux') === undefined) context.set('lastLux', null);
if (context.get('lastAlarm') === undefined) context.set('lastAlarm', null);
// Track the state we last EMITTED on the Motion output (so we can delay Off cleanly)
if (context.get('emittedMotion') === undefined) context.set('emittedMotion', null);
// Auto-Off persisted values (JSON-safe only)
if (context.get('autooff_expiryTs') === undefined) context.set('autooff_expiryTs', null);
// Non-persisted timer/ticker handles (avoid circular refs)
node._offTimer = null;
node._ticker = null;
function boolOrOnOff(val) {
// Match the node's existing convention: when useBoolean==true, it outputs string 'true'/'false' (preserve this)
if (node.useBoolean) return val ? 'true' : 'false';
return val ? 'On' : 'Off';
}
// --- Auto-Off helpers ---
function clearTicker() {
if (node._ticker) { clearInterval(node._ticker); node._ticker = null; }
}
function clearTimer() {
if (node._offTimer) { clearTimeout(node._offTimer); node._offTimer = null; }
}
function startStatusTicker(expiryTs) {
clearTicker();
node._ticker = setInterval(() => {
const now = Date.now();
const remainMs = Math.max(0, (expiryTs ?? now) - now);
const remainSec = Math.ceil(remainMs / 1000);
if (remainSec <= 0) {
node.status({ fill: 'grey', shape: 'ring', text: 'turning off...' });
clearTicker();
return;
}
// Show countdown appended; motion text itself reflects last EMITTED state
const emittedMotion = context.get('emittedMotion');
const lastLux = context.get('lastLux');
const lastAlarm = context.get('lastAlarm');
var motionText = (emittedMotion === null) ? '-' : boolOrOnOff(!!emittedMotion);
var luxText = (lastLux === null) ? '-' : String(lastLux);
var alarmText = (lastAlarm === null) ? '-' : boolOrOnOff(!!lastAlarm);
node.status({ fill: 'yellow', shape: 'dot', text: `M:${motionText} L:${luxText} A:${alarmText} • off in ${remainSec}s` });
}, 1000);
}
// On deploy/restart: if there was a pending expiry, re-arm the OFF
(function rearmPendingOff() {
if (!node.enableAutoOff) return;
const expiryTs = context.get('autooff_expiryTs');
const emittedMotion = context.get('emittedMotion');
if (expiryTs && !node._offTimer) {
const remaining = Math.max(0, expiryTs - Date.now());
if (remaining > 0) {
node._offTimer = setTimeout(() => {
// Time to emit Off on Motion output
context.set('emittedMotion', false);
context.set('autooff_expiryTs', null);
clearTicker();
clearTimer();
// Send only Motion output on port 0 when timer fires
node.send([{ payload: boolOrOnOff(false), topic: 'Motion' }, null, null]);
// Update status after switching off
const lastLux = context.get('lastLux');
const lastAlarm = context.get('lastAlarm');
var motionText = boolOrOnOff(false);
var luxText = (lastLux === null) ? '-' : String(lastLux);
var alarmText = (lastAlarm === null) ? '-' : boolOrOnOff(!!lastAlarm);
var updated = new Date().toLocaleTimeString();
node.status({ fill: 'red', shape: 'ring', text: `M:${motionText} L:${luxText} A:${alarmText} @${updated}` });
}, remaining);
startStatusTicker(expiryTs);
} else {
// Expired while down → emit Off now
context.set('emittedMotion', false);
context.set('autooff_expiryTs', null);
clearTicker();
clearTimer();
node.send([{ payload: boolOrOnOff(false), topic: 'Motion' }, null, null]);
}
} else {
// No pending timer; keep existing status logic
}
})();
node.on('input', function(msg) {
if (!msg.dali || !msg.dali.scheme) return;
// Allow per-message override of auto-off delay when starting a new "off" countdown
var msgDelayMs = Number.isFinite(+msg.delay) ? +msg.delay : null;
var scheme = msg.dali.scheme;
// If using Device/Instance, ensure instances are configured
if (scheme === 'Device/Instance' && (isNaN(node.motionInstance) || isNaN(node.luxInstance))) {
node.status({ fill: 'red', shape: 'ring', text: 'Motion/Lux instance not set' });
return;
}
var typeCode;
if (scheme === 'Device') {
typeCode = parseInt(msg.dali.instanceType, 10);
} else if (scheme === 'Device/Instance') {
typeCode = parseInt(msg.dali.instanceNumber, 10);
} else {
node.status({ fill: 'red', shape: 'ring', text: `Unsupported scheme: ${scheme}` });
return;
}
if (String(msg.dali.shortAddress) !== node.shortAddress) return;
var info = parseInt(msg.dali.info, 10);
if (isNaN(info)) {
node.error('Invalid info field', msg);
return;
}
var motion = null, lux = null, alarm = null;
// Device scheme: instanceType 3 => motion, 4 => lux
if (scheme === 'Device') {
if (typeCode === 3) {
var movement = !!(info & 0x1);
var occCode = (info >> 1) & 0x3;
var implied = (occCode === 1 || occCode === 3);
var latched = context.get('motionLatched') || false;
if (movement || implied) latched = true; else if (occCode === 0) latched = false;
context.set('motionLatched', latched);
motion = latched;
context.set('lastMotion', latched);
} else if (typeCode === 4) {
lux = info;
alarm = info < node.luxSetpoint;
context.set('lastLux', lux);
context.set('lastAlarm', alarm);
} else {
return; // Not a sensor packet
}
} else {
// Device/Instance scheme: match user-configured instances
if (typeCode === node.motionInstance) {
var movement = !!(info & 0x1);
var occCode = (info >> 1) & 0x3;
var implied = (occCode === 1 || occCode === 3);
var latched = context.get('motionLatched') || false;
if (movement || implied) latched = true; else if (occCode === 0) latched = false;
context.set('motionLatched', latched);
motion = latched;
context.set('lastMotion', latched);
} else if (typeCode === node.luxInstance) {
lux = info;
alarm = info < node.luxSetpoint;
context.set('lastLux', lux);
context.set('lastAlarm', alarm);
} else {
return; // Not a sensor packet
}
}
// Prepare outputs using persisted values, but apply Auto-Off gating to Motion
var lastLux = context.get('lastLux');
var lastAlarm = context.get('lastAlarm');
// Determine what Motion should OUTPUT (emittedMotion), possibly delaying Off
var emittedMotion = context.get('emittedMotion');
if (motion !== null) {
if (!node.enableAutoOff) {
// Feature disabled: output follows the latched state directly
emittedMotion = motion;
context.set('emittedMotion', emittedMotion);
// Cancel any pending timer/ticker, just in case
context.set('autooff_expiryTs', null);
clearTimer();
clearTicker();
} else {
// Feature enabled: On immediately, Off after non-resettable delay
if (motion === true) {
// Motion -> ON now: emit immediately; cancel any pending Off
emittedMotion = true;
context.set('emittedMotion', true);
context.set('autooff_expiryTs', null);
clearTimer();
clearTicker();
} else {
// motion === false
// Only start a new Off countdown if none is running and we are currently "emitting" On
var expiryTs = context.get('autooff_expiryTs');
if (!node._offTimer && (emittedMotion === true || emittedMotion === null)) {
var delayMs = (msgDelayMs != null) ? msgDelayMs : node.autoOffDelayMs;
var targetTs = Date.now() + delayMs;
context.set('autooff_expiryTs', targetTs);
node._offTimer = setTimeout(() => {
// When timer fires, actually emit OFF on Motion output
context.set('emittedMotion', false);
context.set('autooff_expiryTs', null);
clearTicker();
clearTimer();
node.send([{ payload: boolOrOnOff(false), topic: 'Motion' }, null, null]);
// Update status after turning off
var luxText = (context.get('lastLux') === null) ? '-' : String(context.get('lastLux'));
var alarmText = (context.get('lastAlarm') === null) ? '-' : boolOrOnOff(!!context.get('lastAlarm'));
var updated = new Date().toLocaleTimeString();
node.status({ fill:'red', shape:'ring', text:`M:${boolOrOnOff(false)} L:${luxText} A:${alarmText} @${updated}` });
}, delayMs);
startStatusTicker(targetTs);
}
// While countdown runs, keep emitting "On" (do not change emittedMotion yet)
// i.e., do nothing here; emittedMotion remains what it was.
}
}
}
// Build outputs
var out0 = null, out1 = null, out2 = null;
// Motion output reflects EMITTED state (not the raw latched when auto-off is enabled)
if (context.get('emittedMotion') !== null) {
out0 = { payload: boolOrOnOff(!!context.get('emittedMotion')), topic: 'Motion' };
}
if (lastLux !== null) {
out1 = { payload: lastLux, topic: 'Lux Value' };
out2 = { payload: boolOrOnOff(!!lastAlarm), topic: 'Lux Alarm' };
}
// Update status — based on EMITTED motion state
var motionText = (context.get('emittedMotion') === null) ? '-' : boolOrOnOff(!!context.get('emittedMotion'));
var luxText2 = (lastLux === null) ? '-' : String(lastLux);
var alarmText2 = (lastAlarm === null) ? '-' : boolOrOnOff(!!lastAlarm);
var updated = new Date().toLocaleTimeString();
// If auto-off countdown is active, the ticker already updates status with countdown;
// otherwise set a normal status.
if (!node._ticker) {
node.status({ fill:'green', shape:'dot', text:`M:${motionText} L:${luxText2} A:${alarmText2} @${updated}` });
}
node.send([out0, out1, out2]);
});
node.on('close', () => {
if (node._offTimer) { clearTimeout(node._offTimer); node._offTimer = null; }
if (node._ticker) { clearInterval(node._ticker); node._ticker = null; }
});
}
RED.nodes.registerType('dali-sensor', DaliSensorNode);
};