JK BMS für IOBroker Kopplung

Hallo

Andy hat gestern das Gobel Power Home Assistant Addon für JK BMS & Pace vorgestellt.

Endlich eine getestete Vorlage um auch zB mit IOBroker das JK BMS (Im Verbund) vollständig und stabil auslesen zu können. Habs mal angepasst um wie folgt bereits im Einsatz:

Man koppelt das Master-BMS (Device ID0) an Port RS485-2 mit einem RS485 zu Ethernet Umsetzer zB von Waveshare oder Ebyte.

Konfiguriert das Ding nicht für Modbus sondern als transparente TTL Kommunikation zB wie hier:

und koppelt das Ding per IOBroker mit einem Javascript (Im Javascript-Adapter):

/**
 * JK BMS PB2A16S20P — 55AA Passive Listener für ioBroker  v6
 * ===========================================================
 * Saubere Trennung: Parsen läuft kontinuierlich auf jedem TCP-Chunk,
 * Schreiben nach ioBroker nur alle WRITE_INTERVAL_MS per Timer.
 * Kein setState-Limit-Problem mehr möglich.
 *
 * ── KONFIGURATION ────────────────────────────────────────────
 */
const CONFIG = {
    host:           '192.168.7.109',
    port:           5000,
    statePrefix:    'javascript.0.jkbms',
    reconnectMs:    5000,
    maxPackId:      3,
    cacheMaxAgeS:   60,
    writeIntervalMs: 10000,  // ioBroker-Schreib-Intervall (10s)
};

const net = require('net');
let socket     = null;
let reconnTimer= null;
let writeTimer = null;
let running    = true;
let rxBuf      = Buffer.alloc(0);

// Cache: zuletzt geparste Daten pro Pack — wird bei jedem Frame aktualisiert
const frameCache = {};  // packId → { data, ts }

// ── CRC16 Modbus ──────────────────────────────────────────────
function crc16(buf, start, len) {
    let crc = 0xFFFF;
    for (let i = start; i < start + len; i++) {
        crc ^= buf[i];
        for (let j = 0; j < 8; j++) crc = (crc & 1) ? ((crc >>> 1) ^ 0xA001) : (crc >>> 1);
    }
    return crc >>> 0;
}

// ── ACK-Validierung ───────────────────────────────────────────
const VALID_REGS = new Set([0x161C, 0x161E, 0x1620]);

function validateAckAt(buf, offset) {
    if (offset + 8 > buf.length)                               return null;
    if (buf[offset + 1] !== 0x10)                              return null;
    if (buf[offset + 4] !== 0x00 || buf[offset + 5] !== 0x01) return null;
    const crcExp = crc16(buf, offset, 6);
    const crcGot = (buf[offset + 6] | (buf[offset + 7] << 8)) >>> 0;
    if (crcExp !== crcGot)                                     return null;
    const regAddr = (buf[offset + 2] << 8) | buf[offset + 3];
    if (!VALID_REGS.has(regAddr))                              return null;
    return { regAddr, packId: buf[offset] };
}

// ── Buffer-Verarbeitung ───────────────────────────────────────
function processBuffer() {
    while (rxBuf.length >= 348) {
        // Suche 55 AA
        let idx = -1;
        for (let i = 0; i <= rxBuf.length - 2; i++) {
            if (rxBuf[i] === 0x55 && rxBuf[i+1] === 0xAA) { idx = i; break; }
        }
        if (idx < 0) {
            rxBuf = (rxBuf[rxBuf.length-1] === 0x55) ? rxBuf.slice(-1) : Buffer.alloc(0);
            break;
        }
        if (idx > 0) rxBuf = rxBuf.slice(idx);
        if (rxBuf.length < 348) break;

        // ACK-Suche Offset 280-340
        let matched = false;
        for (let offset = 280; offset <= 340; offset++) {
            const ack = validateAckAt(rxBuf, offset);
            if (ack) {
                if (ack.regAddr === 0x1620 && ack.packId <= CONFIG.maxPackId) {
                    const frame = rxBuf.slice(0, 300);
                    const parsed = parseDynamic(frame);
                    if (parsed) {
                        frameCache[ack.packId] = { data: parsed, ts: Date.now() };
                    }
                }
                rxBuf = rxBuf.slice(offset + 8);
                matched = true;
                break;
            }
        }
        if (!matched) rxBuf = rxBuf.slice(2);
    }
}

// ── Binary-Helfer ─────────────────────────────────────────────
function u8(b,o)  { return b[o]; }
function u16(b,o) { return (b[o] | (b[o+1]<<8)) >>> 0; }
function i16(b,o) { const v=u16(b,o); return v>=0x8000?v-0x10000:v; }
function u32(b,o) { return (b[o]|(b[o+1]<<8)|(b[o+2]<<16)|(b[o+3]*0x1000000))>>>0; }
function i32(b,o) { const v=u32(b,o); return v>=0x80000000?v-0x100000000:v; }

// ── DYNAMIC-Frame Parser ──────────────────────────────────────
function parseDynamic(data) {
    if (data.length < 300 || data[0] !== 0x55 || data[1] !== 0xAA) return null;
    const cells = [];
    for (let c = 0; c < 16; c++) {
        const mv = u16(data, 6 + c*2);
        if (mv > 0 && mv < 50000) cells.push(mv); else break;
    }
    if (!cells.length) return null;
    const resistances = [];
    for (let c = 0; c < 16; c++) {
        const r = i16(data, 80 + c*2);
        if (r >= 0 && r <= 1000) resistances.push(r); else break;
    }
    const voltRaw = u32(data, 150);
    return {
        cells, resistances,
        cellAvg:       u16(data, 74),
        cellDiff:      u16(data, 76),
        cellMax:       Math.max(...cells),
        cellMin:       Math.min(...cells),
        tempMos:       i16(data, 144) / 10.0,
        wireAlarm:     u32(data, 146),
        voltageV:      voltRaw > 0 ? voltRaw / 1000.0 : u16(data, 234) / 100.0,
        powerW:        u32(data, 154) / 1000.0,
        currentA:      i32(data, 158) / 1000.0,
        tempBat1:      i16(data, 162) / 10.0,
        tempBat2:      i16(data, 164) / 10.0,
        tempBat3:      data.length > 255 ? i16(data, 254) / 10.0 : null,
        tempBat4:      data.length > 257 ? i16(data, 256) / 10.0 : null,
        tempBat5:      data.length > 259 ? i16(data, 258) / 10.0 : null,
        alarmBits:     u32(data, 166),
        balanceCurA:   i16(data, 170) / 1000.0,
        batteryState:  u8(data, 172),
        soc:           u8(data, 173),
        remainCap:     u32(data, 174) / 1000.0,
        fullCap:       u32(data, 178) / 1000.0,
        cycleCount:    u32(data, 182),
        cycleCap:      u32(data, 186) / 1000.0,
        soh:           u8(data, 190),
        totalRuntimeH: Math.round(u32(data, 194) / 36) / 100,
        chargeMos:     !!u8(data, 198),
        dischargeMos:  !!u8(data, 199),
        balanceMos:    !!u8(data, 200),
        heatingState:  !!u8(data, 215),
        chargerPlugged:!!u8(data, 245),
        faultCount:    u8(data, 266),
    };
}

// ── ioBroker States ───────────────────────────────────────────
function es(id, name, type, unit, role) {
    createState(id, { name, type, unit:unit||'', role:role||'value', read:true, write:false });
}
function initPackStates(pid) {
    const p = `${CONFIG.statePrefix}.pack${pid}`;
    es(`${p}.voltage`,         `Pack${pid} Spannung`,       'number','V',  'value.voltage');
    es(`${p}.current`,         `Pack${pid} Strom`,          'number','A',  'value.current');
    es(`${p}.power`,           `Pack${pid} Leistung`,       'number','W',  'value.power');
    es(`${p}.soc`,             `Pack${pid} SOC`,            'number','%',  'value.battery');
    es(`${p}.soh`,             `Pack${pid} SOH`,            'number','%',  'value');
    es(`${p}.remain_cap`,      `Pack${pid} Restkapazität`,  'number','Ah', 'value');
    es(`${p}.full_cap`,        `Pack${pid} Vollkapazität`,  'number','Ah', 'value');
    es(`${p}.cycle_count`,     `Pack${pid} Ladezyklen`,     'number','',   'value');
    es(`${p}.cycle_cap`,       `Pack${pid} Zykluskapazität`,'number','Ah', 'value');
    es(`${p}.balance_current`, `Pack${pid} Balancestrom`,   'number','A',  'value.current');
    es(`${p}.temp_mos`,        `Pack${pid} Temp MOS`,       'number','°C', 'value.temperature');
    es(`${p}.temp_bat1`,       `Pack${pid} Temp 1`,         'number','°C', 'value.temperature');
    es(`${p}.temp_bat2`,       `Pack${pid} Temp 2`,         'number','°C', 'value.temperature');
    es(`${p}.temp_bat3`,       `Pack${pid} Temp 3`,         'number','°C', 'value.temperature');
    es(`${p}.temp_bat4`,       `Pack${pid} Temp 4`,         'number','°C', 'value.temperature');
    es(`${p}.temp_bat5`,       `Pack${pid} Temp 5`,         'number','°C', 'value.temperature');
    es(`${p}.charge_mos`,      `Pack${pid} Lade-MOS`,       'boolean','',  'indicator');
    es(`${p}.discharge_mos`,   `Pack${pid} Entlade-MOS`,    'boolean','',  'indicator');
    es(`${p}.balance_mos`,     `Pack${pid} Balance-MOS`,    'boolean','',  'indicator');
    es(`${p}.alarm_bits`,      `Pack${pid} Alarm-Bits`,     'number','',   'value');
    es(`${p}.battery_state`,   `Pack${pid} Status`,         'number','',   'value');
    es(`${p}.wire_alarm`,      `Pack${pid} Draht-Alarm`,    'number','',   'indicator.alarm');
    es(`${p}.fault_count`,     `Pack${pid} Fehleranzahl`,   'number','',   'value');
    es(`${p}.heating_state`,   `Pack${pid} Heizung`,        'boolean','',  'indicator');
    es(`${p}.charger_plugged`, `Pack${pid} Ladegerät`,      'boolean','',  'indicator');
    es(`${p}.cell_max_mv`,     `Pack${pid} Zelle Max`,      'number','mV', 'value.voltage');
    es(`${p}.cell_min_mv`,     `Pack${pid} Zelle Min`,      'number','mV', 'value.voltage');
    es(`${p}.cell_avg_mv`,     `Pack${pid} Zelle Avg`,      'number','mV', 'value.voltage');
    es(`${p}.cell_diff_mv`,    `Pack${pid} Zelle Diff`,     'number','mV', 'value.voltage');
    es(`${p}.total_runtime_h`, `Pack${pid} Laufzeit`,       'number','h',  'value');
    es(`${p}.last_update`,     `Pack${pid} Aktualisiert`,   'string','',   'value.datetime');
    for (let c = 1; c <= 16; c++) {
        const cs = String(c).padStart(2,'0');
        es(`${p}.cell_${cs}_mv`,   `Pack${pid} Zelle ${cs} Spannung`,   'number','mV','value.voltage');
        es(`${p}.cell_${cs}_mohm`, `Pack${pid} Zelle ${cs} Widerstand`, 'number','mΩ','value');
    }
}
function initTotalStates() {
    const p = `${CONFIG.statePrefix}.total`;
    es(`${p}.voltage_avg`,  'Gesamt Ø Spannung',    'number','V', 'value.voltage');
    es(`${p}.current`,      'Gesamt Strom',         'number','A', 'value.current');
    es(`${p}.power`,        'Gesamt Leistung',      'number','W', 'value.power');
    es(`${p}.soc`,          'Gesamt SOC',           'number','%', 'value.battery');
    es(`${p}.remain_cap`,   'Gesamt Restkapazität', 'number','Ah','value');
    es(`${p}.full_cap`,     'Gesamt Vollkapazität', 'number','Ah','value');
    es(`${p}.active_packs`, 'Aktive Packs',         'number','',  'value');
}

// ── ioBroker Schreiben (nur vom Timer aufgerufen) ─────────────
function writePack(pid, d) {
    const p = `${CONFIG.statePrefix}.pack${pid}`;
    setState(`${p}.voltage`,         Math.round(d.voltageV*1000)/1000,    true);
    setState(`${p}.current`,         Math.round(d.currentA*1000)/1000,    true);
    setState(`${p}.power`,           Math.round(d.powerW*10)/10,          true);
    setState(`${p}.soc`,             d.soc,                               true);
    setState(`${p}.soh`,             d.soh,                               true);
    setState(`${p}.remain_cap`,      Math.round(d.remainCap*1000)/1000,   true);
    setState(`${p}.full_cap`,        Math.round(d.fullCap*1000)/1000,     true);
    setState(`${p}.cycle_count`,     d.cycleCount,                        true);
    setState(`${p}.cycle_cap`,       Math.round(d.cycleCap*10)/10,        true);
    setState(`${p}.balance_current`, Math.round(d.balanceCurA*1000)/1000, true);
    setState(`${p}.temp_mos`,        Math.round(d.tempMos*10)/10,         true);
    setState(`${p}.temp_bat1`,       Math.round(d.tempBat1*10)/10,        true);
    setState(`${p}.temp_bat2`,       Math.round(d.tempBat2*10)/10,        true);
    if (d.tempBat3!==null) setState(`${p}.temp_bat3`, Math.round(d.tempBat3*10)/10, true);
    if (d.tempBat4!==null) setState(`${p}.temp_bat4`, Math.round(d.tempBat4*10)/10, true);
    if (d.tempBat5!==null) setState(`${p}.temp_bat5`, Math.round(d.tempBat5*10)/10, true);
    setState(`${p}.charge_mos`,      d.chargeMos,     true);
    setState(`${p}.discharge_mos`,   d.dischargeMos,  true);
    setState(`${p}.balance_mos`,     d.balanceMos,    true);
    setState(`${p}.alarm_bits`,      d.alarmBits,     true);
    setState(`${p}.battery_state`,   d.batteryState,  true);
    setState(`${p}.wire_alarm`,      d.wireAlarm,     true);
    setState(`${p}.fault_count`,     d.faultCount,    true);
    setState(`${p}.heating_state`,   d.heatingState,  true);
    setState(`${p}.charger_plugged`, d.chargerPlugged,true);
    setState(`${p}.cell_max_mv`,     d.cellMax,       true);
    setState(`${p}.cell_min_mv`,     d.cellMin,       true);
    setState(`${p}.cell_avg_mv`,     d.cellAvg,       true);
    setState(`${p}.cell_diff_mv`,    d.cellDiff,      true);
    setState(`${p}.total_runtime_h`, d.totalRuntimeH, true);
    setState(`${p}.last_update`,     new Date().toISOString(), true);
    d.cells.forEach((mv,i) =>
        setState(`${p}.cell_${String(i+1).padStart(2,'0')}_mv`, mv, true));
    d.resistances.forEach((r,i) =>
        setState(`${p}.cell_${String(i+1).padStart(2,'0')}_mohm`, r, true));
    log(`Pack${pid}: SOC=${d.soc}%  U=${Math.round(d.voltageV*1000)/1000}V  I=${Math.round(d.currentA*1000)/1000}A  ΔCell=${d.cellDiff}mV`, 'info');
}

function writeAll() {
    if (!running) return;
    const now = Date.now();
    const maxAge = CONFIG.cacheMaxAgeS * 1000;
    const activePids = [];

    Object.keys(frameCache).forEach(pid => {
        const entry = frameCache[pid];
        if (now - entry.ts > maxAge) {
            log(`Pack${pid} offline`, 'warn');
            delete frameCache[pid];
            return;
        }
        writePack(parseInt(pid), entry.data);
        activePids.push(parseInt(pid));
    });

    // Aggregat
    if (activePids.length > 0) {
        const p = `${CONFIG.statePrefix}.total`;
        const packs = activePids.map(pid => frameCache[pid].data);
        const rem = packs.reduce((s,x) => s + x.remainCap, 0);
        const ful = packs.reduce((s,x) => s + x.fullCap,   0);
        setState(`${p}.voltage_avg`,  Math.round(packs.reduce((s,x)=>s+x.voltageV,0)/packs.length*1000)/1000, true);
        setState(`${p}.current`,      Math.round(packs.reduce((s,x)=>s+x.currentA,0)*1000)/1000, true);
        setState(`${p}.power`,        Math.round(packs.reduce((s,x)=>s+x.powerW,0)*10)/10, true);
        setState(`${p}.soc`,          ful>0 ? Math.round(rem/ful*1000)/10 : 0, true);
        setState(`${p}.remain_cap`,   Math.round(rem*100)/100, true);
        setState(`${p}.full_cap`,     Math.round(ful*100)/100, true);
        setState(`${p}.active_packs`, activePids.length, true);
    }

    // Nächsten Timer planen
    writeTimer = setTimeout(writeAll, CONFIG.writeIntervalMs);
}

// ── TCP ───────────────────────────────────────────────────────
function connect() {
    if (!running) return;
    if (socket) { socket.destroy(); socket = null; }
    if (reconnTimer) { clearTimeout(reconnTimer); reconnTimer = null; }
    rxBuf = Buffer.alloc(0);

    log(`JK BMS: Verbinde zu ${CONFIG.host}:${CONFIG.port}...`, 'info');
    socket = new net.Socket();
    socket.setTimeout(0);

    socket.connect(CONFIG.port, CONFIG.host, () => {
        log('JK BMS: Verbunden', 'info');
        socket.removeAllListeners('data');
        socket.on('data', (chunk) => {
            if (!running) return;
            rxBuf = Buffer.concat([rxBuf, chunk]);
            if (rxBuf.length > 50000) rxBuf = rxBuf.slice(-10000);
            processBuffer();
        });
    });

    socket.on('error', (err) => log(`JK BMS: Fehler: ${err.message}`, 'warn'));

    socket.on('close', () => {
        if (!running) return;
        log(`JK BMS: Getrennt — reconnect in ${CONFIG.reconnectMs/1000}s`, 'warn');
        socket = null;
        reconnTimer = setTimeout(connect, CONFIG.reconnectMs);
    });
}

// ── Start ─────────────────────────────────────────────────────
for (let p = 0; p <= CONFIG.maxPackId; p++) initPackStates(p);
initTotalStates();
connect();
writeTimer = setTimeout(writeAll, CONFIG.writeIntervalMs);

onStop(() => {
    log('JK BMS: Script gestoppt.', 'info');
    running = false;
    if (reconnTimer) { clearTimeout(reconnTimer); reconnTimer = null; }
    if (writeTimer)  { clearTimeout(writeTimer);  writeTimer  = null; }
    if (socket) {
        try {
            socket.removeAllListeners();
            socket.unref();
            socket.end();
            socket.destroy();
        } catch(e) {}
        socket = null;
    }
}, 2000);


Fertig

Edit: hier nochmal aktualisiert und hübscher RaW07/jkbms-iobroker-rs485: JK-BMS 55AA passive listener for ioBroker — reads cell voltages, SOC, temperatures and more via RS485/Ethernet, JavaScript adapter only - Codeberg.org