Initial commit: CODESYS tree, integration docs, Node-RED scripts.

PLC application sources (NVL, rooms, boiler), HA/Node-RED integration guide, and NVL patch utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 21:21:56 +03:00
commit d98c44bfa3
27 changed files with 3844 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
#!/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")

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Use global context for nvlInState/nvlClearSeq (shared across Node-RED tabs)."""
import json
import shutil
from datetime import datetime
path = "/srv/docker-volumes/config/nodered/data/flows.json"
backup = path + ".bak-global-nvl-" + 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)
replacements = [
("flow.get('nvlInState')", "global.get('nvlInState')"),
('flow.get("nvlInState")', 'global.get("nvlInState")'),
("flow.set('nvlInState'", "global.set('nvlInState'"),
('flow.set("nvlInState"', 'global.set("nvlInState"'),
("flow.get('nvlClearSeq')", "global.get('nvlClearSeq')"),
("flow.set('nvlClearSeq'", "global.set('nvlClearSeq'"),
]
count = 0
for n in flows:
fn = n.get("func")
if not fn:
continue
original = fn
for old, new in replacements:
fn = fn.replace(old, new)
if fn != original:
n["func"] = fn
count += 1
print("patched", n.get("id"), n.get("name"))
with open(path, "w") as f:
json.dump(flows, f)
print("patched nodes:", count, "done")