PLC application sources (NVL, rooms, boiler), HA/Node-RED integration guide, and NVL patch utilities. Co-authored-by: Cursor <cursoragent@cursor.com>
168 lines
5.2 KiB
Python
168 lines
5.2 KiB
Python
#!/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")
|