#!/usr/bin/env python3 """Patch Node-RED flows for fast HA toggle reliability.""" import json import shutil from datetime import datetime path = "/srv/docker-volumes/config/nodered/data/flows.json" backup = path + ".bak-fasttoggle-" + datetime.now().strftime("%Y%m%d-%H%M%S") shutil.copy2(path, backup) print("backup:", backup) with open(path) as f: flows = json.load(f) HA_TO_NVL = r"""const lib = global.get('roomConfigLib'); const config = global.get('roomConfig') || {}; const parsed = msg.plcLight || (lib ? lib.parseHaStateMessage(msg, config) : null); if (!parsed) return null; const ROOM_NAME = parsed.cmdRoom; const lightNum = parsed.lightNum; const isOn = parsed.isOn; // User toggle: update mirror cache so NVL→HA feedback does not re-write HA or set echo suppress. const flowKey = 'nvlToHa_' + parsed.entityId.replace(/\./g, '_'); flow.set(flowKey, isOn); const echo = flow.get('haEchoSuppress') || {}; delete echo[parsed.entityId]; flow.set('haEchoSuppress', echo); if (!flow.get('nvlInState')) flow.set('nvlInState', { rooms: {}, boiler: {} }); const state = flow.get('nvlInState'); if (!state.rooms[ROOM_NAME]) state.rooms[ROOM_NAME] = {}; const r = state.rooms[ROOM_NAME]; const onKey = 'ha_l' + lightNum + '_on'; const offKey = 'ha_l' + lightNum + '_off'; r[onKey] = false; r[offKey] = false; if (isOn) r[onKey] = true; else r[offKey] = true; flow.set('nvlInState', state); const seq = (flow.get('nvlClearSeq') || 0) + 1; flow.set('nvlClearSeq', seq); node.warn('[HA to NVL] ' + parsed.entityId + ' → ' + ROOM_NAME + ' ' + (isOn ? onKey : offKey)); msg.payload = { buildAndSend: true }; msg.zigbeeClear = { seq, list: [ { room: ROOM_NAME, key: onKey }, { room: ROOM_NAME, key: offKey }, ], }; return msg;""" CLEAR_NVL = r"""if (msg.zigbeeClear) { const spec = Array.isArray(msg.zigbeeClear) ? { seq: msg.zigbeeClearSeq, list: msg.zigbeeClear } : msg.zigbeeClear; const list = spec.list || (spec.room ? [spec] : []); const seq = spec.seq != null ? spec.seq : msg.zigbeeClearSeq; if (seq != null && seq !== flow.get('nvlClearSeq')) { return null; } const state = flow.get('nvlInState') || { rooms: {}, boiler: {} }; for (const item of list) { if (item.room === 'boiler') { if (!state.boiler) state.boiler = {}; if (item.key) state.boiler[item.key] = false; continue; } const r = state.rooms[item.room]; if (r && item.key) r[item.key] = false; } flow.set('nvlInState', state); } msg.payload = { buildAndSend: true }; return msg;""" NVL_TO_HA = r"""const config = global.get('roomConfig'); const LIGHT_ENTITY_MAP = (config && config.lightEntityMap && config.lightEntityMap.length) ? config.lightEntityMap : []; const ECHO_MS = 400; const payload = msg.payload || {}; const calls = []; const echo = flow.get('haEchoSuppress') || {}; const until = Date.now() + ECHO_MS; for (const entry of LIGHT_ENTITY_MAP) { const room = payload[entry.room] || {}; const li = entry.light; const isOn = !!(room['l_' + li + '_status'] || room['l_' + li] || room['l' + li]); const flowKey = 'nvlToHa_' + entry.entityId.replace(/\./g, '_'); const last = flow.get(flowKey); if (last === isOn) continue; flow.set(flowKey, isOn); echo[entry.entityId] = until; const domain = entry.entityId.split('.')[0] || 'input_boolean'; const service = isOn ? 'turn_on' : 'turn_off'; calls.push({ payload: { action: domain + '.' + service, target: { entity_id: [entry.entityId] }, }, }); } flow.set('haEchoSuppress', echo); if (!calls.length) return null; return calls;""" patches = { "4045239f675b77d2": HA_TO_NVL, "44822e048dbaee31": CLEAR_NVL, "d5cdc38171230744": NVL_TO_HA, } for n in flows: nid = n.get("id") if nid in patches: n["func"] = patches[nid] print("patched", nid, n.get("name")) if nid == "wh_pulse_fn" and "nvlClearSeq" not in n.get("func", ""): n["func"] = n["func"].replace( "return {\n payload: { buildAndSend: true },\n zigbeeClear: { room: 'boiler', key: key },\n};", """const seq = (flow.get('nvlClearSeq') || 0) + 1; flow.set('nvlClearSeq', seq); return { payload: { buildAndSend: true }, zigbeeClear: { seq, list: [{ room: 'boiler', key }] }, };""", ) print("patched wh_pulse_fn") if nid == "plc_tt_pulse" and "nvlClearSeq" not in n.get("func", ""): n["func"] = n["func"].replace( "return {\n payload: { buildAndSend: true },\n zigbeeClear: { room: room, key: key },\n};", """const seq = (flow.get('nvlClearSeq') || 0) + 1; flow.set('nvlClearSeq', seq); return { payload: { buildAndSend: true }, zigbeeClear: { seq, list: [{ room: room, key: key }] }, };""", ) print("patched plc_tt_pulse") if nid == "718ba68ae647874f" and "nvlClearSeq" not in n.get("func", ""): n["func"] = n["func"].replace( " outPlc.push({\n payload: { buildAndSend: true },\n zigbeeClear: clearList,\n });", """ const seq = (flow.get('nvlClearSeq') || 0) + 1; flow.set('nvlClearSeq', seq); outPlc.push({ payload: { buildAndSend: true }, zigbeeClear: { seq, list: clearList }, });""", ) print("patched zigbee dispatch") with open(path, "w") as f: json.dump(flows, f) print("done")