-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathjkbms_55aa.js
More file actions
416 lines (383 loc) · 20.7 KB
/
Copy pathjkbms_55aa.js
File metadata and controls
416 lines (383 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
/**
* JK BMS PB2A16S20P — 55AA Passive Listener für ioBroker v8
* ===========================================================
* Liest JK-BMS-Daten per RS485/Ethernet direkt in ioBroker ein.
* Kein Polling nötig — der BMS sendet alle ~200 ms automatisch.
*
* Protokoll: JK-BMS 55AA Binary Broadcast
* Frame-Header: 55 AA EB 90 ...
* Frame-Länge: ~348 Byte
* Prüfsumme: CRC16 Modbus
* Relevanter Typ: 0x1620 (DYNAMIC) — Spannung, Strom, SOC, Temperaturen
*
* Hardware:
* - JK-BMS RS485-2 (rechter Port) → RS485/Ethernet-Konverter (Waveshare, Ebyte o.ä.)
* - Konverter-Modus: transparenter TCP-Server, Baudrate 115200
* - JK-BMS DIP-Schalter: 0000 (Master #0, RS485-Modus)
*
* Architektur:
* Parsen läuft kontinuierlich auf jedem TCP-Chunk.
* Schreiben nach ioBroker nur vom Timer — zwei Stufen:
* Schnell (writeIntervalMs): Messwerte, Temperaturen, Zellspannungen
* Langsam (slowIntervalMs): Widerstände, SOH, Zyklen, Laufzeit
* Change-only + Write-Budget: setState nur bei Wertänderung, max. writeBudget
* Aufrufe pro Zyklus. Überschuss wird im nächsten Zyklus nachgeholt.
* → Startup, Alarm-Sturm und Steady-state werden gleichmäßig behandelt.
*
* ── KONFIGURATION ────────────────────────────────────────────
*/
const CONFIG = {
host: '192.168.7.109', // IP des RS485/Ethernet-Konverters
port: 5000, // TCP-Port des Konverters (Standard: 5000 oder 8899)
statePrefix: 'javascript.0.jkbms',
reconnectMs: 5000, // Wartezeit vor Reconnect (ms)
maxPackId: 3, // Höchste Pack-ID (0 = 1 Pack, 3 = bis zu 4 Packs)
cacheMaxAgeS: 60, // Pack gilt als offline nach N Sekunden ohne Frame
writeIntervalMs: 5000, // Schnelles Schreib-Intervall (5 s, change-only)
slowIntervalMs: 300000, // Langsames Schreib-Intervall (5 min, für Widerstände etc.)
writeBudget: 80, // Max. setState-Aufrufe pro Zyklus inkl. last_update
// ioBroker-Limit: ~1000 setState/min
// Worst-case: 80 × 12 Zyklen/min = 960/min (inkl. last_update)
// States pro Pack: ~41 fast + 21 slow = ~62 gesamt
// 4 Packs + Total: ~168 fast + 84 slow = ~252 gesamt
};
const net = require('net');
let socket = null;
let reconnTimer= null;
let writeTimer = null;
let slowTimer = 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 }
// Change-only Cache: zuletzt geschriebener Wert pro State-ID
const writeCache = {}; // stateId → letzter geschriebener Wert
// Budget-Zähler: wird zu Beginn jedes Schreibzyklus zurückgesetzt
let cycleWrites = 0;
// setState nur bei Wertänderung und innerhalb des Zyklus-Budgets.
// Überschreitet der Zyklus das Budget, bleibt writeCache unverändert
// → der Wert gilt weiterhin als "geändert" und wird im nächsten Zyklus nachgeholt.
function ws(id, val) {
if (writeCache[id] === val) return; // kein Schreiben nötig
if (cycleWrites >= CONFIG.writeBudget) return; // Budget erschöpft, nächster Zyklus
writeCache[id] = val;
setState(id, val, true);
cycleWrites++;
}
// ── 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 ───────────────────────────────────────────
// Jeder 55AA-Frame endet mit einem 8-Byte Modbus-ACK, der den Frame-Typ und Pack-ID enthält.
// Register 0x1620 = DYNAMIC (Messwerte), 0x161C = STATIC (Info), 0x161E = SETUP (Config)
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; // Modbus Fn-Code Write
if (buf[offset + 4] !== 0x00 || buf[offset + 5] !== 0x01) return null; // Register-Anzahl
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] }; // buf[offset] = Pack-ID (0-based)
}
// ── Buffer-Verarbeitung ───────────────────────────────────────
function processBuffer() {
while (rxBuf.length >= 348) {
// Suche Frame-Header 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) {
// Kein Header gefunden — Buffer leeren, letztes Byte behalten (könnte 0x55 sein)
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 suchen: liegt typisch bei 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 (Register 0x1620) ────────────────────
// Alle Byte-Offsets beziehen sich auf den 55AA-Frame (ab Byte 0 = 0x55).
function parseDynamic(data) {
if (data.length < 300 || data[0] !== 0x55 || data[1] !== 0xAA) return null;
// Zellspannungen: Offset 6, je 2 Byte, Little-Endian, in mV
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;
// Zellwiderstände: Offset 80, je 2 Byte, signed, in mΩ
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); // Gesamtspannung 32-bit (mV), 0 = Fallback auf 16-bit
return {
cells, resistances,
cellAvg: u16(data, 74), // mV
cellDiff: u16(data, 76), // mV (Max − Min)
cellMax: Math.max(...cells), // mV
cellMin: Math.min(...cells), // mV
tempMos: i16(data, 144) / 10.0, // °C
wireAlarm: u32(data, 146), // Draht-Alarm-Bits
voltageV: voltRaw > 0 ? voltRaw / 1000.0 : u16(data, 234) / 100.0, // V
powerW: u32(data, 154) / 1000.0, // W
currentA: i32(data, 158) / 1000.0, // A (negativ = Entladung)
tempBat1: i16(data, 162) / 10.0, // °C
tempBat2: i16(data, 164) / 10.0, // °C
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), // 32-Bit Alarm-Flags (siehe README)
balanceCurA: i16(data, 170) / 1000.0, // A
batteryState: u8(data, 172), // Rohstatus-Byte
soc: u8(data, 173), // % (0-100)
remainCap: u32(data, 174) / 1000.0, // Ah
fullCap: u32(data, 178) / 1000.0, // Ah
cycleCount: u32(data, 182), // Ladezyklen
cycleCap: u32(data, 186) / 1000.0, // Ah Zykluskapazität
soh: u8(data, 190), // % State of Health
totalRuntimeH: Math.round(u32(data, 194) / 36) / 100, // h
chargeMos: !!u8(data, 198), // Lade-MOS ein/aus
dischargeMos: !!u8(data, 199), // Entlade-MOS ein/aus
balanceMos: !!u8(data, 200), // Balance aktiv
heatingState: !!u8(data, 215), // Heizung aktiv
chargerPlugged:!!u8(data, 245), // Ladegerät gesteckt
faultCount: u8(data, 266), // Fehlerzähler
};
}
// ── ioBroker States anlegen ───────────────────────────────────
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 ────────────────────────────────────────
//
// Schnell (alle writeIntervalMs): Messwerte, Temperaturen, Zellspannungen, Flags
// Langsam (alle slowIntervalMs): Zellwiderstände, SOH, Vollkapazität, Zyklen, Laufzeit
// Change-only: ws() schreibt nur wenn sich der Wert geändert hat
function writePackFast(pid, d) {
const p = `${CONFIG.statePrefix}.pack${pid}`;
// ── Priorität 1: Spannung, Strom, Leistung, Alarmzustände ──
ws(`${p}.voltage`, Math.round(d.voltageV*1000)/1000);
ws(`${p}.current`, Math.round(d.currentA*1000)/1000);
ws(`${p}.power`, Math.round(d.powerW*10)/10);
ws(`${p}.charge_mos`, d.chargeMos);
ws(`${p}.discharge_mos`, d.dischargeMos);
ws(`${p}.alarm_bits`, d.alarmBits);
ws(`${p}.wire_alarm`, d.wireAlarm);
ws(`${p}.battery_state`, d.batteryState);
ws(`${p}.fault_count`, d.faultCount);
ws(`${p}.heating_state`, d.heatingState);
ws(`${p}.charger_plugged`, d.chargerPlugged);
// ── Priorität 2: Temperaturen, Balance, Zell-Stats ──────────
ws(`${p}.temp_mos`, Math.round(d.tempMos*10)/10);
ws(`${p}.temp_bat1`, Math.round(d.tempBat1*10)/10);
ws(`${p}.temp_bat2`, Math.round(d.tempBat2*10)/10);
if (d.tempBat3!==null) ws(`${p}.temp_bat3`, Math.round(d.tempBat3*10)/10);
if (d.tempBat4!==null) ws(`${p}.temp_bat4`, Math.round(d.tempBat4*10)/10);
if (d.tempBat5!==null) ws(`${p}.temp_bat5`, Math.round(d.tempBat5*10)/10);
ws(`${p}.balance_current`, Math.round(d.balanceCurA*1000)/1000);
ws(`${p}.balance_mos`, d.balanceMos);
ws(`${p}.cell_max_mv`, d.cellMax);
ws(`${p}.cell_min_mv`, d.cellMin);
ws(`${p}.cell_avg_mv`, d.cellAvg);
ws(`${p}.cell_diff_mv`, d.cellDiff);
// ── Priorität 3: SOC, Restkapazität, Zellspannungen ─────────
ws(`${p}.soc`, d.soc);
ws(`${p}.remain_cap`, Math.round(d.remainCap*1000)/1000);
if (cycleWrites < CONFIG.writeBudget) {
setState(`${p}.last_update`, new Date().toISOString(), true);
cycleWrites++;
}
d.cells.forEach((mv,i) =>
ws(`${p}.cell_${String(i+1).padStart(2,'0')}_mv`, mv));
}
function writePackSlow(pid, d) {
const p = `${CONFIG.statePrefix}.pack${pid}`;
ws(`${p}.soh`, d.soh);
ws(`${p}.full_cap`, Math.round(d.fullCap*1000)/1000);
ws(`${p}.cycle_count`, d.cycleCount);
ws(`${p}.cycle_cap`, Math.round(d.cycleCap*10)/10);
ws(`${p}.total_runtime_h`, d.totalRuntimeH);
d.resistances.forEach((r,i) =>
ws(`${p}.cell_${String(i+1).padStart(2,'0')}_mohm`, r));
}
function writeAll() {
if (!running) return;
cycleWrites = 0; // Budget für diesen Zyklus zurücksetzen
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;
}
writePackFast(parseInt(pid), entry.data);
activePids.push(parseInt(pid));
});
// Aggregat über alle aktiven Packs
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);
ws(`${p}.voltage_avg`, Math.round(packs.reduce((s,x)=>s+x.voltageV,0)/packs.length*1000)/1000);
ws(`${p}.current`, Math.round(packs.reduce((s,x)=>s+x.currentA,0)*1000)/1000);
ws(`${p}.power`, Math.round(packs.reduce((s,x)=>s+x.powerW,0)*10)/10);
ws(`${p}.soc`, ful>0 ? Math.round(rem/ful*1000)/10 : 0);
ws(`${p}.remain_cap`, Math.round(rem*100)/100);
ws(`${p}.full_cap`, Math.round(ful*100)/100);
ws(`${p}.active_packs`, activePids.length);
}
writeTimer = setTimeout(writeAll, CONFIG.writeIntervalMs);
}
function writeAllSlow() {
if (!running) return;
cycleWrites = 0; // Budget für diesen Zyklus zurücksetzen
const now = Date.now();
const maxAge = CONFIG.cacheMaxAgeS * 1000;
Object.keys(frameCache).forEach(pid => {
const entry = frameCache[pid];
if (now - entry.ts <= maxAge) {
writePackSlow(parseInt(pid), entry.data);
}
});
slowTimer = setTimeout(writeAllSlow, CONFIG.slowIntervalMs);
}
// ── TCP-Verbindung ────────────────────────────────────────────
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); // Overflow-Schutz
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, 2000); // erster Schreibzyklus nach 2 s (Daten schnell verfügbar)
slowTimer = setTimeout(writeAllSlow, CONFIG.slowIntervalMs);
// ── Cleanup beim Script-Stop ──────────────────────────────────
onStop(() => {
log('JK BMS: Script gestoppt.', 'info');
running = false;
if (reconnTimer) { clearTimeout(reconnTimer); reconnTimer = null; }
if (writeTimer) { clearTimeout(writeTimer); writeTimer = null; }
if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
if (socket) {
try {
socket.removeAllListeners();
socket.unref();
socket.end();
socket.destroy();
} catch(e) {}
socket = null;
}
}, 2000);