Ich habe mich ein paar Stunden noch mit dem JKBMS Export beschäftigt, und habe einen kleinen Viewer in Python geschrieben.
Screenshot
Hier der Quelltext:
#!/usr/bin/env python3
"""
JK-BMS .jkcfg Viewer
====================
Liest ein Einstellungs-Backup (.jkcfg) eines JK-BMS (getestet am Layout des
JK-PB2A16S20P, Firmware 19.26) und stellt die Werte in einer GUI dar.
Nur Python-Standardbibliothek (tkinter), keine Zusatzpakete noetig.
Start: python jkcfg_viewer.py [datei.jkcfg]
Hinweis: Das Dateiformat ist nicht offiziell dokumentiert. Die Offsets stammen
aus dem Community-Layout (JK02_32S, siehe github.com/syssi/esphome-jk-bms) und
wurden am Beispiel plausibilisiert. Felder mit "?" sind Vermutungen.
"""
import csv
import struct
import sys
from dataclasses import dataclass
from pathlib import Path
HEADER = b"\x55\xaa\xeb\x90"
@dataclass
class Field:
group: str
name: str
value: str
unit: str
offset: int
size: int
raw: str
certain: bool = True
# ---------------------------------------------------------------------------
# Parser (ohne GUI-Abhaengigkeit, damit er auch einzeln testbar ist)
# ---------------------------------------------------------------------------
# (Gruppe, Name, Offset, Typ, Faktor, Einheit)
# Typ: u32 = unsigned, i32 = signed
NUMERIC_FIELDS = [
("Spannungen (pro Zelle)", "Smart-Sleep-Spannung", 6, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "Zell-Unterspannung (Abschaltung)", 10, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "Zell-Unterspannung (Wiederfreigabe)", 14, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "Zell-Ueberspannung (Abschaltung)", 18, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "Zell-Ueberspannung (Wiederfreigabe)", 22, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "Balancer-Ausloeseschwelle (Zelldifferenz)", 26, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "SOC-100%-Spannung", 30, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "SOC-0%-Spannung", 34, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "Ladeende-Wiederfreigabe (RCV)", 38, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "Float-Wiederfreigabe (RFV)", 42, "u32", 1, "mV"),
("Spannungen (pro Zelle)", "System-Abschaltspannung", 46, "u32", 1, "mV"),
("Strom", "Lade-Ueberstrom (Limit)", 50, "u32", 0.001, "A"),
("Strom", "Lade-Ueberstrom Verzoegerung", 54, "u32", 1, "s"),
("Strom", "Lade-Ueberstrom Wiederfreigabe", 58, "u32", 1, "s"),
("Strom", "Entlade-Ueberstrom (Limit)", 62, "u32", 0.001, "A"),
("Strom", "Entlade-Ueberstrom Verzoegerung", 66, "u32", 1, "s"),
("Strom", "Entlade-Ueberstrom Wiederfreigabe", 70, "u32", 1, "s"),
("Strom", "Kurzschluss Wiederfreigabe", 74, "u32", 1, "s"),
("Strom", "Max. Balancer-Strom", 78, "u32", 0.001, "A"),
("Temperatur", "Laden Uebertemperatur (Abschaltung)", 82, "i32", 0.1, "°C"),
("Temperatur", "Laden Uebertemperatur (Wiederfreigabe)", 86, "i32", 0.1, "°C"),
("Temperatur", "Entladen Uebertemperatur (Abschaltung)", 90, "i32", 0.1, "°C"),
("Temperatur", "Entladen Uebertemperatur (Wiederfreigabe)", 94, "i32", 0.1, "°C"),
("Temperatur", "Laden Tieftemperatur (Abschaltung)", 98, "i32", 0.1, "°C"),
("Temperatur", "Laden Tieftemperatur (Wiederfreigabe)", 102, "i32", 0.1, "°C"),
("Temperatur", "MOSFET Uebertemperatur (Abschaltung)", 106, "i32", 0.1, "°C"),
("Temperatur", "MOSFET Uebertemperatur (Wiederfreigabe)", 110, "i32", 0.1, "°C"),
("Akku", "Zellanzahl", 114, "u32", 1, "Zellen"),
("Akku", "Kapazitaet", 130, "u32", 0.001, "Ah"),
("Sonstiges", "Kurzschluss-Verzoegerung (Rohwert)", 134, "u32", 1, ""),
("Sonstiges", "Start-Stromkalibrierung (Rohwert) ?", 138, "u32", 1, "mA"),
]
SWITCH_FIELDS = [
("Akku", "Laden aktiviert", 118),
("Akku", "Entladen aktiviert", 122),
("Akku", "Balancer aktiviert", 126),
]
# Unbekannte Zahlenfelder - nur zur Anzeige, Bedeutung unklar
UNKNOWN_U32 = [462, 538, 542, 546, 550, 562]
# Textfelder (Offset, Laenge, Gruppe, Name, sicher?)
TEXT_FIELDS = [
(306, 16, "Geraet", "Modell", True),
(322, 8, "Geraet", "Hardware-Version", True),
(330, 8, "Geraet", "Software-Version", True),
(346, 16, "Geraet", "Geraetename / Kennung", True),
(362, 16, "Zugang", "Bluetooth-Passcode ?", False),
(378, 8, "Geraet", "Herstelldatum (JJMMTT)", True),
(386, 16, "Geraet", "Seriennummer", True),
(402, 16, "Geraet", "Benutzerdaten ?", False),
(418, 16, "Zugang", "Setup-Passwort ?", False),
(434, 16, "Geraet", "Bluetooth-Name ?", False),
(490, 8, "Unbekannt", "Textfeld @490", False),
]
def _cstr(data: bytes) -> str:
return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
def _fmt_num(v: float) -> str:
if float(v).is_integer():
return str(int(v))
return f"{v:.3f}".rstrip("0").rstrip(".")
def parse_jkcfg(data: bytes):
"""Gibt (fields, warnings) zurueck."""
fields: list[Field] = []
warnings: list[str] = []
if not data.startswith(HEADER):
warnings.append("Datei beginnt nicht mit dem erwarteten Header 55 AA EB 90.")
if len(data) < 600:
warnings.append(f"Datei ist nur {len(data)} Byte lang (erwartet: 600). "
"Manche Felder fehlen evtl.")
if data[4:5] != b"\x01":
warnings.append(f"Frame-Typ @4 ist 0x{data[4]:02x}, erwartet 0x01 (Settings).")
if len(data) > 304 and data[300:304] != HEADER:
warnings.append("Zweiter Frame-Header @300 nicht gefunden.")
def has(off, size):
return off + size <= len(data)
# Zahlen
for group, name, off, typ, factor, unit in NUMERIC_FIELDS:
if not has(off, 4):
continue
fmt = "<I" if typ == "u32" else "<i"
(raw,) = struct.unpack_from(fmt, data, off)
fields.append(Field(group, name, _fmt_num(raw * factor), unit, off, 4,
str(raw), certain="?" not in name))
# Schalter
for group, name, off in SWITCH_FIELDS:
if not has(off, 4):
continue
(raw,) = struct.unpack_from("<I", data, off)
fields.append(Field(group, name, "Ja" if raw else "Nein", "", off, 4, str(raw)))
# Unbekannte Zahlen
for off in UNKNOWN_U32:
if has(off, 4):
(raw,) = struct.unpack_from("<I", data, off)
fields.append(Field("Unbekannt", f"Wert @{off}", str(raw), "", off, 4,
f"0x{raw:08x}", certain=False))
# Texte
for off, size, group, name, certain in TEXT_FIELDS:
if has(off, size):
s = _cstr(data[off:off + size])
if s or certain:
fields.append(Field(group, name, s, "", off, size,
data[off:off + size].hex(" "), certain))
# Betriebszeit / Einschaltvorgaenge
if has(338, 4):
(up,) = struct.unpack_from("<I", data, 338)
d, rem = divmod(up, 86400)
h, rem = divmod(rem, 3600)
m, s = divmod(rem, 60)
fields.append(Field("Geraet", "Betriebszeit", f"{d} d {h} h {m} min {s} s",
"", 338, 4, f"{up} s"))
if has(342, 4):
(cnt,) = struct.unpack_from("<I", data, 342)
fields.append(Field("Geraet", "Anzahl Einschaltvorgaenge", str(cnt), "", 342, 4, str(cnt)))
# Abgeleitete Werte
by_name = {f.name: f for f in fields}
try:
n = int(by_name["Zellanzahl"].value)
cap = float(by_name["Kapazitaet"].value)
ov = float(by_name["Zell-Ueberspannung (Abschaltung)"].value) / 1000
uv = float(by_name["Zell-Unterspannung (Abschaltung)"].value) / 1000
fields.append(Field("Abgeleitet", "Packspannung max. (Ueberspannung x Zellen)",
f"{ov * n:.1f}", "V", -1, 0, "", True))
fields.append(Field("Abgeleitet", "Packspannung min. (Unterspannung x Zellen)",
f"{uv * n:.1f}", "V", -1, 0, "", True))
fields.append(Field("Abgeleitet", "Nennenergie (bei 3,2 V/Zelle)",
f"{cap * n * 3.2 / 1000:.2f}", "kWh", -1, 0, "", True))
ic = float(by_name["Lade-Ueberstrom (Limit)"].value)
fields.append(Field("Abgeleitet", "Lade-Limit in C-Rate", f"{ic / cap:.2f}", "C",
-1, 0, "", True))
except (KeyError, ValueError, ZeroDivisionError):
pass
# Plausibilitaetshinweise
def val(name):
try:
return float(by_name[name].value)
except (KeyError, ValueError):
return None
ov = val("Zell-Ueberspannung (Abschaltung)")
if ov and ov > 3600:
warnings.append(f"Ueberspannung {ov:.0f} mV pro Zelle liegt am oberen Rand fuer LiFePO4 "
"(oft 3550-3600 mV gewaehlt).")
cut = val("Laden Tieftemperatur (Abschaltung)")
if cut is not None and cut < 0:
warnings.append(f"Laden ist bis {cut:.1f} °C erlaubt - LiFePO4 sollte nicht unter 0 °C "
"geladen werden (Lithium-Plating).")
for f in fields:
if f.name in ("Bluetooth-Passcode ?", "Setup-Passwort ?") and f.value in ("1234", "123456"):
warnings.append(f"{f.name.rstrip(' ?')} steht noch auf dem Standardwert ({f.value}).")
return fields, warnings
def hexdump(data: bytes, known: list[Field]) -> str:
"""Hexdump mit Markierung, welche Bytes einem bekannten Feld zugeordnet sind."""
covered = set()
for f in known:
if f.offset >= 0:
covered.update(range(f.offset, f.offset + f.size))
lines = []
for i in range(0, len(data), 16):
chunk = data[i:i + 16]
hx = " ".join(f"{b:02x}" + ("*" if (i + k) in covered else " ")
for k, b in enumerate(chunk))
asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
lines.append(f"{i:04d} {hx:<64} {asc}")
lines.append("")
lines.append("* = Byte gehoert zu einem dekodierten Feld")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------
def run_gui(initial: str | None = None):
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
root = tk.Tk()
root.title("JK-BMS .jkcfg Viewer")
root.geometry("1000x680")
state = {"path": None, "data": b"", "fields": [], "warnings": []}
# Oben: Dateileiste
top = ttk.Frame(root, padding=6)
top.pack(fill="x")
path_var = tk.StringVar(value="Keine Datei geladen")
ttk.Button(top, text="Datei öffnen …", command=lambda: choose()).pack(side="left")
ttk.Button(top, text="Als CSV exportieren …", command=lambda: export_csv()).pack(side="left", padx=6)
ttk.Label(top, textvariable=path_var).pack(side="left", padx=10)
search_var = tk.StringVar()
ttk.Label(top, text="Filter:").pack(side="right")
ttk.Entry(top, textvariable=search_var, width=22).pack(side="right", padx=4)
nb = ttk.Notebook(root)
nb.pack(fill="both", expand=True, padx=6, pady=(0, 6))
# Tab 1: Parameter
tab_params = ttk.Frame(nb)
nb.add(tab_params, text="Parameter")
cols = ("name", "value", "unit", "offset", "raw")
tree = ttk.Treeview(tab_params, columns=cols, show="tree headings")
tree.heading("#0", text="Gruppe")
tree.column("#0", width=180)
for c, t, w in [("name", "Parameter", 340), ("value", "Wert", 170),
("unit", "Einheit", 70), ("offset", "Offset", 60), ("raw", "Rohwert", 150)]:
tree.heading(c, text=t)
tree.column(c, width=w, anchor="w")
tree.tag_configure("uncertain", foreground="#888888")
vsb = ttk.Scrollbar(tab_params, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
# Tab 2: Hinweise
tab_warn = ttk.Frame(nb)
nb.add(tab_warn, text="Hinweise")
warn_text = tk.Text(tab_warn, wrap="word", font=("TkDefaultFont", 10))
warn_text.pack(fill="both", expand=True)
# Tab 3: Hexdump
tab_hex = ttk.Frame(nb)
nb.add(tab_hex, text="Hexdump")
hex_text = tk.Text(tab_hex, wrap="none", font=("Courier", 10))
hex_text.pack(fill="both", expand=True)
status = tk.StringVar(value="")
ttk.Label(root, textvariable=status, anchor="w", padding=4).pack(fill="x")
def refresh_tree(*_):
tree.delete(*tree.get_children())
flt = search_var.get().strip().lower()
groups = {}
order = ["Geraet", "Akku", "Spannungen (pro Zelle)", "Strom", "Temperatur",
"Abgeleitet", "Zugang", "Sonstiges", "Unbekannt"]
for f in state["fields"]:
if flt and flt not in f.name.lower() and flt not in f.value.lower() \
and flt not in f.group.lower():
continue
if f.group not in groups:
groups[f.group] = tree.insert("", "end", text=f.group, open=True)
tree.insert(groups[f.group], "end",
values=(f.name, f.value, f.unit,
"" if f.offset < 0 else f.offset, f.raw),
tags=() if f.certain else ("uncertain",))
# Gruppen sortieren
items = tree.get_children()
sorted_items = sorted(items, key=lambda i: order.index(tree.item(i, "text"))
if tree.item(i, "text") in order else 99)
for idx, i in enumerate(sorted_items):
tree.move(i, "", idx)
def load(path):
try:
data = Path(path).read_bytes()
except OSError as e:
messagebox.showerror("Fehler", f"Datei konnte nicht gelesen werden:\n{e}")
return
fields, warnings = parse_jkcfg(data)
state.update(path=path, data=data, fields=fields, warnings=warnings)
path_var.set(path)
refresh_tree()
warn_text.config(state="normal")
warn_text.delete("1.0", "end")
if warnings:
warn_text.insert("end", "\n\n".join("• " + w for w in warnings))
else:
warn_text.insert("end", "Keine Auffälligkeiten erkannt.")
warn_text.config(state="disabled")
hex_text.config(state="normal")
hex_text.delete("1.0", "end")
hex_text.insert("end", hexdump(data, fields))
hex_text.config(state="disabled")
status.set(f"{len(data)} Byte, {len(fields)} Felder dekodiert, "
f"{len(warnings)} Hinweis(e). Graue Zeilen = Zuordnung unsicher.")
def choose():
p = filedialog.askopenfilename(
title="JK-BMS Konfiguration wählen",
filetypes=[("JK-BMS Config", "*.jkcfg"), ("Alle Dateien", "*.*")])
if p:
load(p)
def export_csv():
if not state["fields"]:
messagebox.showinfo("CSV-Export", "Zuerst eine Datei laden.")
return
p = filedialog.asksaveasfilename(defaultextension=".csv",
filetypes=[("CSV", "*.csv")])
if not p:
return
with open(p, "w", newline="", encoding="utf-8-sig") as fh:
w = csv.writer(fh, delimiter=";")
w.writerow(["Gruppe", "Parameter", "Wert", "Einheit", "Offset", "Rohwert", "Sicher"])
for f in state["fields"]:
w.writerow([f.group, f.name, f.value, f.unit,
"" if f.offset < 0 else f.offset, f.raw,
"ja" if f.certain else "nein"])
status.set(f"CSV gespeichert: {p}")
search_var.trace_add("write", refresh_tree)
if initial:
load(initial)
root.mainloop()
if __name__ == "__main__":
if "--selftest" in sys.argv:
f, w = parse_jkcfg(Path(sys.argv[-1]).read_bytes())
for x in f:
print(f"{x.group:26} {x.name:48} {x.value:>22} {x.unit}")
print("\n".join("WARN: " + s for s in w))
else:
run_gui(sys.argv[1] if len(sys.argv) > 1 else None)