UNPKG

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
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); };