Auf dem Core Pro ist eine 120Ohm Terminierung standardmäßig gesetzt per Jumper.
Du musst in der /boot/firmware/config.txt das hier einfügen, um den CAN-bus zu aktivieren:
# activate CAN interface
dtparam=spi=on
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25
dtoverlay=spi-bcm2835
Am besten ganz ans Ende, bzw. im Bereich von “[all]”. Neustarten dann nicht vergessen.
In NR mach ich es dann folgendermaßen:
Aus der Join-Node ganz am Ende kommt dann ein Object raus mit allen Daten, die ankommen auf dem CAN-bus, fertig interpretiert nach dem Victron BMS Protokoll in diesem Fall.
Der Flow geht noch weiter, mit Dashboard Nodes etc. und einem automatischen Reset/Neustart des CAN-bus, sollten 5min lang keine Daten mehr kommen. Ich hänge den kompletten Flow mit an ist noch nicht final, aber wird so bzw. so ähnlich ins Image kommen.
[{"id":"5434bb2220a4967c","type":"tab","label":"Flow 1","disabled":false,"info":"","env":[]},{"id":"ab82272997b3a62f","type":"exec","z":"5434bb2220a4967c","command":"candump can0","addpay":"","append":"","useSpawn":"true","timer":"","winHide":false,"oldrc":false,"name":"","x":360,"y":120,"wires":[["3f36b14dfbd14a5b","e9ad06d7282fb1b5"],[],[]]},{"id":"94fc24085f2d6b90","type":"inject","z":"5434bb2220a4967c","name":"start","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":150,"y":120,"wires":[["ab82272997b3a62f"]]},{"id":"3f36b14dfbd14a5b","type":"debug","z":"5434bb2220a4967c","name":"debug 11","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":540,"y":80,"wires":[]},{"id":"2393357f4d335765","type":"function","z":"5434bb2220a4967c","name":"get single frame","func":"const line = msg.payload.trim();\nconst match = line.match(/can0\\s+([0-9A-F]+)\\s+\\[(\\d+)\\]\\s+([0-9A-F\\s]+)/i);\n\nif (!match) {\n // Ignore if incoming data is not a valid CAN frame\n return null;\n}\n\nconst canid = parseInt(match[1], 16);\nconst dlc = parseInt(match[2], 10);\nconst bytes = match[3].trim().split(/\\s+/).map(x => parseInt(x, 16));\n\nmsg.payload = {\n timestamp: Date.now(),\n ext: false,\n canid,\n dlc,\n rtr: false,\n data: bytes,\n rawData: bytes.map(b => `0x${b.toString(16).padStart(2, \"0\")}`)\n};\n\n// Set the topic to the incoming CAN-Id\nmsg.topic = canid\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":680,"y":120,"wires":[["fa9dcd9c440a5c0c","aa3b32fc48cea7d7","e0f8a3d625bcb2d2"]]},{"id":"e9ad06d7282fb1b5","type":"split","z":"5434bb2220a4967c","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","property":"payload","x":530,"y":120,"wires":[["2393357f4d335765"]]},{"id":"fa9dcd9c440a5c0c","type":"debug","z":"5434bb2220a4967c","name":"Parsed Output","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":880,"y":80,"wires":[]},{"id":"aa3b32fc48cea7d7","type":"function","z":"5434bb2220a4967c","name":"CAN-bus BMS Protocol Victron (combined CAN-IDs)","func":"// Combined CAN-BMS parser (Victron spec)\n// Emits ONE msg per incoming frame with a small, well-named object.\n// Downstream: use a Join node (manual mode) to merge objects and emit after 0.5s idle.\n\nconst canid = msg.payload?.canid ?? msg.canid;\nconst b = Buffer.isBuffer(msg.payload?.data) ? msg.payload.data\n : Buffer.isBuffer(msg.data) ? msg.data\n : Buffer.from(msg.payload?.data || []);\n\nif (typeof canid !== 'number' || b.length === 0) return null;\n\n// helpers\nconst u16LE = (off) => b.readUInt16LE(off);\nconst i16LE = (off) => b.readInt16LE(off);\nconst u32LE = (off) => b.readUInt32LE(off);\n\nconst toAscii = (buf8) => Buffer.from(buf8).toString('ascii').replace(/\\u0000+$/g, '').trim();\nconst toAscii7 = (buf8) => Buffer.from([...buf8].map(x => x & 0x7F)).toString('ascii').replace(/\\u0000+$/g, '').trim();\nconst cleanAscii = (buf8) => {\n let s = toAscii(buf8);\n if (/[^ -~]/.test(s)) s = toAscii7(buf8);\n return s;\n};\n\n// build one small object per frame; keep unique key names so Join can merge cleanly\nlet out = null;\n\n// ---------------- core frames ----------------\nswitch (canid) {\n case 0x351: {\n const chargeVoltageLimit_V = u16LE(0) * 0.1; // CVL\n let maxChargeCurrent_A = Math.abs(i16LE(2)) * 0.1; // CCL (positive)\n let maxDischargeCurrent_A = Math.abs(i16LE(4)) * 0.1; // DCL (positive)\n const batteryDischargeVoltageMin_V = u16LE(6) * 0.1; // not used by Victron\n\n flow.set('last_cvl_V', Number(chargeVoltageLimit_V.toFixed(1)));\n \n out = {\n chargeVoltageLimit_V: Number(chargeVoltageLimit_V.toFixed(1)),\n maxChargeCurrent_A: Number(maxChargeCurrent_A.toFixed(1)),\n maxDischargeCurrent_A: Number(maxDischargeCurrent_A.toFixed(1)),\n batteryDischargeVoltageMin_V: Number(batteryDischargeVoltageMin_V.toFixed(1))\n };\n break;\n }\n\n case 0x355: {\n const soc_percent = u16LE(0);\n const soh_percent = b.length >= 4 ? u16LE(2) : null;\n const highres_soc_percent = b.length >= 6 ? u16LE(4) * 0.01 : null;\n out = {\n soc_percent,\n ...(soh_percent !== null ? { soh_percent } : {}),\n ...(highres_soc_percent !== null ? { highres_soc_percent: Number(highres_soc_percent.toFixed(2)) } : {})\n };\n break;\n }\n\n case 0x356: {\n const voltage_V = i16LE(0) * 0.01;\n const current_A = i16LE(2) * 0.1; // negative = discharge\n const temperature_C= i16LE(4) * 0.1;\n // remember last V & T for alarm plausibility\n flow.set('last_voltage_V', Number(voltage_V.toFixed(2)));\n flow.set('last_temperature_C', Number(temperature_C.toFixed(1)));\n \n out = {\n voltage_V: Number(voltage_V.toFixed(2)),\n current_A: Number(current_A.toFixed(2)),\n temperature_C: Number(temperature_C.toFixed(1))\n };\n break;\n }\n\n case 0x35A: {\n // --- helpers ---\n const pick2bits = (byte, pairIdx, msbFirst) => {\n // pairIdx 0..3\n if (!msbFirst) {\n // LSB-first: pairs are bits (0..1), (2..3), (4..5), (6..7)\n return ((b[byte] ?? 0) >> (pairIdx * 2)) & 0b11;\n } else {\n // MSB-first: pairs are bits (6..7), (4..5), (2..3), (0..1)\n const shift = 6 - (pairIdx * 2);\n return ((b[byte] ?? 0) >> shift) & 0b11;\n }\n };\n\n const decode = (code, reversed) => {\n // normal: 10 = active, 01 = ok; reversed flips those\n if (!reversed) return code === 0b10 ? 'active' : (code === 0b01 ? 'ok' : 'unsupported');\n else return code === 0b01 ? 'active' : (code === 0b10 ? 'ok' : 'unsupported');\n };\n\n const build = (msbFirst, reversed) => {\n const P = (byte, idx) => decode(pick2bits(byte, idx, msbFirst), reversed);\n const alarms = {\n highVoltage: P(0, 1),\n lowVoltage: P(0, 2),\n highTemp: P(0, 3),\n lowTemp: P(1, 0),\n highTempCharge: P(1, 1),\n lowTempCharge: P(1, 2),\n highCurrent: P(1, 3),\n highChargeCurrent: P(2, 0),\n contactor: P(2, 1),\n shortCircuit: P(2, 2),\n bmsInternal: P(2, 3),\n cellImbalance: P(3, 0),\n };\n const warnings = {\n highVoltage: P(4, 1),\n lowVoltage: P(4, 2),\n highTemp: P(4, 3),\n lowTemp: P(5, 0),\n highTempCharge: P(5, 1),\n lowTempCharge: P(5, 2),\n highCurrent: P(5, 3),\n highChargeCurrent: P(6, 0),\n contactor: P(6, 1),\n shortCircuit: P(6, 2),\n bmsInternal: P(6, 3),\n cellImbalance: P(7, 0),\n };\n const sysPair = pick2bits(7, 1, msbFirst); // byte7 bits 2–3\n const system_status = decode(sysPair, reversed);\n\n const anyActive = (o)=> Object.values(o).some(v=>v==='active');\n return {\n alarms, warnings, system_status,\n anyAlarmActive: anyActive(alarms),\n anyWarningActive: anyActive(warnings),\n _mode: { msbFirst, reversed }\n };\n };\n\n // --- candidate decodes ---\n const cand = [\n build(false, false), // LSB + normal\n build(false, true ), // LSB + reversed\n build(true , false), // MSB + normal\n build(true , true ), // MSB + reversed\n ];\n\n // --- plausibility scoring ---\n const V = flow.get('last_voltage_V'); // set in 0x356 case (we already do this)\n const T = flow.get('last_temperature_C');\n const CVL= flow.get('last_cvl_V'); // capture in 0x351 case (see note below)\n // infer nominal from CVL if available (very rough thresholds)\n const nominal = (CVL && CVL < 20) ? 12 : (CVL && CVL < 65) ? 48 : null;\n\n function score(c) {\n let s = 0;\n // Voltage plausibility (rough bands; tweak if needed)\n if (typeof V === 'number') {\n if (nominal === 12) {\n if (c.alarms.highVoltage === 'active' && V < 14.6) s -= 2;\n if (c.alarms.lowVoltage === 'active' && V > 11.0) s -= 2;\n } else if (nominal === 48) {\n if (c.alarms.highVoltage === 'active' && V < 58.4) s -= 2;\n if (c.alarms.lowVoltage === 'active' && V > 44.0) s -= 2;\n }\n }\n // Temperature plausibility\n if (typeof T === 'number') {\n if (c.alarms.highTemp === 'active' && T < 45) s -= 2;\n if (c.alarms.lowTemp === 'active' && T > 0) s -= 2;\n }\n // Prefer fewer \"active\" overall\n const act = Object.values(c.alarms).filter(v => v === 'active').length;\n s -= act * 0.5;\n return s;\n }\n\n // force override if set (per brand)\n const forced = flow.get('bms_alarm_encoding'); // e.g. 'lsb-normal' | 'lsb-rev' | 'msb-normal' | 'msb-rev'\n const forcedMap = {\n 'lsb-normal': 0, 'lsb-rev': 1, 'msb-normal': 2, 'msb-rev': 3\n };\n\n let chosen = cand[0];\n if (forced && forcedMap.hasOwnProperty(forced)) {\n chosen = cand[forcedMap[forced]];\n } else {\n // pick best by score\n let best = -Infinity, bestIdx = 0;\n cand.forEach((c, i) => { const sc = score(c); if (sc > best) { best = sc; bestIdx = i; }});\n chosen = cand[bestIdx];\n }\n\n // emit & annotate which mapping won\n out = {\n ...chosen,\n alarm_encoding: forced || (\n chosen._mode.msbFirst\n ? (chosen._mode.reversed ? 'msb-rev' : 'msb-normal')\n : (chosen._mode.reversed ? 'lsb-rev' : 'lsb-normal')\n )\n };\n delete out._mode;\n\n break;\n }\n\n // ---------------- identity & meta ----------------\n case 0x35E: {\n const bms_name_fallback = cleanAscii(b.slice(0,8));\n out = {bms_name_fallback };\n break;\n }\n\n case 0x35F: {\n const model_id = u16LE(0);\n const firmware_raw_BE = b.readUInt16BE(2); // MSB first\n const fwMajor = (firmware_raw_BE >> 8) & 0xFF;\n const fwMinor = firmware_raw_BE & 0xFF;\n const firmware_version = `${fwMajor}.${fwMinor}`;\n const onlineCapacity_Ah = u16LE(4);\n // stash online for later delta vs installed\n flow.set('onlineCapacity_Ah', onlineCapacity_Ah);\n out = {\n model_id,\n firmware_version,\n firmware_raw_BE: firmware_raw_BE,\n onlineCapacity_Ah\n };\n break;\n }\n\n case 0x370:\n case 0x371: {\n // 16-char name: 0x370 first half, 0x371 second half\n const part = cleanAscii(b.slice(0, 8));\n\n if (canid === 0x370) flow.set('name370', part);\n if (canid === 0x371) {\n flow.set('name371', part);\n\n const p1 = flow.get('name370') || '';\n const p2 = part; // freshly received\n const deviceName_16 = (p1 + p2).slice(0, 16).trim();\n\n // only emit when full name available\n out = {\n bms_name_16: deviceName_16,\n bms_name_part1: p1,\n bms_name_part2: p2\n };\n }\n break;\n }\n\n // ---------------- module status & cell info ----------------\n case 0x372: {\n const nz = (v)=> v===0xFFFF ? null : v;\n out = {\n modules_ok: nz(u16LE(0)),\n modules_blocking_charge: nz(u16LE(2)),\n modules_blocking_discharge: nz(u16LE(4)),\n modules_offline: nz(u16LE(6))\n };\n break;\n }\n\n case 0x373: {\n const min_cell_mV = u16LE(0);\n const max_cell_mV = u16LE(2);\n const lowest_temp_K = u16LE(4);\n const highest_temp_K= u16LE(6);\n const toC = (K)=> (typeof K==='number') ? (K - 273.15) : null;\n out = {\n min_cell_mV,\n max_cell_mV,\n lowest_temp_K,\n highest_temp_K,\n lowest_temp_C: Number(toC(lowest_temp_K).toFixed(1)),\n highest_temp_C: Number(toC(highest_temp_K).toFixed(1))\n };\n break;\n }\n\n case 0x374:\n case 0x375:\n case 0x376:\n case 0x377: {\n const labelMap = {\n 0x374: 'min_cell_voltage_id',\n 0x375: 'max_cell_voltage_id',\n 0x376: 'min_cell_temp_id',\n 0x377: 'max_cell_temp_id'\n };\n const idTxt = cleanAscii(b.slice(0,8));\n out = {[labelMap[canid]]: idTxt };\n break;\n }\n\n // ---------------- energy & capacity ----------------\n case 0x378: {\n const energy_in_100Wh = u32LE(0);\n const energy_out_100Wh = u32LE(4);\n out = {\n energy_in_100Wh,\n energy_out_100Wh,\n energy_in_kWh: Number((energy_in_100Wh * 0.1).toFixed(1)),\n energy_out_kWh: Number((energy_out_100Wh * 0.1).toFixed(1))\n };\n break;\n }\n\n case 0x379: {\n const installedCapacity_Ah = u16LE(0);\n flow.set('installedCapacity_Ah', installedCapacity_Ah);\n\n const online = flow.get('onlineCapacity_Ah');\n const offlineCapacity_Ah = (typeof online === 'number')\n ? Math.max(0, installedCapacity_Ah - online)\n : null;\n\n out = {\n installedCapacity_Ah,\n ...(offlineCapacity_Ah !== null ? { offlineCapacity_Ah } : {})\n };\n break;\n }\n\n // ---------------- serial & family ----------------\n case 0x380:\n case 0x381: {\n const part = cleanAscii(b.slice(0,8));\n if (canid === 0x380) flow.set('sn1', part);\n if (canid === 0x381) flow.set('sn2', part);\n const sn = ((flow.get('sn1') || '') + (flow.get('sn2') || '')).slice(0,16).trim();\n out = {\n serial_number_part: part,\n ...(sn ? { serial_number: sn } : {})\n };\n break;\n }\n\n case 0x382: {\n const battery_family = cleanAscii(b.slice(0,8));\n out = {battery_family };\n break;\n }\n}\n\n// if we recognized the canid, emit; else ignore\nif (!out) return null;\nmsg.topic = `0x${canid.toString(16)}`; // good for debugging in Debug node\nmsg.payload = out; // no .source anywhere\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1000,"y":120,"wires":[["7c47df8d31819221"]]},{"id":"7c47df8d31819221","type":"join","z":"5434bb2220a4967c","name":"","mode":"custom","build":"merged","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","useparts":false,"accumulate":false,"timeout":"0.5","count":"","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"num","reduceFixup":"","x":1270,"y":120,"wires":[["d460a6880c9dc87d","1dc5600ba9312622"]]},{"id":"d460a6880c9dc87d","type":"debug","z":"5434bb2220a4967c","name":"Parsed Werte","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1420,"y":80,"wires":[]},{"id":"1dc5600ba9312622","type":"function","z":"5434bb2220a4967c","name":"add custom calculations & order","func":"// Post-Join: derive remaining capacity from SoC, add timestamps, stale detection\n\n// --- Energy helpers (0x378) ---\nfunction u32Delta(prev, cur) {\n if (typeof prev !== 'number') return 0;\n if (cur >= prev) return cur - prev; // normal\n const MAX = 0xFFFFFFFF; // rollover\n return (MAX - prev + 1) + cur;\n}\n\n// --- Config ---\nconst STALE_THRESHOLD_MS = 5000; // >5s without a joined update => stale\nconst ROUND_AH = 1;\n\n// --- Input ---\nconst p = msg.payload || {};\nconst soc = (typeof p.soc_percent === 'number') ? p.soc_percent : null;\n\nconst bmsInstalled = (typeof p.installedCapacity_Ah === 'number') ? p.installedCapacity_Ah : null; // 0x379\nconst bmsOnline = (typeof p.onlineCapacity_Ah === 'number') ? p.onlineCapacity_Ah : null; // 0x35F\n\n// --- Derivations ---\nlet remaining_fromInstalled_Ah = null;\nlet remaining_fromOnline_Ah = null;\nlet usedCapacity_Ah_est = null;\n\nif (typeof soc === 'number' && typeof bmsInstalled === 'number') {\n remaining_fromInstalled_Ah = +(bmsInstalled * (soc / 100)).toFixed(ROUND_AH);\n usedCapacity_Ah_est = +((bmsInstalled - remaining_fromInstalled_Ah)).toFixed(ROUND_AH);\n}\n\nif (typeof soc === 'number' && typeof bmsOnline === 'number') {\n // If your BMS reports online ~ installed*SoC, this will mirror that.\n // Keep it explicit so semantics stay clear.\n remaining_fromOnline_Ah = +(bmsOnline * (soc / 100)).toFixed(ROUND_AH);\n}\n\n// Health-style utilization: how much of installed capacity is currently online\nlet capacity_utilization_percent = null;\nif (typeof bmsInstalled === 'number' && bmsInstalled > 0 && typeof bmsOnline === 'number') {\n capacity_utilization_percent = +((bmsOnline / bmsInstalled) * 100).toFixed(1);\n}\n\n// --- Timestamps ---\nconst nowMs = Date.now();\n\nconst timestamp_ms = nowMs;\nconst timestamp_iso = new Date(nowMs).toISOString();\n\n// --- Instantaneous power ---\nconst voltage_V = (typeof p.voltage_V === 'number') ? p.voltage_V : null;\nconst current_A = (typeof p.current_A === 'number') ? p.current_A : null;\nlet power_W = null, power_state = 'Unknown';\nif (typeof voltage_V === 'number' && typeof current_A === 'number') {\n power_W = Math.round(voltage_V * current_A * 10) / 10;\n const absPower = Math.abs(power_W);\n power_W = absPower > 1000 ? Math.round(power_W) : Math.round(power_W * 10) / 10;\n power_state = current_A > 0.1 ? 'Charging' : current_A < -0.1 ? 'Discharging' : 'Idle';\n}\n\n// --- Output (single assignment, ordered & grouped) ---\nmsg.payload = {\n meta: {\n timestamp_ms,\n timestamp_iso,\n\n model_id: p.model_id,\n firmware_version: p.firmware_version,\n firmware_raw_BE: p.firmware_raw_BE,\n serial_number: p.serial_number,\n serial_number_part: p.serial_number_part,\n battery_family: p.battery_family,\n bms_name_fallback: p.bms_name_fallback,\n bms_name_16: p.bms_name_16 ?? null,\n bms_name_part1: p.bms_name_part1 ?? null,\n bms_name_part2: p.bms_name_part2 ?? null\n },\n\n capacity: {\n // BMS truth\n installed_Ah: p.installedCapacity_Ah ?? null,\n online_Ah: p.onlineCapacity_Ah ?? null,\n offline_Ah: p.offlineCapacity_Ah ?? null,\n\n // Effective & override\n bmsInstalled_Ah: (typeof bmsInstalled === 'number') ? bmsInstalled : null,\n\n // SoC-derived estimates\n remaining_fromInstalled_Ah: remaining_fromInstalled_Ah,\n remaining_fromOnline_Ah: remaining_fromOnline_Ah,\n used_est_Ah: usedCapacity_Ah_est,\n\n // Health style\n capacity_utilization_percent,\n soc_percent: p.soc_percent,\n soh_percent: p.soh_percent,\n highres_soc_percent: p.highres_soc_percent\n },\n\n electrical: {\n voltage_V: p.voltage_V,\n current_A: p.current_A,\n chargeVoltageLimit_V: p.chargeVoltageLimit_V,\n maxChargeCurrent_A: p.maxChargeCurrent_A,\n maxDischargeCurrent_A: p.maxDischargeCurrent_A,\n batteryDischargeVoltageMin_V: p.batteryDischargeVoltageMin_V,\n\n // Instantaneous power and state\n power_W: power_W,\n power_state: power_state,\n },\n\n thermal: {\n temperature_C: p.temperature_C,\n lowest_temp_K: p.lowest_temp_K,\n highest_temp_K: p.highest_temp_K,\n lowest_temp_C: p.lowest_temp_C,\n highest_temp_C: p.highest_temp_C\n },\n\n cell_info: {\n min_cell_mV: p.min_cell_mV,\n max_cell_mV: p.max_cell_mV,\n min_cell_voltage_id: p.min_cell_voltage_id,\n max_cell_voltage_id: p.max_cell_voltage_id,\n min_cell_temp_id: p.min_cell_temp_id,\n max_cell_temp_id: p.max_cell_temp_id\n },\n\n module_status: {\n modules_ok: p.modules_ok,\n modules_blocking_charge: p.modules_blocking_charge,\n modules_blocking_discharge: p.modules_blocking_discharge,\n modules_offline: p.modules_offline\n },\n\n alarms: {\n summary: {\n anyAlarmActive: p.anyAlarmActive,\n anyWarningActive: p.anyWarningActive,\n system_status: p.system_status,\n alarm_encoding: p.alarm_encoding ?? null,\n },\n details: {\n alarms: p.alarms,\n warnings: p.warnings\n }\n }\n};\n\nglobal.set(\"canbus_BMS\", msg.payload)\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1470,"y":120,"wires":[["572560a665797336","e8bcb5ba0696edf2","2a2a19d3b39d2c08","d12df3ae2cd37871","3058cad586a49838","0b0cf1f63163411e","7326fad341383309","531e3e3cf679a0b6","61cbde4d55a8083a","cace18ac4081439e","52a6d3f36e3e61ce"]]},{"id":"572560a665797336","type":"debug","z":"5434bb2220a4967c","name":"Parsed","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1720,"y":80,"wires":[]},{"id":"e8bcb5ba0696edf2","type":"ui_template","z":"5434bb2220a4967c","group":"255107b99d3a7830","name":"SoC Template","order":1,"width":0,"height":0,"format":"<style>\n .battery-container {\n margin-top: 5px;\n width: 100%;\n max-width: 300px;\n /* Adjust width as needed */\n height: 10px;\n /* Line height */\n background: #2d3136;\n /* Base line color */\n position: relative;\n border-radius: 5px;\n overflow: hidden;\n }\n\n .battery-fill {\n height: 100%;\n width: 0%;\n transition: width 0.5s ease-in-out;\n background: linear-gradient(90deg, rgba(0, 255, 0, 0.7), rgba(0, 255, 0, 1), rgba(0, 255, 0, 0.7));\n box-shadow: 0px 0px 10px rgba(0, 255, 0, 0.7);\n }\n\n .battery-percentage {\n margin-top: 5px;\n font-size: 16px;\n font-weight: bold;\n text-align: center;\n }\n</style>\n\n<div class=\"battery-container\">\n <div id=\"batteryFill\" class=\"battery-fill\"></div>\n</div>\n<div class=\"battery-percentage\" id=\"batteryPercentage\">0%</div>\n\n<script>\n (function(scope) {\n scope.$watch('msg.payload.capacity.soc_percent', function(soc) {\n if (typeof soc === 'number' && soc >= 0 && soc <= 100) {\n let fill = document.getElementById(\"batteryFill\");\n let percentText = document.getElementById(\"batteryPercentage\");\n\n fill.style.width = soc + \"%\";\n percentText.innerHTML = `\n <span style=\"float: left; margin-left: 5px;\">State of Charge</span> \n <span style=\"float: right; margin-right: 5px;\">${soc}%</span>\n `;\n\n // Change glow color based on SOC level\n if (soc < 20) {\n fill.style.background = \"linear-gradient(90deg, rgba(255,0,0,0.7), rgba(255,0,0,1), rgba(255,0,0,0.7))\";\n fill.style.boxShadow = \"0px 0px 20px rgba(255, 0, 0, 1), 0px 0px 40px rgba(255, 0, 0, 0.8), 0px 0px 60px rgba(255, 0, 0, 0.6)\";\n\n } else if (soc < 50) {\n fill.style.background = \"linear-gradient(90deg, rgba(255,165,0,0.7), rgba(255,165,0,1), rgba(255,165,0,0.7))\";\n fill.style.boxShadow = \"0px 0px 20px rgba(255, 165, 0, 1), 0px 0px 40px rgba(255, 165, 0, 0.8), 0px 0px 60px rgba(255, 165, 0, 0.6)\";\n } else {\n fill.style.background = \"linear-gradient(90deg, rgba(0,255,0,0.7), rgba(0,255,0,1), rgba(0,255,0,0.7))\";\n fill.style.boxShadow = \"0px 0px 20px rgba(0, 255, 0, 1), 0px 0px 40px rgba(0, 255, 0, 0.8), 0px 0px 60px rgba(0, 255, 0, 0.6)\";\n }\n }\n });\n })(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"md-card","x":1740,"y":160,"wires":[[]]},{"id":"3058cad586a49838","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":2,"width":"2","height":"1","name":"power_w","label":"","format":"{{msg.payload.electrical.power_W}} W","layout":"col-center","className":"","style":false,"font":"","fontSize":"","color":"#000000","x":1720,"y":280,"wires":[]},{"id":"0b0cf1f63163411e","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":5,"width":"0","height":"0","name":"charging state","label":"Charging state:","format":"{{msg.payload.electrical.power_state}}","layout":"row-spread","className":"","style":false,"font":"","fontSize":"","color":"#000000","x":1740,"y":320,"wires":[]},{"id":"e03dbd91c52a4eb7","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":6,"width":0,"height":0,"name":"ttgo","label":"{{msg.payload.ttg.labelBadge}}","format":"{{msg.payload.ttg.pretty}}","layout":"col-center","className":"","style":false,"font":"","fontSize":"","color":"#000000","x":2350,"y":120,"wires":[]},{"id":"2a2a19d3b39d2c08","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":3,"width":"2","height":"1","name":"current_V","label":"","format":"{{msg.payload.electrical.voltage_V}} V","layout":"col-center","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":1720,"y":200,"wires":[]},{"id":"d12df3ae2cd37871","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":4,"width":"2","height":"1","name":"current_A","label":"","format":"{{msg.payload.electrical.current_A}} A","layout":"col-center","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":1720,"y":240,"wires":[]},{"id":"531e3e3cf679a0b6","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":8,"width":"2","height":"1","name":"current temp","label":"Temp","format":"{{msg.payload.thermal.temperature_C}}°C","layout":"col-center","className":"","style":false,"font":"","fontSize":"","color":"#000000","x":1730,"y":400,"wires":[]},{"id":"7326fad341383309","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":7,"width":"2","height":"1","name":"lowest Temp","label":"Lowest","format":"{{msg.payload.thermal.lowest_temp_C}}°C","layout":"col-center","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":1730,"y":360,"wires":[]},{"id":"61cbde4d55a8083a","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":9,"width":"2","height":"1","name":"highest temp:","label":"Highest","format":"{{msg.payload.thermal.highest_temp_C}}°C","layout":"col-center","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":1740,"y":440,"wires":[]},{"id":"cace18ac4081439e","type":"function","z":"5434bb2220a4967c","name":"caclulate effective Ah","func":"// Capacity Model Builder (robust)\n// - Infers full capacity from online_Ah & SoC, with smoothing + hysteresis\n// - Chooses between installed vs inferred full without flip-flopping\n// - Derives remaining_Ah consistently from chosen full & SoC\n// - Cross-checks with provided \"remaining\" fields if present\n\nconst p = msg.payload || {};\nconst cap = p.capacity || {};\n\n// Inputs\n// use high resolution soc percentage if available\nlet soc = (typeof cap.highres_soc_percent === 'number' && cap.highres_soc_percent != 0) ? cap.highres_soc_percent : (typeof cap.soc_percent === 'number') ? cap.soc_percent : null;\nconst onlineAh = (typeof cap.online_Ah === 'number') ? cap.online_Ah : null; // measured usable at current SoC\nconst installedAh = (typeof cap.installed_Ah === 'number') ? cap.installed_Ah : null; // nominal from device\n\nconst remOnline = (typeof cap.remaining_fromOnline_Ah === 'number') ? cap.remaining_fromOnline_Ah : null;\nconst remInstall = (typeof cap.remaining_fromInstalled_Ah === 'number') ? cap.remaining_fromInstalled_Ah : null;\n\n// Config\nconst TOL_REL_SWITCH = 0.06; // 6% to switch to inferred\nconst TOL_REL_BACK = 0.04; // 4% to switch back to installed (hysteresis)\nconst SOC_MIN_FOR_INFER = 5; // avoid inferring full at very low SoC (unstable)\nconst SOC_MAX_FOR_INFER = 95; // avoid inferring full near 100%\nconst EMA_ALPHA = 0.25; // smooth inferred full capacity\nconst ROUND_OUT = 1; // dashboard rounding (decimals)\n\n// Clamp SoC for safety\nif (typeof soc === 'number') {\n if (!isFinite(soc)) soc = null;\n else soc = Math.min(100, Math.max(0, soc));\n}\n\n// 1) Inferred full from SoC & onlineAh (no rounding yet)\nlet inferredFullAh = null;\nif (typeof soc === 'number' && typeof onlineAh === 'number'\n && soc >= SOC_MIN_FOR_INFER && soc <= SOC_MAX_FOR_INFER && soc > 0) {\n inferredFullAh = onlineAh / (soc / 100);\n}\n\n// Smooth inferred full to reduce jitter (keep per-flow)\nlet infFullEma = flow.get('infFullEma');\nif (typeof inferredFullAh === 'number') {\n infFullEma = (typeof infFullEma === 'number')\n ? (EMA_ALPHA * inferredFullAh + (1 - EMA_ALPHA) * infFullEma)\n : inferredFullAh;\n flow.set('infFullEma', infFullEma);\n} else if (infFullEma != null) {\n // keep last EMA if current inference is not valid\n inferredFullAh = infFullEma;\n}\n\n// 2) Choose effective full with hysteresis\nlet priorChosen = flow.get('capacityFullSource'); // 'installed' | 'inferred' | null\nlet effectiveFullAh = null;\nlet fullSource = null;\n\nfunction relDiff(a, b) {\n if (a == null || b == null) return null;\n const denom = Math.max(Math.abs(a), 1e-6);\n return Math.abs(a - b) / denom;\n}\n\nconst diffRel = (typeof installedAh === 'number' && typeof inferredFullAh === 'number')\n ? relDiff(inferredFullAh, installedAh)\n : null;\n\nif (installedAh == null && inferredFullAh != null) {\n effectiveFullAh = inferredFullAh; fullSource = 'inferred_only';\n} else if (inferredFullAh == null && installedAh != null) {\n effectiveFullAh = installedAh; fullSource = 'installed_only';\n} else if (inferredFullAh != null && installedAh != null) {\n // Hysteresis around the switch threshold\n if (priorChosen === 'inferred') {\n if (diffRel != null && diffRel <= TOL_REL_BACK) {\n effectiveFullAh = installedAh; fullSource = 'installed_preferred';\n } else {\n effectiveFullAh = inferredFullAh; fullSource = 'inferred_preferred';\n }\n } else { // prior installed or null\n if (diffRel != null && diffRel > TOL_REL_SWITCH) {\n effectiveFullAh = inferredFullAh; fullSource = 'inferred_preferred';\n } else {\n effectiveFullAh = installedAh; fullSource = 'installed_preferred';\n }\n }\n} else {\n effectiveFullAh = null; fullSource = 'none';\n}\n\n// Persist chosen class for next time (collapsing *_preferred/only into a stable tag)\nif (fullSource.startsWith('inferred')) flow.set('capacityFullSource', 'inferred');\nelse if (fullSource.startsWith('installed')) flow.set('capacityFullSource', 'installed');\n\n// 3) Remaining Ah from chosen full & SoC (primary), with cross-checks\nlet remainingAh = null;\nif (typeof effectiveFullAh === 'number' && typeof soc === 'number') {\n remainingAh = effectiveFullAh * (soc / 100);\n}\n\n// If BMS gave explicit remaining values, use as sanity hints (not override by default)\nlet remainingHints = {};\nif (typeof remOnline === 'number') remainingHints.remOnline = remOnline;\nif (typeof remInstall === 'number') remainingHints.remInstall = remInstall;\n\n// Optional: if you want to trust “remaining_fromInstalled” when you chose installed:\nif (remainingAh == null && fullSource.includes('installed') && typeof remInstall === 'number') {\n remainingAh = remInstall;\n}\n// Optional: if you want to trust “remaining_fromOnline” when you chose inferred:\nif (remainingAh == null && fullSource.includes('inferred') && typeof remOnline === 'number') {\n remainingAh = remOnline;\n}\n\n// 4) Deficit to full\nlet deficitToFullAh = null;\nif (typeof effectiveFullAh === 'number' && typeof remainingAh === 'number') {\n deficitToFullAh = Math.max(0, effectiveFullAh - remainingAh);\n}\n\n// 5) Output (rounded for presentation only)\nconst capacity_model = {\n effective_full_Ah: (typeof effectiveFullAh === 'number') ? +effectiveFullAh.toFixed(ROUND_OUT) : null,\n remaining_Ah: (typeof remainingAh === 'number') ? +remainingAh.toFixed(ROUND_OUT) : null,\n deficit_to_full_Ah: (typeof deficitToFullAh === 'number') ? +deficitToFullAh.toFixed(ROUND_OUT) : null,\n source: {\n chosen: (fullSource || 'none'),\n inferred_full_Ah: (typeof inferredFullAh === 'number') ? +inferredFullAh.toFixed(ROUND_OUT) : null,\n installed_full_Ah: installedAh,\n diff_percent: (diffRel != null) ? +(diffRel * 100).toFixed(1) : null,\n tolerance_switch_percent: +(TOL_REL_SWITCH * 100).toFixed(1),\n tolerance_back_percent: +(TOL_REL_BACK * 100).toFixed(1),\n soc_used_percent: soc,\n hints: remainingHints\n }\n};\n\nmsg.payload = { ...p, capacity_model };\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1760,"y":120,"wires":[["64e5df544a7bd8c8","0e20759b05ac12bb"]]},{"id":"e1d5045a0a309fc2","type":"debug","z":"5434bb2220a4967c","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":2150,"y":80,"wires":[]},{"id":"64e5df544a7bd8c8","type":"function","z":"5434bb2220a4967c","name":"actual ttgo","func":"// Unified TTG/TTF estimator (single result depending on mode)\n// Adds: pretty formatter for long durations + badge string\n// Keeps: 'hhmm' for exact debugging\n\n// --- Config ---\nconst IDLE_THRESHOLD_A = 0.15; // |I| below => idle\nconst ROUND_HOURS = 2; // decimals for hours output\nconst RESERVE_SOC_PCT = 0; // e.g. 10 -> keep 10% in tank for TTE\nconst MAX_DAYS_CLAMP = 120; // show as >120d if exceeded (optional clamp)\n\n// --- Inputs ---\nconst p = msg.payload || {};\nconst cm = p.capacity_model || {};\nconst I = (typeof p.electrical?.current_A === 'number') ? p.electrical.current_A : null;\n\n// Optional: smooth current with EMA so TTG doesn't jump around\nconst EMA_ALPHA = 0.3;\nlet I_ema = flow.get('I_ema');\nif (typeof I === 'number') {\n I_ema = (typeof I_ema === 'number') ? (EMA_ALPHA * I + (1 - EMA_ALPHA) * I_ema) : I;\n flow.set('I_ema', I_ema);\n}\nconst I_used = (typeof I_ema === 'number') ? I_ema : I;\n\n// Capacity bases from model\nlet remainingAh = (typeof cm.remaining_Ah === 'number') ? cm.remaining_Ah : null;\nconst effectiveFull = (typeof cm.effective_full_Ah === 'number') ? cm.effective_full_Ah : null;\nconst deficitToFull = (typeof cm.deficit_to_full_Ah === 'number')? cm.deficit_to_full_Ah: null;\n\n// Optional reserve: reduce remaining by reserve portion of full cap\nif (RESERVE_SOC_PCT > 0 && typeof effectiveFull === 'number' && typeof remainingAh === 'number') {\n const reserveAh = effectiveFull * (RESERVE_SOC_PCT / 100);\n remainingAh = Math.max(0, remainingAh - reserveAh);\n}\n\n// Helpers\nfunction toHhMm(hours) {\n if (typeof hours !== 'number' || !isFinite(hours) || hours < 0) return null;\n const totalMin = Math.round(hours * 60);\n const h = Math.floor(totalMin / 60);\n const m = totalMin % 60;\n return `${h}:${m.toString().padStart(2,'0')}`;\n}\n\n// Compact human formatter for long durations\nfunction fmtDuration(hours) {\n if (hours == null || !isFinite(hours)) return '–';\n if (hours <= 0) return '0m';\n\n const totalMin = Math.round(hours * 60);\n const totalHours = Math.floor(totalMin / 60);\n const mins = totalMin % 60;\n const days = Math.floor(totalHours / 24);\n const hoursLeft = totalHours % 24;\n const weeks = Math.floor(days / 7);\n const daysLeft = days % 7;\n\n // clamp very large values\n if (totalHours > 24 * MAX_DAYS_CLAMP) return `>${MAX_DAYS_CLAMP}d`;\n\n if (totalHours < 24) return `${totalHours}:${mins.toString().padStart(2,'0')}`; // 7:15\n if (days < 14) return `${days}d${hoursLeft ? ` ${hoursLeft}h` : ''}`; // 5d 6h\n if (weeks < 26) return `${weeks}w${daysLeft ? ` ${daysLeft}d` : ''}`; // 3w 2d\n const monthsApprox = Math.round(days / 30);\n return `~${monthsApprox}mo`;\n}\n\nfunction iconFor(mode) {\n return mode === 'discharging' ? '🔻'\n : mode === 'charging' ? '⚡'\n : mode === 'full' ? '✅'\n : '⏸';\n}\n\n// --- Compute unified result ---\nlet mode = 'idle';\nlet hours = null;\nlet hhmm = null; // exact format (keep for debugging)\nlet label = 'Idle';\nlet note = null;\n\nif (typeof I_used === 'number') {\n if (I_used < -IDLE_THRESHOLD_A) {\n // Discharging → Time To Empty\n if (typeof remainingAh === 'number' && remainingAh > 0) {\n hours = +((remainingAh / Math.abs(I_used))).toFixed(ROUND_HOURS);\n hhmm = toHhMm(hours);\n mode = 'discharging';\n label = 'Time to 0%';\n } else {\n mode = 'discharging'; label = 'Time to 0%'; note = 'No remainingAh available';\n }\n } else if (I_used > IDLE_THRESHOLD_A) {\n // Charging → Time To Full\n if (typeof deficitToFull === 'number' && deficitToFull > 0) {\n hours = +((deficitToFull / I_used)).toFixed(ROUND_HOURS);\n hhmm = toHhMm(hours);\n mode = 'charging';\n label = 'Time to full';\n } else if (deficitToFull === 0) {\n mode = 'full'; label = 'Battery full'; hours = 0; hhmm = '0:00';\n } else {\n mode = 'charging'; label = 'Time to full'; note = 'No deficitToFull available';\n }\n } else {\n // Idle band\n mode = 'idle'; label = 'Idle';\n }\n} else {\n note = 'Missing current_A';\n}\n\n// Pretty string + badge (visual)\nlet pretty = fmtDuration(hours);\nconst labelBadge = `${label.toLocaleLowerCase().includes(\"idle\") ? \"\" : iconFor(mode)} ${label.toLocaleLowerCase().includes(\"idle\") ? \"\" : label} ${label.toLocaleLowerCase().includes(\"idle\") ? \"\" : iconFor(mode)}`\npretty = `${pretty && pretty !== '–' ? `${pretty}` : ''}`\nlet badge = `${iconFor(mode)} ${label}${pretty && pretty !== '–' ? `: ${pretty}` : ''} ${iconFor(mode)}`;\nbadge = badge.toLocaleLowerCase().includes(\"idle\") ? \"\" : badge\n\n// Attach under msg.payload.ttg (single object)\nmsg.payload = {\n ...p,\n ttg: {\n mode, // 'discharging' | 'charging' | 'idle' | 'full'\n label, // human-friendly label\n hours, // number (may be null)\n hhmm, // exact \"H:MM\" (kept for debug/exports)\n pretty, // compact human-readable (days/weeks)\n labelBadge, // extra value for text node label\n badge, // e.g. \"Time to full: 5d 6h\"\n\n // context for debugging:\n current_A: I_used,\n base_remaining_Ah: remainingAh,\n deficit_to_full_Ah: deficitToFull,\n idle_threshold_A: IDLE_THRESHOLD_A,\n reserve_soc_percent: RESERVE_SOC_PCT || 0,\n clamp_days: MAX_DAYS_CLAMP,\n note\n }\n};\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1970,"y":120,"wires":[["e1d5045a0a309fc2","e3dc39043fb097c4"]]},{"id":"52a6d3f36e3e61ce","type":"ui_template","z":"5434bb2220a4967c","group":"255107b99d3a7830","name":"debug CAN BMS","order":9,"width":0,"height":0,"format":"<style>\n .popup-debug-ovl {\n display: none;\n position: fixed;\n z-index: 9999;\n inset: 0;\n background: rgba(0, 0, 0, 0.6);\n justify-content: center;\n align-items: center;\n }\n\n .popup-debug-ovl.is-open {\n display: flex;\n }\n\n .popup-debug-box {\n background: var(--nrdb-card-bg, #1e1e1e);\n color: var(--nrdb-text, #eee);\n border-radius: 8px;\n width: 90%;\n max-width: 800px;\n max-height: 80%;\n overflow: auto;\n padding: 16px;\n box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5);\n font-family: ui-monospace, Menlo, Consolas, monospace;\n white-space: pre-wrap;\n }\n\n .popup-debug-hdr {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 8px;\n }\n\n .popup-debug-title {\n margin: 0;\n font-size: 16px;\n }\n\n .popup-debug-close {\n cursor: pointer;\n font-size: 20px;\n line-height: 1;\n }\n</style>\n\n<!-- This <md-button> mirrors the stock dashboard button structure/classes -->\n<md-button class=\"md-raised md-button md-ink-ripple md-primary\" type=\"button\" ng-click=\"popupOpen = true\"\n aria-label=\"{{msg.debugButtonAria || 'Show all values'}}\">\n <ui-icon ng-show=\"msg.debugButtonIcon\" icon=\"{{msg.debugButtonIcon}}\"></ui-icon>\n <span ng-bind-html=\"(msg.debugButtonLabel || 'Debug CAN-bus BMS')\"></span>\n <div class=\"md-ripple-container\" style></div>\n</md-button>\n\n<!-- Modal popup -->\n<div class=\"popup-debug-ovl\" ng-class=\"{'is-open': popupOpen}\" ng-click=\"popupOpen=false\">\n <div class=\"popup-debug-box\" ng-click=\"$event.stopPropagation()\">\n <div class=\"popup-debug-hdr\">\n <h4 class=\"popup-debug-title\" ng-bind=\"msg.debugTitle || 'CAN-bus BMS Data'\"></h4>\n <span class=\"popup-debug-close\" ng-click=\"popupOpen=false\" aria-label=\"Close\">×</span>\n </div>\n <pre>{{msg.payload | json:2}}</pre>\n </div>\n</div>\n\n<script>\n(function(scope){\n const btn = document.getElementById(\"showDebugBtn\");\n const popup = document.getElementById(\"debugPopup\");\n const close = document.getElementById(\"closeDebug\");\n const content = document.getElementById(\"debugContent\");\n\n btn.onclick = () => popup.style.display = \"flex\";\n close.onclick = () => popup.style.display = \"none\";\n popup.onclick = (e) => { if (e.target === popup) popup.style.display = \"none\"; };\n\n scope.$watch('msg', function(msg){\n if(!msg || !msg.payload) return;\n content.textContent = JSON.stringify(msg.payload, null, 2);\n });\n})(scope);\n</script>\n","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":1750,"y":480,"wires":[[]]},{"id":"27939ff7ae5d324a","type":"function","z":"5434bb2220a4967c","name":"latest data age","func":"// Node-RED Function node: pretty-print data age if canBus == true\n\n\nglobal.set(\"canBus\", true) // Remove this line for production!\n\n// Only run if canBus flag is true\nif ((global.get(\"canBus\") || false) !== true) {\n return null; // stop the flow\n}\n\n// Get saved timestamp safely\nconst bms = global.get('canbus_BMS');\nlet ts = bms && bms.meta && bms.meta.timestamp_ms;\n\n// Validate: undefined/null/NaN -> \"unknown\"\nif (ts === undefined || ts === null) {\n msg.payload = \"unknown\";\n return msg;\n}\n\n// Accept strings like \"1699999999999\"\nif (typeof ts === \"string\" && ts.trim() !== \"\") {\n ts = Number(ts);\n}\n\nif (!Number.isFinite(ts)) {\n msg.payload = \"unknown\";\n return msg;\n}\n\n// Heuristic: if looks like seconds, convert to ms\nif (ts < 1e11) { // definitely seconds, not ms\n ts = ts * 1000;\n}\n\n// Compute difference\nconst now = Date.now();\nlet diffMs = now - ts;\nif (diffMs < 0) diffMs = 0; // clamp future timestamps\n\n// Pretty-print helper\nfunction pretty(ms) {\n if (ms < 1000) return `${ms}ms ago`;\n const sec = Math.floor(ms / 1000);\n if (sec < 60) return `${sec}s ago`;\n const m = Math.floor(sec / 60);\n if (m < 60) return `${m}m ${sec % 60}s ago`;\n const h = Math.floor(m / 60);\n if (h < 24) return `${h}h ${m % 60}m ago`;\n const d = Math.floor(h / 24);\n const remH = h % 24;\n return `${d}d ${remH}h ago`;\n}\nmsg.payload = pretty(diffMs);\n\n// Optional: Node status indicator\nlet color = \"green\";\nif (diffMs >= 10000 && diffMs < 60000) color = \"yellow\"; // delayed\nelse if (diffMs >= 60000) color = \"red\"; // dropped\nnode.status({ fill: color, shape: \"dot\", text: msg.payload });\n\nmsg.payload = color != \"green\" ? `<font color=\"${color}\">${msg.payload}` : msg.payload\n\n// Create key to indicate if a CAN-bus restart is needed\nlet restartNeeded = false\nif (diffMs >= 300000) restartNeeded = true\n\n// Build age output\nmsg.age = { lastTimestampMs: ts, diffMs , restartNeeded};\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1740,"y":520,"wires":[["7efdce795477b955","b61a54a078f39ab3","aa606f46a214a379"]]},{"id":"e299846e2382bcdc","type":"inject","z":"5434bb2220a4967c","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"5","crontab":"","once":true,"onceDelay":"15","topic":"","payload":"","payloadType":"date","x":1530,"y":520,"wires":[["27939ff7ae5d324a"]]},{"id":"7efdce795477b955","type":"debug","z":"5434bb2220a4967c","name":"debug 15","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1940,"y":560,"wires":[]},{"id":"d29c3c2ffda9035d","type":"inject","z":"5434bb2220a4967c","name":"kill","props":[{"p":"payload"},{"p":"kill","v":"true","vt":"bool"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":170,"y":160,"wires":[["ab82272997b3a62f"]]},{"id":"b61a54a078f39ab3","type":"ui_text","z":"5434bb2220a4967c","group":"255107b99d3a7830","order":9,"width":"0","height":"0","name":"latest data age","label":"Latest data age:","format":"{{msg.payload}}","layout":"row-spread","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":1960,"y":520,"wires":[]},{"id":"e0d011e9f02da4f9","type":"link out","z":"5434bb2220a4967c","name":"can bus data age","mode":"link","links":["9ad588551b99c6b9"],"x":2085,"y":480,"wires":[]},{"id":"9ad588551b99c6b9","type":"link in","z":"5434bb2220a4967c","name":"inject can bus","links":["e0d011e9f02da4f9"],"x":145,"y":280,"wires":[["cbcc044ba366a677"]]},{"id":"aa606f46a214a379","type":"function","z":"5434bb2220a4967c","name":"restart needed?","func":"if (msg.age.hasOwnProperty(\"restartNeeded\") && msg.age.restartNeeded == true) {\n return msg;\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1960,"y":480,"wires":[["e0d011e9f02da4f9"]]},{"id":"cbcc044ba366a677","type":"function","z":"5434bb2220a4967c","name":"set cmd","func":"if ((global.get(\"canBus\") || false) == true) {\n const bitrate = global.get(\"canBusBitrate\") || 500000\n msg.payload = `sudo ip link set can0 down && sleep 1 && sudo ip link set can0 type can bitrate ${bitrate} restart-ms 100 && sleep 1 && sudo ip link set can0 up`\n flow.set(\"canbusRestartTs\", Date.now())\n return msg;\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":260,"y":280,"wires":[["c117be479c9d69e4","74b7bb488faf835d","33509cdce3f81b0b"]]},{"id":"c117be479c9d69e4","type":"exec","z":"5434bb2220a4967c","command":"","addpay":"payload","append":"","useSpawn":"false","timer":"","winHide":false,"oldrc":false,"name":"reset can interface","x":450,"y":280,"wires":[["ab82272997b3a62f"],[],[]]},{"id":"74b7bb488faf835d","type":"debug","z":"5434bb2220a4967c","name":"debug 17","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":420,"y":320,"wires":[]},{"id":"0e20759b05ac12bb","type":"debug","z":"5434bb2220a4967c","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1970,"y":80,"wires":[]},{"id":"e3dc39043fb097c4","type":"delay","z":"5434bb2220a4967c","name":"","pauseType":"rate","timeout":"5","timeoutUnits":"seconds","rate":"1","nbRateUnits":"2","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":true,"allowrate":false,"outputs":1,"x":2180,"y":120,"wires":[["e03dbd91c52a4eb7"]]},{"id":"2a55fff0d27c23fd","type":"mqtt out","z":"5434bb2220a4967c","name":"","topic":"test/can","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"0ddb07c65cbb68bc","x":1000,"y":180,"wires":[]},{"id":"aa6fad8be1d0e4d2","type":"mqtt in","z":"5434bb2220a4967c","name":"","topic":"test/can","qos":"2","datatype":"auto-detect","broker":"0ddb07c65cbb68bc","nl":false,"rap":true,"rh":0,"inputs":0,"x":850,"y":280,"wires":[["d57e491d10b1ece9"]]},{"id":"d57e491d10b1ece9","type":"debug","z":"5434bb2220a4967c","name":"Parsed Output","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1020,"y":280,"wires":[]},{"id":"e0f8a3d625bcb2d2","type":"join","z":"5434bb2220a4967c","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","useparts":false,"accumulate":false,"timeout":"0.5","count":"","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"num","reduceFixup":"","x":850,"y":180,"wires":[["040a1f72620b7099","2a55fff0d27c23fd"]]},{"id":"040a1f72620b7099","type":"debug","z":"5434bb2220a4967c","name":"Parsed Output","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1020,"y":240,"wires":[]},{"id":"33509cdce3f81b0b","type":"change","z":"5434bb2220a4967c","name":"","rules":[{"t":"set","p":"kill","pt":"msg","to":"true","tot":"bool"}],"action":"","property":"","from":"","to":"","reg":false,"x":280,"y":240,"wires":[["ab82272997b3a62f"]]},{"id":"255107b99d3a7830","type":"ui_group","name":"CAN BMS","tab":"deee53a800de461d","order":12,"disp":true,"width":6,"collapse":true,"className":""},{"id":"0ddb07c65cbb68bc","type":"mqtt-broker","name":"","broker":"http://localhost","port":"1883","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willPayload":"","willMsg":{},"sessionExpiry":""},{"id":"deee53a800de461d","type":"ui_tab","name":"Info","icon":"mi-info","order":1,"disabled":false,"hidden":false}]
Wenn du es erstmal in der Konsole probieren möchtest:
Um den CAN-bus zu starten auf dem RPI benutzt du die folgenden Befehle:
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 500000 restart-ms 100
sudo ip link set can0 up
Die Bitrate musst du natürlich entsprechend anpassen. Mit candump can0 in der Konsole solltest du dann die CAN-Frames sehen können.
Die Exec-Node im Flow macht das Gleiche, die Frames werden dann aufgeteilt in einzelne Frames (falls die Exec-Node mehrere aufeinmal ausgibt), dann durch das Protokoll interpretiert und wieder zusammengefügt, sodass ein einzelnes Object am Ende entsteht und wir damit weiterarbeiten können. Lass dich nicht verwirren von allem was im Flow danach kommt, da wird noch einiges dazugerechnet für QoL/UI/UX usw. 
Für das Umschreiben der Frames/Bytes lohnt es sich eine KI deiner Wahl zu befragen, die können das recht gut.
EDIT: Mit der Socketcan-Node hatte ich auch mal rumgespielt, das Problem dabei war aber, dass der CAN-bus erst aktiviert sein muss mit can0 up, dann kann erst NR gestartet werden damit es funktioniert. Also müsste man bei jedem Neustart des CAN-bus auch NR neustarten.