549 lines
21 KiB
Python
549 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Small desktop BLE client for Berger LFP / JBD-style battery telemetry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from concurrent.futures import Future
|
|
|
|
try:
|
|
import tkinter as tk
|
|
from tkinter import messagebox, ttk
|
|
except ModuleNotFoundError: # Allows parser tests on minimal/headless Python.
|
|
tk = None
|
|
messagebox = None
|
|
ttk = None
|
|
|
|
try:
|
|
from bleak import BleakClient, BleakScanner
|
|
except ModuleNotFoundError: # Allows parser tests before GUI dependencies are installed.
|
|
BleakClient = None
|
|
BleakScanner = None
|
|
|
|
SERVICE_UUID = "0000ff00-0000-1000-8000-00805f9b34fb"
|
|
NOTIFY_UUID = "0000ff01-0000-1000-8000-00805f9b34fb"
|
|
WRITE_UUID = "0000ff02-0000-1000-8000-00805f9b34fb"
|
|
|
|
|
|
def checksum(command: int, payload: bytes = b"") -> int:
|
|
total = (command + len(payload) + sum(payload)) & 0xFFFF
|
|
return ((total ^ 0xFFFF) + 1) & 0xFFFF
|
|
|
|
|
|
def build_frame(mode: int, command: int, payload: bytes = b"") -> bytes:
|
|
check = checksum(command, payload)
|
|
return bytes([0xDD, mode, command, len(payload), *payload, check >> 8, check & 0xFF, 0x77])
|
|
|
|
|
|
def read_frame(command: int) -> bytes:
|
|
return build_frame(0xA5, command)
|
|
|
|
|
|
def write_frame(command: int, payload: bytes) -> bytes:
|
|
return build_frame(0x5A, command, payload)
|
|
|
|
|
|
def mos_mode(charge_enabled: bool, discharge_enabled: bool) -> int:
|
|
return (0 if charge_enabled else 1) | (0 if discharge_enabled else 2)
|
|
|
|
|
|
def cell_statistics(cells: list[float]) -> tuple[float, float, float] | None:
|
|
if not cells:
|
|
return None
|
|
minimum = min(cells)
|
|
maximum = max(cells)
|
|
return minimum, maximum, round(maximum - minimum, 3)
|
|
|
|
|
|
def validate_frame(frame: bytes) -> bool:
|
|
if len(frame) < 7 or frame[0] != 0xDD or frame[-1] != 0x77:
|
|
return False
|
|
payload_len = frame[3]
|
|
if len(frame) != payload_len + 7:
|
|
return False
|
|
# Response checksums cover status, length, and payload, but not the command.
|
|
expected = (-sum(frame[2:-3])) & 0xFFFF
|
|
actual = int.from_bytes(frame[-3:-1], "big")
|
|
return actual == expected
|
|
|
|
|
|
def split_frames(buffer: bytearray) -> list[bytes]:
|
|
frames: list[bytes] = []
|
|
while True:
|
|
try:
|
|
start = buffer.index(0xDD)
|
|
except ValueError:
|
|
buffer.clear()
|
|
return frames
|
|
if start:
|
|
del buffer[:start]
|
|
if len(buffer) < 7:
|
|
return frames
|
|
frame_len = buffer[3] + 7
|
|
if len(buffer) < frame_len:
|
|
return frames
|
|
frame = bytes(buffer[:frame_len])
|
|
del buffer[:frame_len]
|
|
if frame[-1] == 0x77:
|
|
frames.append(frame)
|
|
|
|
|
|
@dataclass
|
|
class Telemetry:
|
|
voltage: float = 0.0
|
|
current: float = 0.0
|
|
power: float = 0.0
|
|
soc: int = 0
|
|
remaining_capacity: float = 0.0
|
|
nominal_capacity: float = 0.0
|
|
cycle_count: int = 0
|
|
production_date: str = ""
|
|
software_version: str = ""
|
|
charge_mos: bool = False
|
|
discharge_mos: bool = False
|
|
balancing: bool = False
|
|
protection: bool = False
|
|
protection_bit: int | None = None
|
|
temperatures: list[float] = field(default_factory=list)
|
|
cells: list[float] = field(default_factory=list)
|
|
time_label: str = "Est. time"
|
|
time_value: str = "--"
|
|
|
|
|
|
@dataclass
|
|
class DeviceState:
|
|
telemetry: Telemetry = field(default_factory=Telemetry)
|
|
rx_log: list[str] = field(default_factory=list)
|
|
tx_log: list[str] = field(default_factory=list)
|
|
|
|
|
|
def word(data: bytes, offset: int) -> int:
|
|
return (data[offset] << 8) | data[offset + 1]
|
|
|
|
|
|
def signed_current(raw: int) -> float:
|
|
if raw > 32768:
|
|
raw -= 65536
|
|
return round(raw / 100, 2)
|
|
|
|
|
|
def transform_time(hours: float) -> str:
|
|
whole = int(hours)
|
|
minutes = int((hours - whole) * 60)
|
|
return f"{whole} h {minutes:02d} m"
|
|
|
|
|
|
def decode_response(frame: bytes, state: DeviceState) -> None:
|
|
if not validate_frame(frame):
|
|
return
|
|
command = frame[1]
|
|
status = frame[2]
|
|
length = frame[3]
|
|
payload = frame[4 : 4 + length]
|
|
t = state.telemetry
|
|
|
|
if command == 0x03 and status == 0 and length >= 23:
|
|
t.voltage = round(word(payload, 0) / 100, 2)
|
|
t.current = signed_current(word(payload, 2))
|
|
t.power = round(t.voltage * t.current, 2)
|
|
t.remaining_capacity = round(word(payload, 4) / 100, 2)
|
|
t.nominal_capacity = round(word(payload, 6) / 100, 2)
|
|
t.cycle_count = word(payload, 8)
|
|
date_raw = word(payload, 10)
|
|
day = date_raw & 0x1F
|
|
month = (date_raw >> 5) & 0x0F
|
|
year = 2000 + (date_raw >> 9)
|
|
t.production_date = f"{year}-{month}-{day}"
|
|
t.balancing = payload[12] != 0 or payload[13] != 0
|
|
t.protection = payload[16] != 0 or payload[17] != 0
|
|
if t.protection:
|
|
bits = f"{payload[16]:08b}{payload[17]:08b}"[::-1]
|
|
t.protection_bit = bits.find("1")
|
|
version = str(payload[18])
|
|
t.software_version = f"{version[0]}.{version[1]}" if len(version) > 1 else version
|
|
t.soc = payload[19]
|
|
mos = payload[20]
|
|
t.charge_mos = mos in {1, 3}
|
|
t.discharge_mos = mos in {2, 3}
|
|
temp_count = payload[22] if length > 22 else 0
|
|
temps = []
|
|
for idx in range(temp_count):
|
|
off = 23 + idx * 2
|
|
if off + 1 < len(payload):
|
|
temps.append(round((word(payload, off) - 2731) / 10, 1))
|
|
t.temperatures = temps
|
|
if t.current == 0:
|
|
t.time_label = "Est. time"
|
|
t.time_value = "--"
|
|
elif t.current > 0 and t.nominal_capacity:
|
|
t.time_label = "Time till full"
|
|
t.time_value = transform_time((t.nominal_capacity - t.remaining_capacity) / t.current)
|
|
elif t.current < 0:
|
|
t.time_label = "Time till empty"
|
|
t.time_value = transform_time(t.remaining_capacity / -t.current)
|
|
|
|
elif command == 0x04 and status == 0:
|
|
cells = []
|
|
for off in range(0, len(payload) - 1, 2):
|
|
cells.append(round(word(payload, off) / 1000, 3))
|
|
t.cells = cells
|
|
|
|
|
|
def berger_match_reason(name: str | None, service_uuids: list[str]) -> str | None:
|
|
normalized = {s.lower() for s in service_uuids}
|
|
if SERVICE_UUID in normalized:
|
|
return "service:ff00"
|
|
if name and any(token in name.upper() for token in ("JBD", "BMS", "BERGER")):
|
|
return "name"
|
|
return None
|
|
|
|
|
|
class BergerApp:
|
|
def __init__(self, root: tk.Tk) -> None:
|
|
self.root = root
|
|
self.root.title("Berger LFP BLE")
|
|
self.state = DeviceState()
|
|
self.devices = []
|
|
self.client: BleakClient | None = None
|
|
self.write_response = False
|
|
self.buffer = bytearray()
|
|
self.response_waiters: dict[int, asyncio.Future] = {}
|
|
self.poll_lock = asyncio.Lock()
|
|
self.loop = asyncio.new_event_loop()
|
|
threading.Thread(target=self.loop.run_forever, daemon=True).start()
|
|
|
|
self.status = tk.StringVar(value="Idle")
|
|
self.scan_seconds = tk.IntVar(value=6)
|
|
self.direct = tk.StringVar(value="")
|
|
self.charge_enabled = tk.BooleanVar(value=False)
|
|
self.discharge_enabled = tk.BooleanVar(value=False)
|
|
self.mos_ready = False
|
|
self.mos_busy = False
|
|
self.rows: dict[str, tk.StringVar] = {}
|
|
self.cell_rows: list[tk.StringVar] = []
|
|
self._build_ui()
|
|
|
|
def _build_ui(self) -> None:
|
|
root = ttk.Frame(self.root, padding=10)
|
|
root.grid(sticky="nsew")
|
|
self.root.columnconfigure(0, weight=1)
|
|
self.root.rowconfigure(0, weight=1)
|
|
|
|
top = ttk.Frame(root)
|
|
top.grid(row=0, column=0, sticky="ew")
|
|
ttk.Button(top, text="Scan", command=self.scan).grid(row=0, column=0)
|
|
ttk.Spinbox(top, from_=3, to=20, textvariable=self.scan_seconds, width=4).grid(row=0, column=1)
|
|
ttk.Entry(top, textvariable=self.direct, width=32).grid(row=0, column=2, padx=6)
|
|
ttk.Button(top, text="Connect", command=self.connect_selected).grid(row=0, column=3)
|
|
ttk.Label(top, textvariable=self.status).grid(row=0, column=4, padx=8, sticky="w")
|
|
|
|
self.listbox = tk.Listbox(root, height=8)
|
|
self.listbox.grid(row=1, column=0, sticky="nsew", pady=8)
|
|
|
|
data = ttk.Frame(root)
|
|
data.grid(row=2, column=0, sticky="nsew")
|
|
labels = [
|
|
("soc", "SOC"),
|
|
("voltage", "Voltage"),
|
|
("current", "Current"),
|
|
("power", "Power"),
|
|
("remaining_capacity", "Remaining Ah"),
|
|
("nominal_capacity", "Nominal Ah"),
|
|
("cycle_count", "Cycles"),
|
|
("mos", "MOS"),
|
|
("temperatures", "Temps"),
|
|
("cells", "Cell count"),
|
|
("cell_min", "Cell min"),
|
|
("cell_max", "Cell max"),
|
|
("cell_delta", "Cell delta"),
|
|
("time", "Time"),
|
|
("production_date", "Production"),
|
|
("software_version", "Software"),
|
|
("status", "Status"),
|
|
]
|
|
for idx, (key, label) in enumerate(labels):
|
|
self.rows[key] = tk.StringVar(value="--")
|
|
ttk.Label(data, text=label).grid(row=idx // 2, column=(idx % 2) * 2, sticky="e", padx=4, pady=2)
|
|
ttk.Label(data, textvariable=self.rows[key], width=32).grid(row=idx // 2, column=(idx % 2) * 2 + 1, sticky="w")
|
|
|
|
cells = ttk.Frame(root)
|
|
cells.grid(row=3, column=0, sticky="ew", pady=(8, 2))
|
|
ttk.Label(cells, text="Cell voltages").grid(row=0, column=0, sticky="ne", padx=4, pady=2)
|
|
self.cell_values = ttk.Frame(cells)
|
|
self.cell_values.grid(row=0, column=1, sticky="w")
|
|
|
|
controls = ttk.Frame(root)
|
|
controls.grid(row=4, column=0, sticky="ew", pady=8)
|
|
ttk.Button(controls, text="Refresh", command=self.poll_once).grid(row=0, column=0)
|
|
ttk.Button(controls, text="Disconnect", command=self.disconnect).grid(row=0, column=1, padx=6)
|
|
self.charge_control = ttk.Checkbutton(
|
|
controls,
|
|
text="Charge enabled",
|
|
variable=self.charge_enabled,
|
|
command=self.set_mos,
|
|
)
|
|
self.charge_control.grid(row=0, column=2, padx=(12, 6))
|
|
self.discharge_control = ttk.Checkbutton(
|
|
controls,
|
|
text="Discharge enabled",
|
|
variable=self.discharge_enabled,
|
|
command=self.set_mos,
|
|
)
|
|
self.discharge_control.grid(row=0, column=3, padx=6)
|
|
self._update_mos_controls()
|
|
|
|
def run(self, coro):
|
|
future = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
future.add_done_callback(self._handle_future)
|
|
return future
|
|
|
|
def _handle_future(self, future: Future) -> None:
|
|
try:
|
|
future.result()
|
|
except Exception as exc:
|
|
self.root.after(0, lambda: self.status.set(f"BLE error: {exc}"))
|
|
|
|
def scan(self) -> None:
|
|
self.status.set("Scanning")
|
|
self.run(self._scan())
|
|
|
|
async def _scan(self) -> None:
|
|
if BleakScanner is None:
|
|
self.root.after(0, lambda: self.status.set("Install bleak first"))
|
|
return
|
|
devices = await BleakScanner.discover(timeout=self.scan_seconds.get(), return_adv=True)
|
|
self.devices = []
|
|
for device, adv in devices.values():
|
|
services = list(adv.service_uuids or [])
|
|
reason = berger_match_reason(device.name, services)
|
|
self.devices.append((device, adv, reason))
|
|
self.devices.sort(key=lambda item: (item[2] is None, -(item[1].rssi or -999)))
|
|
self.root.after(0, self._render_devices)
|
|
|
|
def _render_devices(self) -> None:
|
|
self.listbox.delete(0, tk.END)
|
|
for device, adv, reason in self.devices:
|
|
mark = "*" if reason else " "
|
|
self.listbox.insert(tk.END, f"{mark} {device.address} {adv.rssi:>4} {device.name or ''} {reason or ''}")
|
|
self.status.set(f"{len(self.devices)} devices")
|
|
|
|
def connect_selected(self) -> None:
|
|
address = self.direct.get().strip()
|
|
if not address:
|
|
selected = self.listbox.curselection()
|
|
if not selected:
|
|
messagebox.showinfo("Berger LFP BLE", "Select a device or enter an address.")
|
|
return
|
|
address = self.devices[selected[0]][0].address
|
|
self.mos_ready = False
|
|
self.mos_busy = False
|
|
self._update_mos_controls()
|
|
self.status.set("Connecting")
|
|
self.run(self._connect(address))
|
|
|
|
async def _connect(self, address: str) -> None:
|
|
if BleakClient is None:
|
|
self.root.after(0, lambda: self.status.set("Install bleak first"))
|
|
return
|
|
self.client = BleakClient(address)
|
|
await self.client.connect()
|
|
services = self.client.services
|
|
if services is None:
|
|
services = await self.client.get_services()
|
|
write_char = services.get_characteristic(WRITE_UUID)
|
|
notify_char = services.get_characteristic(NOTIFY_UUID)
|
|
if write_char is None:
|
|
raise RuntimeError(f"write characteristic not found: {WRITE_UUID}")
|
|
if notify_char is None:
|
|
raise RuntimeError(f"notify characteristic not found: {NOTIFY_UUID}")
|
|
props = set(write_char.properties)
|
|
self.write_response = "write-without-response" not in props and "write" in props
|
|
await self.client.start_notify(NOTIFY_UUID, self._on_notify)
|
|
await self._poll_once()
|
|
|
|
async def _send(self, data: bytes) -> None:
|
|
if not self.client:
|
|
return
|
|
self.state.tx_log.append(data.hex().upper())
|
|
for off in range(0, len(data), 20):
|
|
await self.client.write_gatt_char(WRITE_UUID, data[off : off + 20], response=self.write_response)
|
|
|
|
def _on_notify(self, _sender, data: bytearray) -> None:
|
|
self.buffer.extend(data)
|
|
base_received = False
|
|
for frame in split_frames(self.buffer):
|
|
self.state.rx_log.append(frame.hex().upper())
|
|
decode_response(frame, self.state)
|
|
waiter = self.response_waiters.get(frame[1])
|
|
if waiter and not waiter.done():
|
|
waiter.set_result(frame)
|
|
if validate_frame(frame) and frame[1] == 0x03 and frame[2] == 0:
|
|
base_received = True
|
|
self.root.after(0, lambda: self.status.set(f"Connected, TX {len(self.state.tx_log)}, RX {len(self.state.rx_log)}"))
|
|
self.root.after(0, self._base_received if base_received else self._render_state)
|
|
|
|
def _base_received(self) -> None:
|
|
self.mos_ready = True
|
|
self._render_state()
|
|
self._update_mos_controls()
|
|
|
|
def _update_mos_controls(self) -> None:
|
|
state = "normal" if self.mos_ready and not self.mos_busy else "disabled"
|
|
self.charge_control.configure(state=state)
|
|
self.discharge_control.configure(state=state)
|
|
|
|
def _render_state(self) -> None:
|
|
t = self.state.telemetry
|
|
self.rows["soc"].set(f"{t.soc} %")
|
|
self.rows["voltage"].set(f"{t.voltage:.2f} V")
|
|
self.rows["current"].set(f"{t.current:.2f} A")
|
|
self.rows["power"].set(f"{t.power:.2f} W")
|
|
self.rows["remaining_capacity"].set(f"{t.remaining_capacity:.2f} Ah")
|
|
self.rows["nominal_capacity"].set(f"{t.nominal_capacity:.2f} Ah")
|
|
self.rows["cycle_count"].set(str(t.cycle_count))
|
|
self.rows["mos"].set(f"charge {'on' if t.charge_mos else 'off'}, discharge {'on' if t.discharge_mos else 'off'}")
|
|
self.charge_enabled.set(t.charge_mos)
|
|
self.discharge_enabled.set(t.discharge_mos)
|
|
self.rows["temperatures"].set(", ".join(f"{v:.1f} C" for v in t.temperatures) or "--")
|
|
self.rows["cells"].set(str(len(t.cells)) if t.cells else "--")
|
|
stats = cell_statistics(t.cells)
|
|
self.rows["cell_min"].set(f"{stats[0]:.3f} V" if stats else "--")
|
|
self.rows["cell_max"].set(f"{stats[1]:.3f} V" if stats else "--")
|
|
self.rows["cell_delta"].set(f"{stats[2]:.3f} V" if stats else "--")
|
|
self._render_cells(t.cells)
|
|
self.rows["time"].set(f"{t.time_label}: {t.time_value}")
|
|
self.rows["production_date"].set(t.production_date or "--")
|
|
self.rows["software_version"].set(t.software_version or "--")
|
|
status = []
|
|
if t.balancing:
|
|
status.append("balancing")
|
|
if t.protection:
|
|
status.append(f"protection bit {t.protection_bit}")
|
|
self.rows["status"].set(", ".join(status) or "normal")
|
|
|
|
def _render_cells(self, cells: list[float]) -> None:
|
|
if len(self.cell_rows) != len(cells):
|
|
for widget in self.cell_values.winfo_children():
|
|
widget.destroy()
|
|
self.cell_rows = []
|
|
for index in range(len(cells)):
|
|
row = index // 4
|
|
column = (index % 4) * 2
|
|
value = tk.StringVar()
|
|
self.cell_rows.append(value)
|
|
ttk.Label(self.cell_values, text=f"Cell {index + 1}").grid(
|
|
row=row, column=column, sticky="e", padx=(4, 2), pady=2
|
|
)
|
|
ttk.Label(self.cell_values, textvariable=value, width=9).grid(
|
|
row=row, column=column + 1, sticky="w", padx=(0, 8), pady=2
|
|
)
|
|
for value, voltage in zip(self.cell_rows, cells):
|
|
value.set(f"{voltage:.3f} V")
|
|
|
|
def poll_once(self) -> None:
|
|
self.run(self._poll_once())
|
|
|
|
async def _exchange(self, command: int, frame: bytes) -> bytes | None:
|
|
waiter = self.loop.create_future()
|
|
self.response_waiters[command] = waiter
|
|
try:
|
|
await self._send(frame)
|
|
return await asyncio.wait_for(waiter, timeout=2.0)
|
|
except TimeoutError:
|
|
return None
|
|
finally:
|
|
if self.response_waiters.get(command) is waiter:
|
|
del self.response_waiters[command]
|
|
|
|
async def _request(self, command: int) -> bool:
|
|
response = await self._exchange(command, read_frame(command))
|
|
return bool(response and validate_frame(response) and response[2] == 0)
|
|
|
|
async def _poll_once(self) -> None:
|
|
if not self.client or not self.client.is_connected:
|
|
self.root.after(0, lambda: self.status.set("Not connected"))
|
|
return
|
|
async with self.poll_lock:
|
|
missing = []
|
|
for index, command in enumerate((0x03, 0x04)):
|
|
if index:
|
|
await asyncio.sleep(0.15)
|
|
if not await self._request(command):
|
|
missing.append(f"0x{command:02X}")
|
|
suffix = f", no response from {', '.join(missing)}" if missing else ""
|
|
self.root.after(
|
|
0,
|
|
lambda: self.status.set(
|
|
f"Connected, TX {len(self.state.tx_log)}, RX {len(self.state.rx_log)}{suffix}"
|
|
),
|
|
)
|
|
|
|
def set_mos(self) -> None:
|
|
if not self.mos_ready or self.mos_busy:
|
|
self._render_state()
|
|
return
|
|
charge_enabled = self.charge_enabled.get()
|
|
discharge_enabled = self.discharge_enabled.get()
|
|
self.mos_busy = True
|
|
self._update_mos_controls()
|
|
self.status.set("Updating MOS state")
|
|
self.run(self._set_mos(charge_enabled, discharge_enabled))
|
|
|
|
async def _set_mos(self, charge_enabled: bool, discharge_enabled: bool) -> None:
|
|
if not self.client or not self.client.is_connected:
|
|
self.root.after(0, lambda: self._finish_mos_change("Not connected", ready=False))
|
|
return
|
|
async with self.poll_lock:
|
|
mode = mos_mode(charge_enabled, discharge_enabled)
|
|
frame = write_frame(0xE1, bytes([0x00, mode]))
|
|
response = await self._exchange(0xE1, frame)
|
|
accepted = bool(response and validate_frame(response) and response[2] == 0)
|
|
refreshed = False
|
|
if accepted:
|
|
await asyncio.sleep(0.15)
|
|
refreshed = await self._request(0x03)
|
|
|
|
if not accepted:
|
|
message = "MOS command was not acknowledged"
|
|
elif not refreshed:
|
|
message = "MOS updated; telemetry refresh timed out"
|
|
else:
|
|
message = f"MOS updated, TX {len(self.state.tx_log)}, RX {len(self.state.rx_log)}"
|
|
self.root.after(0, lambda: self._finish_mos_change(message, ready=True))
|
|
|
|
def _finish_mos_change(self, message: str, ready: bool) -> None:
|
|
self.mos_busy = False
|
|
self.mos_ready = ready
|
|
self._render_state()
|
|
self._update_mos_controls()
|
|
self.status.set(message)
|
|
|
|
def disconnect(self) -> None:
|
|
self.run(self._disconnect())
|
|
|
|
async def _disconnect(self) -> None:
|
|
if self.client:
|
|
await self.client.disconnect()
|
|
self.client = None
|
|
self.root.after(0, self._show_disconnected)
|
|
|
|
def _show_disconnected(self) -> None:
|
|
self.mos_ready = False
|
|
self.mos_busy = False
|
|
self._update_mos_controls()
|
|
self.status.set("Disconnected")
|
|
|
|
|
|
def main() -> None:
|
|
if tk is None:
|
|
raise SystemExit("tkinter is not installed. Install python3-tk to run the GUI.")
|
|
root = tk.Tk()
|
|
BergerApp(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|