Genial? JKBMS V19 | Export / Import Parameter

Liebe Community,
gerade habe ich erfahren das das JKBS in der Version 19 offenbar eine "Export / Import" Funktion hat.

Hat da jemand Erfahrungen mit? Das wäre ja super wenn man nur noch ein "Masterfile" bräuchte?

Hat sich jemand schon für die LFP 16S 280Ah ein Masterfile erstellt?

Ja gesehen hab ich das auch und ein Export erstellt. Aber ob da nun alles richtig drin steht ? Wer weiss das schon.

Kannst du das File hier hochladen oder den Quelltext der Datei hier einfügen?

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)