Enecsys v1 Gateway → Home Assistant (MQTT) – WS/WZ Decoder (HTTP + TCP 5040)

Enecsys v1 Gateway → Home Assistant (MQTT) — WS/WZ Decoder (TCP 5040 only)

Decode Enecsys WS/WZ frames streamed by the v1 gateway over TCP :5040 and publish discovery-ready MQTT sensors for Home Assistant. Note: This flow does not poll /ajax.xml; it listens to the gateway only.

Gateway setup: On the gateway Main Settings, set “Remote Server Address or name” to your Node-RED / Home Assistant server IP. The flow opens a TCP server on port 5040.

What this flow does

Listens on TCP 5040 and decodes:

WS frames (e2pv 42-byte layout)

WZ frames (handles hex prefixes and CRC/serial suffixes)

Publishes a single retained JSON to enecsys/inverter/state with Home Assistant MQTT Discovery for:

Smoothed AC power, energy-delta power, voltage, frequency, temperature, total energy, and last_seen

Maintains availability via enecsys/bridge/state

Sends the required keep-alive back to the gateway to keep the stream open Works only v1 model gateaways like at image https://enecsys-monitoring.com/wp-content/uploads/gateway-670-700x700.png

[{"id":"a6e3dd72f6bb8f28","type":"tab","label":"Eski Enecsys TCP 5040 → MQTT (HA)","disabled":false,"info":""},{"id":"c3fe9b6d0d0ab09c","type":"tcp in","z":"a6e3dd72f6bb8f28","name":"TCP server :5040 (CR-delimited)","server":"server","host":"","port":"5040","datamode":"stream","datatype":"utf8","newline":"\\r","topic":"","trim":true,"base64":false,"x":160,"y":120,"wires":[["9417209f36d2c822","68782759232e45ef"]]},{"id":"9417209f36d2c822","type":"debug","z":"a6e3dd72f6bb8f28","name":"RAW TCP line","active":true,"tosidebar":true,"tostatus":false,"complete":"payload","targetType":"msg","x":140,"y":60,"wires":[]},{"id":"68782759232e45ef","type":"function","z":"a6e3dd72f6bb8f28","name":"Process line + keepalive","func":"// Logs session, sends keepalive if >200s, passes WS/WZ to decoder\n// Input: msg.payload is one CR-terminated line from gateway\n// Uses msg._session to reply via TCP out\n\nconst KEEPALIVE = \"0E0000000000cgAD83\\r\"; // same as e2pv script\nconst sid = msg._session && msg._session.id ? msg._session.id : \"global\";\nconst now = Date.now();\n\n// per-connection keepalive tracking\nlet ks = context.get('ks') || {};\nif (!ks[sid]) ks[sid] = { lastka: 0 };\n\n// if >200s since last keepalive, send one back\nlet kaMsg = null;\nif (now - ks[sid].lastka > 200000) {\n  kaMsg = { payload: KEEPALIVE, _session: msg._session };\n  ks[sid].lastka = now;\n}\ncontext.set('ks', ks);\n\n// expose session info for debug pane\nmsg.session = { id: sid, peer: (msg.ip || ''), port: (msg.port || '') };\n\n// Forward for decode only if it contains WS/WZ\nif (/W[ZS]=/.test(msg.payload)) {\n  return [msg, kaMsg];\n}\n// no WS/WZ line, still send keepalive if needed\nreturn [null, kaMsg];","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":120,"wires":[["b21c140bfdce8379","3f4290d9165c2e3f"],["15d25a161a88f40c"]]},{"id":"b21c140bfdce8379","type":"debug","z":"a6e3dd72f6bb8f28","name":"TCP session info","active":true,"tosidebar":true,"tostatus":true,"complete":"session","targetType":"msg","statusVal":"session.id & ' ' & session.peer","statusType":"jsonata","x":430,"y":60,"wires":[]},{"id":"3f4290d9165c2e3f","type":"function","z":"a6e3dd72f6bb8f28","name":"Decode WS/WZ (e2pv layout) → JSON","func":"// Decode WS/WZ from any position in the line (handles hex prefix & CRC/serial)\n// Outputs a single JSON state compatible with your HA discovery.\n\n// ---- helpers ----\nfunction extractFrame(line) {\n  // find first WS= or WZ=\n  const mType = line.match(/W([ZS])=/);\n  if (!mType) return null;\n  const type = mType[1];                 // 'S' or 'Z'\n  const start = line.indexOf(mType[0]);\n  const tail = line.slice(start);        // starts at 'WS=' or 'WZ='\n\n  // capture base64 up to \"=XX,S=\" (CRC + serial) OR end-of-string\n  // base64 may be URL-safe (- _), and gateway sometimes uses '*' as '='\n  const mB64 = tail.match(/^W[ZS]=([A-Za-z0-9+/_\\-*]+?)(?==[A-F0-9]{2},S=|$)/);\n  if (!mB64) return null;\n  let b64 = mB64[1].replace(/-/g,'+').replace(/_/g,'/').replace(/\\*/g,'=');\n  const rem = b64.length % 4; if (rem) b64 += '='.repeat(4 - rem);\n  return { type, b64 };\n}\n\nfunction smooth(ctxKey, val, N=5) {\n  let buf = context.get(ctxKey) || [];\n  buf.push(val);\n  if (buf.length > N) buf.shift();\n  context.set(ctxKey, buf);\n  return Math.round(buf.reduce((a,b)=>a+b,0)/buf.length);\n}\n\n// ---- main ----\nlet line = (msg.zigbeeData ?? msg.payload ?? '').toString().replace(/\\r|\\n/g,'').trim();\nif (!line) { node.status({fill:'yellow',shape:'ring',text:'empty line'}); return null; }\n\nconst fr = extractFrame(line);\nif (!fr) { node.status({fill:'yellow',shape:'ring',text:'no WS/WZ or b64'}); return null; }\n\nlet raw;\ntry { raw = Buffer.from(fr.b64, 'base64'); }\ncatch (e) { node.status({fill:'red',shape:'dot',text:'bad base64'}); return null; }\n\nlet out;\nif (fr.type === 'S') {\n  // ---- WS frame: exact e2pv layout, 42 bytes ----\n  if (raw.length !== 42) {\n    node.status({fill:'red',shape:'dot',text:`WS len ${raw.length} ≠ 42`});\n    return null;\n  }\n  const idDec        = raw.readUInt32LE(0);\n  const stateCode    = raw.readUInt8(22);\n  const dcCurrentRaw = raw.readUInt16BE(23);\n  const dcPowerRaw   = raw.readUInt16BE(25);\n  const effRaw       = raw.readUInt16BE(27);\n  const acFreq       = raw.readInt8(29);\n  const acVolt       = raw.readUInt16BE(30);\n  const tempC        = raw.readInt8(32);\n  const wh           = raw.readUInt16BE(33);\n  const kwh          = raw.readUInt16BE(35);\n\n  const dcCurrentA   = dcCurrentRaw * 0.025;\n  const efficiency   = effRaw * 0.001;\n  const acPowerW     = Math.round(dcPowerRaw * efficiency);\n  const dcVolt       = dcCurrentA > 0 ? (dcPowerRaw / dcCurrentA) : 0;\n  const energyWh     = (kwh * 1000) + wh;\n\n  // power from energy Δ\n  const now = Date.now();\n  const kLast = 'last_ws';\n  const last = context.get(kLast) || {};\n  let pFromEnergy;\n  if (last.energyWh !== undefined && last.ts) {\n    const dt = (now - last.ts)/1000;\n    if (dt > 0 && energyWh >= last.energyWh) {\n      pFromEnergy = ( (energyWh - last.energyWh) * 3600 ) / dt;\n    }\n  }\n  context.set(kLast, { energyWh, ts: now });\n\n  const pSmooth = smooth('buf_ws', acPowerW, 5);\n  const stateMap = {0:'normal',1:'low_light',3:'low_light_other'};\n\n  out = {\n    inverter_id: idDec,\n    state_code: stateCode,\n    state_text: stateMap[stateCode] || 'other',\n    dc_current_a: dcCurrentA,\n    dc_power_w: dcPowerRaw,\n    dc_voltage_v: Math.round(dcVolt*100)/100,\n    efficiency_pct: Math.round(efficiency*1000)/10,\n    ac_power_w: acPowerW,\n    ac_power_w_smoothed: pSmooth,\n    ac_power_w_from_energy: pFromEnergy,\n    ac_voltage_v: acVolt,\n    ac_frequency_hz: acFreq,\n    temperature_c: tempC,\n    energy_wh: energyWh,\n    last_seen: new Date().toISOString()\n  };\n  node.status({fill:'green',shape:'dot',text:`WS id ${idDec} P ${pSmooth}W`});\n\n} else {\n  // ---- WZ frame: short layout (>=20 bytes); offsets like your Python ----\n  if (raw.length < 20) {\n    node.status({fill:'red',shape:'dot',text:`WZ short (${raw.length})`});\n    return null;\n  }\n  const power_u16  = raw.readUInt16BE(8);\n  const volt_u16   = raw.readUInt16BE(10);\n  const freq_u16   = raw.readUInt16BE(12);\n  const temp_u16   = raw.readUInt16BE(14);\n  const energy_u32 = raw.readUInt32BE(16);\n\n  // Divisors from your Python (tune as needed)\n  const VOLT_DIV = 33.4;\n  const FREQ_DIV = 169.0;\n  const TEMP_DIV = 10.0;\n\n  const instP   = power_u16;\n  const now     = Date.now();\n  const kLast   = 'last_wz';\n  const last    = context.get(kLast) || {};\n  let pFromEnergy;\n  if (last.energy !== undefined && last.ts) {\n    const dt = (now - last.ts)/1000;\n    if (dt > 0 && energy_u32 >= last.energy) {\n      pFromEnergy = ( (energy_u32 - last.energy) * 3600 ) / dt;\n    }\n  }\n  context.set(kLast, { energy: energy_u32, ts: now });\n\n  const pSmooth = smooth('buf_wz', instP, 5);\n\n  out = {\n    // WZ doesn’t carry the same id/state; we publish core measurements:\n    ac_power_w: instP,\n    ac_power_w_smoothed: pSmooth,\n    ac_power_w_from_energy: pFromEnergy,\n    ac_voltage_v: VOLT_DIV ? volt_u16 / VOLT_DIV : volt_u16,\n    ac_frequency_hz: FREQ_DIV ? freq_u16 / FREQ_DIV : freq_u16,\n    temperature_c: TEMP_DIV ? temp_u16 / TEMP_DIV : temp_u16,\n    energy_wh: energy_u32,\n    last_seen: new Date().toISOString(),\n    raw_hex: raw.toString('hex').match(/.{1,2}/g).join(' ')\n  };\n  node.status({fill:'green',shape:'dot',text:`WZ P ${pSmooth}W V ${out.ac_voltage_v.toFixed(1)}V`});\n}\n\nmsg.payload = out;\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":160,"wires":[["ec101a66f7269e96","8ffed7a23b9425e0","aef4a292a8b2a234"]]},{"id":"ec101a66f7269e96","type":"debug","z":"a6e3dd72f6bb8f28","name":"Decoded JSON","active":true,"tosidebar":true,"complete":"payload","targetType":"msg","x":680,"y":60,"wires":[]},{"id":"8ffed7a23b9425e0","type":"function","z":"a6e3dd72f6bb8f28","name":"Publish JSON state (retain)","func":"return [[{ topic: 'enecsys/inverter/state', payload: JSON.stringify(msg.payload), retain: true }]];","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":740,"y":160,"wires":[["7bc63f80f80b7a0b","fbc5c5d5665008ec"]]},{"id":"fbc5c5d5665008ec","type":"debug","z":"a6e3dd72f6bb8f28","name":"MQTT state payload","active":true,"tosidebar":true,"complete":"payload","targetType":"msg","x":960,"y":120,"wires":[]},{"id":"aef4a292a8b2a234","type":"trigger","z":"a6e3dd72f6bb8f28","name":"Availability online now, offline after 180s","op1":"online","op2":"offline","duration":"180","extend":true,"units":"s","outputs":1,"x":480,"y":220,"wires":[["ad38d4c67bbbcca1"]]},{"id":"ad38d4c67bbbcca1","type":"change","z":"a6e3dd72f6bb8f28","name":"topic enecsys/bridge/state (retain)","rules":[{"t":"set","p":"topic","pt":"msg","to":"enecsys/bridge/state","tot":"str"},{"t":"set","p":"retain","pt":"msg","to":"true","tot":"bool"}],"x":800,"y":220,"wires":[["7bc63f80f80b7a0b"]]},{"id":"15d25a161a88f40c","type":"tcp out","z":"a6e3dd72f6bb8f28","name":"TCP keepalive reply","host":"","port":"","base64":false,"end":false,"x":140,"y":180,"wires":[]},{"id":"7bc63f80f80b7a0b","type":"mqtt out","z":"a6e3dd72f6bb8f28","name":"MQTT publish","topic":"","qos":"0","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"mqtt_broker_tcp","x":1000,"y":160,"wires":[]},{"id":"a2b91bd9c326c601","type":"inject","z":"a6e3dd72f6bb8f28","name":"Send HA Discovery on deploy/start","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":"0.5","topic":"","payload":"","payloadType":"str","x":180,"y":300,"wires":[["bd096a5f2610802b"]]},{"id":"bd096a5f2610802b","type":"function","z":"a6e3dd72f6bb8f28","name":"HA Discovery (explicit topics)","func":"const DISCOVERY_PREFIX = 'homeassistant';\nconst DEV_ID = 'enecsys_inverter';\nconst DEVICE = { identifiers: [DEV_ID], manufacturer: 'Enecsys', model: 'Inverter', name: 'Enecsys Inverter' };\nconst AVAIL_TOPIC = 'enecsys/bridge/state';\nconst STATE_TOPIC = 'enecsys/inverter/state';\nconst EXPIRE = 300;\nfunction cfg(oid, name, tpl, unit, dclass, sclass, extra){\n  const o = { name:`Enecsys ${name}`, unique_id:`${DEV_ID}_${oid}`, state_topic:STATE_TOPIC, value_template:tpl, availability_topic:AVAIL_TOPIC, device:DEVICE, expire_after:EXPIRE };\n  if(unit) o.unit_of_measurement = unit; if(dclass) o.device_class = dclass; if(sclass) o.state_class = sclass; if(extra) Object.assign(o, extra); return o; }\nconst msgs = [];\nmsgs.push({ topic: `${DISCOVERY_PREFIX}/sensor/${DEV_ID}_power/config`, payload: JSON.stringify(cfg('power','Power','{{ value_json.ac_power_w_smoothed }}','W','power','measurement',{icon:'mdi:solar-power'})), retain: true });\nmsgs.push({ topic: `${DISCOVERY_PREFIX}/sensor/${DEV_ID}_voltage/config`, payload: JSON.stringify(cfg('voltage','Voltage','{{ value_json.ac_voltage_v }}','V','voltage','measurement')), retain: true });\nmsgs.push({ topic: `${DISCOVERY_PREFIX}/sensor/${DEV_ID}_frequency/config`, payload: JSON.stringify(cfg('frequency','Frequency','{{ value_json.ac_frequency_hz }}','Hz','frequency','measurement')), retain: true });\nmsgs.push({ topic: `${DISCOVERY_PREFIX}/sensor/${DEV_ID}_temperature/config`, payload: JSON.stringify(cfg('temperature','Temperature','{{ value_json.temperature_c }}','°C','temperature','measurement')), retain: true });\nmsgs.push({ topic: `${DISCOVERY_PREFIX}/sensor/${DEV_ID}_energy/config`, payload: JSON.stringify(cfg('energy','Energy Total','{{ value_json.energy_wh }}','Wh','energy','total_increasing',{icon:'mdi:solar-power'})), retain: true });\nmsgs.push({ topic: `${DISCOVERY_PREFIX}/sensor/${DEV_ID}_last_seen/config`, payload: JSON.stringify(cfg('last_seen','Last Seen','{{ value_json.last_seen }}',null,'timestamp',null,{entity_category:'diagnostic'})), retain: true });\nmsgs.push({ topic: `${DISCOVERY_PREFIX}/sensor/${DEV_ID}_state/config`, payload: JSON.stringify(cfg('state','State','{{ value_json.state_text }}',null,null,null,{entity_category:'diagnostic'})), retain: true });\nreturn [msgs];","outputs":1,"noerr":0,"libs":[],"x":520,"y":300,"wires":[["7bc63f80f80b7a0b"]]},{"id":"mqtt_broker_tcp","type":"mqtt-broker","name":"HA MQTT","broker":"192.168.0.200","port":"1883","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":true,"autoUnsubscribe":true,"birthTopic":"encesys/bridge/state","birthQos":"0","birthPayload":"online","birthMsg":{},"closeTopic":"encesys/bridge/state","closeQos":"0","closePayload":"offline","closeMsg":{},"willTopic":"encesys/bridge/state","willQos":"0","willPayload":"offline","willMsg":{},"userProps":"","sessionExpiry":""}]

Flow Info

Created 6 months, 3 weeks ago
Rating: not yet rated

Owner

Actions

Rate:

Node Types

Core
  • change (x1)
  • debug (x4)
  • function (x4)
  • inject (x1)
  • mqtt out (x1)
  • mqtt-broker (x1)
  • tcp in (x1)
  • tcp out (x1)
  • trigger (x1)
Other
  • tab (x1)

Tags

  • enecsys
  • mqtt
  • homeassistant
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option