#!/usr/bin/env python3 """Tkinter BLE dashboard for BullTron batteries.""" from __future__ import annotations import argparse import asyncio import queue import threading import time from dataclasses import dataclass, field from typing import Any, Callable try: import tkinter as tk from tkinter import messagebox, ttk except ImportError: # pragma: no cover - depends on system Tk packages. class _MissingTk: class Tk: pass class StringVar: def __init__(self, value: str = "") -> None: self.value = value def get(self) -> str: return self.value def set(self, value: str) -> None: self.value = value class BooleanVar(StringVar): pass END = "end" BOTH = "both" X = "x" HORIZONTAL = "horizontal" LEFT = "left" tk = _MissingTk() messagebox = None ttk = None try: from bleak import BleakClient, BleakScanner except ImportError: # pragma: no cover - exercised by users without deps. BleakClient = None BleakScanner = None MAIN_SERVICE = "0000fff0-0000-1000-8000-00805f9b34fb" NOTIFY_CHAR = "0000fff1-0000-1000-8000-00805f9b34fb" WRITE_CHAR = "0000fff2-0000-1000-8000-00805f9b34fb" AT_CHAR = "0000fff3-0000-1000-8000-00805f9b34fb" SECRET_KEY_CHAR = "02f00000-0000-0000-0000-00000000ff05" VERSION_CHAR = "02f00000-0000-0000-0000-00000000ff04" BULLTRON_CAPTURE_ADDRESS = "7C:3E:82:1C:7A:86" BULLTRON_NAME_MARKERS = ("dl", "b35") BULLTRON_ADV_MARKERS = (b"DL", b"PU", b"JHB") def iter_advertisement_payloads(adv: Any) -> list[bytes]: """Best-effort extraction of payload-like byte blobs from bleak metadata.""" payloads: list[bytes] = [] for data in getattr(adv, "manufacturer_data", {}).values(): if isinstance(data, bytes): payloads.append(data) for data in getattr(adv, "service_data", {}).values(): if isinstance(data, bytes): payloads.append(data) for item in getattr(adv, "platform_data", ()) or (): if isinstance(item, bytes): payloads.append(item) elif isinstance(item, (tuple, list)): payloads.extend(part for part in item if isinstance(part, bytes)) return payloads def bulltron_match_reason(name: str, service_uuids: list[str], payloads: list[bytes] | None = None) -> str | None: lowered = name.lower() if "bull" in lowered: return "name:BullTron" for marker in BULLTRON_NAME_MARKERS: if marker in lowered: return f"name:{marker.upper()}" if MAIN_SERVICE in service_uuids: return "service:fff0" for payload in payloads or []: # The Android app checks the legacy scanRecord at byte offsets 14..16 # for ASCII DL/PU/JHB markers, then requires byte 16 to be 0x00. if len(payload) > 17 and payload[16] == 0x00: for marker in BULLTRON_ADV_MARKERS: if payload[14 : 14 + len(marker)].upper() == marker: return f"adv:{marker.decode('ascii')}" for marker in BULLTRON_ADV_MARKERS: if marker in payload.upper(): return f"adv:{marker.decode('ascii')}" return None def crc16_modbus_swapped(data: bytes) -> int: crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return ((crc & 0xFF00) >> 8) | ((crc & 0x00FF) << 8) def read_frame(start: int, count: int) -> bytes: body = bytes([0xD2, 0x03]) + start.to_bytes(2, "big") + count.to_bytes(2, "big") return body + crc16_modbus_swapped(body).to_bytes(2, "big") def write_single_frame(register: int, value: int) -> bytes: body = bytes([0xD2, 0x06]) + register.to_bytes(2, "big") + value.to_bytes(2, "big") return body + crc16_modbus_swapped(body).to_bytes(2, "big") def write_time_frame() -> bytes: now = time.localtime() payload = bytes([now.tm_year - 2000, now.tm_mon, now.tm_mday, now.tm_hour, now.tm_min, now.tm_sec]) body = bytes([0xD2, 0x10, 0x00, 0xD4, 0x00, 0x03]) + payload return body + crc16_modbus_swapped(body).to_bytes(2, "big") def words_from_payload(payload: bytes) -> list[int]: return [int.from_bytes(payload[i : i + 2], "big") for i in range(0, len(payload) - 1, 2)] def printable_ascii(data: bytes) -> str: text = "".join(chr(b) if 32 <= b <= 126 else "." for b in data) return text.strip(".\x00 ") def format_duration(minutes: float | None) -> str: if minutes is None or minutes <= 0: return "-- h -- m" hours, mins = divmod(int(round(minutes)), 60) return f"{hours} h {mins:02d} m" def split_frames(buffer: bytearray) -> list[bytes]: frames: list[bytes] = [] while True: start = buffer.find(b"\xD2") if start < 0: buffer.clear() return frames if start: del buffer[:start] if len(buffer) < 3: return frames func = buffer[1] if func == 0x03: frame_len = 3 + buffer[2] + 2 elif func in (0x06, 0x10): frame_len = 8 else: del buffer[0] continue if len(buffer) < frame_len: return frames frame = bytes(buffer[:frame_len]) del buffer[:frame_len] if len(frame) >= 5: expected = int.from_bytes(frame[-2:], "big") actual = crc16_modbus_swapped(frame[:-2]) if expected == actual: frames.append(frame) @dataclass class Telemetry: cells: list[float] = field(default_factory=list) voltage: float | None = None current: float | None = None soc: float | None = None remaining_ah: float | None = None power_w: float | None = None state: int | None = None cycle_count: int | None = None charge_mos: bool | None = None discharge_mos: bool | None = None cell_max: float | None = None cell_min: float | None = None cell_delta: float | None = None cell_count: int | None = None alarm_words: list[int] = field(default_factory=list) time_label: str = "Time" time_value: str = "-- h -- m" @dataclass class DeviceState: telemetry: Telemetry = field(default_factory=Telemetry) rated_capacity_ah: float | None = None control_pin: str | None = None firmware: str = "" battery_code: str = "" production_date: str = "" product_info: str = "" raw_blocks: dict[str, str] = field(default_factory=dict) alarms: list[str] = field(default_factory=list) ALARM_LABELS = [ "Cell volt high level 1", "Cell volt high level 2", "Cell volt low level 1", "Cell volt low level 2", "Sum volt high level 1", "Sum volt high level 2", "Sum volt low level 1", "Sum volt low level 2", "Chg temp high level 1", "Chg temp high level 2", "Chg temp low level 1", "Chg temp low level 2", "Dischg temp high level 1", "Dischg temp high level 2", "Dischg temp low level 1", "Dischg temp low level 2", "Chg overcurrent level 1", "Chg overcurrent level 2", "Dischg overcurrent level 1", "Dischg overcurrent level 2", "SOC high level 1", "SOC high level 2", "SOC Low level 1", "SOC Low level 2", "Diff volt level 1", "Diff volt level 2", "Diff temp level 1", "Diff temp level 2", "Short circuit protect fault", "AFE collect chip err", "Voltage collect dropped", "Cell temp sensor err", "EEPROM err", "RTC err", "Precharge failure", "Communication failure", "Internal communication failure", "Current module fault", "Sum voltage detect fault", "Low volt forbidden chg fault", "Chg MOS temp high alarm", "Dischg MOS temp high alarm", "Chg MOS temp sensor err", "Dischg MOS temp sensor err", "Chg MOS adhesion err", "Dischg MOS adhesion err", "Chg MOS open circuit err", "Discrg MOS open circuit err", ] def decode_live(payload: bytes, state: DeviceState) -> None: words = words_from_payload(payload) if len(words) < 62: return tel = state.telemetry cells = [raw * 0.001 for raw in words[:32] if raw > 0] tel.cells = cells tel.cell_count = len(cells) tel.voltage = words[40] * 0.1 tel.current = (words[41] - 30000) * 0.1 tel.soc = words[42] / 10 tel.cell_max = words[43] * 0.001 tel.cell_min = words[44] * 0.001 tel.state = words[47] tel.remaining_ah = words[48] * 0.1 tel.cycle_count = words[51] tel.charge_mos = words[53] == 1 tel.discharge_mos = words[54] == 1 tel.cell_delta = words[56] * 0.001 tel.alarm_words = words[58:62] if tel.voltage is not None and tel.current is not None: tel.power_w = abs(tel.voltage * tel.current) capacity = state.rated_capacity_ah or tel.remaining_ah minutes = None if capacity and tel.soc is not None and tel.current and abs(tel.current) > 0.05: if tel.state == 1: tel.time_label = "Time till full" minutes = capacity * (100 - tel.soc) / abs(tel.current) * 60 elif tel.state == 2: tel.time_label = "Time till empty" minutes = capacity * tel.soc / abs(tel.current) * 60 else: tel.time_label = "Time" tel.time_value = format_duration(minutes) state.alarms = decode_alarm_words(tel.alarm_words) def decode_alarm_words(words: list[int]) -> list[str]: active: list[str] = [] for word_index, word in enumerate(words): for bit in range(16): if word & (1 << bit): label_index = word_index * 16 + bit label = ALARM_LABELS[label_index] if label_index < len(ALARM_LABELS) else f"Alarm word {word_index + 1} bit {bit}" active.append(label) return active def decode_response(frame: bytes, state: DeviceState) -> None: if frame[1] != 0x03: state.raw_blocks["Last ACK"] = frame.hex(" ").upper() return byte_count = frame[2] payload = frame[3:-2] state.raw_blocks[f"D203 byte_count 0x{byte_count:02X}"] = payload.hex(" ").upper() if byte_count == 0x7C: decode_live(payload, state) elif byte_count == 0x06: text = printable_ascii(payload) state.control_pin = text or payload.hex().upper() elif byte_count == 0x40: state.firmware = printable_ascii(payload) or payload.hex().upper() elif byte_count == 0x18: state.battery_code = printable_ascii(payload) or payload.hex().upper() elif byte_count == 0x04: words = words_from_payload(payload) if len(words) == 2: state.production_date = f"raw {words[0]:04X} {words[1]:04X}" elif byte_count == 0x52: parse_settings(payload, state) def parse_settings(payload: bytes, state: DeviceState) -> None: words = words_from_payload(payload) candidates = [] for raw in words: val = raw * 0.1 if 20 <= val <= 1000: candidates.append(val) if candidates and state.rated_capacity_ah is None: state.rated_capacity_ah = max(candidates) class BleWorker: def __init__(self, ui_queue: queue.Queue[tuple[str, Any]]) -> None: self.ui_queue = ui_queue self.loop = asyncio.new_event_loop() self.thread = threading.Thread(target=self._run_loop, daemon=True) self.thread.start() self.client: Any = None self.buffer = bytearray() self.state = DeviceState() self.polling = False def _run_loop(self) -> None: asyncio.set_event_loop(self.loop) self.loop.run_forever() def submit(self, coro: Any) -> None: asyncio.run_coroutine_threadsafe(coro, self.loop) def scan(self, timeout: float = 12.0) -> None: self.submit(self._scan(timeout)) async def _scan(self, timeout: float) -> None: if BleakScanner is None: self.ui_queue.put(("error", "Install dependencies first: python3 -m pip install -r requirements.txt")) return self.ui_queue.put(("status", "Scanning...")) devices = await BleakScanner.discover(timeout=timeout, return_adv=True) rows = [] for _, (dev, adv) in devices.items(): name = dev.name or adv.local_name or "(unnamed)" service_uuids = [u.lower() for u in (adv.service_uuids or [])] reason = bulltron_match_reason(name, service_uuids, iter_advertisement_payloads(adv)) rows.append({"name": name, "address": dev.address, "rssi": adv.rssi, "likely": bool(reason), "reason": reason or ""}) rows.sort(key=lambda row: (not row["likely"], -(row["rssi"] or -999))) self.ui_queue.put(("scan", rows)) self.ui_queue.put(("status", f"Found {len(rows)} BLE devices")) def connect(self, address: str) -> None: self.submit(self._connect(address)) async def _connect(self, address: str) -> None: if BleakClient is None: self.ui_queue.put(("error", "Install dependencies first: python3 -m pip install -r requirements.txt")) return await self.disconnect() self.ui_queue.put(("status", f"Connecting to {address}...")) self.client = BleakClient(address, disconnected_callback=lambda _: self.ui_queue.put(("status", "Disconnected"))) await self.client.connect() self.buffer.clear() self.state = DeviceState() try: await self.client.start_notify(NOTIFY_CHAR, self._notification) except Exception as exc: self.ui_queue.put(("error", f"Could not enable fff1 notifications: {exc}")) await self.disconnect() return for char in (AT_CHAR, VERSION_CHAR): try: await self.client.start_notify(char, lambda _sender, data, source=char: self._text_notification(source, data)) except Exception as exc: self.ui_queue.put(("log", f"Optional notify on {char[-4:]} skipped: {exc}")) await self._optional_write(SECRET_KEY_CHAR, b"HiLink", response=False) await self._optional_write(VERSION_CHAR, b"AT+VER=?\r\n", response=False) await self._optional_write(AT_CHAR, b"AT+PRODUCTINFO=?", response=False) await self._write_command(write_time_frame()) self.polling = True self.ui_queue.put(("connected", address)) self.submit(self._poll_loop()) async def _optional_write(self, char: str, data: bytes, response: bool) -> None: try: if self.client and self.client.is_connected: await self.client.write_gatt_char(char, data, response=response) except Exception as exc: self.ui_queue.put(("log", f"Optional write to {char[-4:]} skipped: {exc}")) async def _write_command(self, data: bytes) -> None: if self.client and self.client.is_connected: await self.client.write_gatt_char(WRITE_CHAR, data, response=False) self.ui_queue.put(("tx", data.hex(" ").upper())) async def _poll_loop(self) -> None: slow_counter = 0 while self.polling and self.client and self.client.is_connected: try: await self._write_command(read_frame(0x0000, 0x003E)) slow_counter += 1 if slow_counter % 5 == 1: for start, count in ((0x003E, 0x0010), (0x0064, 0x0001), (0x0080, 0x0029), (0x00A9, 0x0025), (0x00C9, 0x0003), (0x00D9, 0x0005)): await asyncio.sleep(0.12) await self._write_command(read_frame(start, count)) await asyncio.sleep(2.0) except Exception as exc: self.ui_queue.put(("error", f"Polling failed: {exc}")) await self.disconnect() return def _notification(self, _sender: Any, data: bytearray) -> None: self.buffer.extend(bytes(data)) for frame in split_frames(self.buffer): decode_response(frame, self.state) self.ui_queue.put(("rx", frame.hex(" ").upper())) self.ui_queue.put(("state", self.state)) def _text_notification(self, source: str, data: bytearray) -> None: raw = bytes(data) text = printable_ascii(raw) or raw.hex(" ").upper() sender_text = str(source) if "ff04" in sender_text.lower(): self.state.firmware = text elif "fff3" in sender_text.lower(): if "PRODUCTINFO" in text.upper() or text: self.state.product_info = text self.ui_queue.put(("rx", f"{sender_text}: {raw.hex(' ').upper()}")) self.ui_queue.put(("state", self.state)) def write_mos(self, register: int, enabled: bool) -> None: self.submit(self._write_command(write_single_frame(register, 1 if enabled else 0))) async def disconnect(self) -> None: self.polling = False if self.client: try: if self.client.is_connected: await self.client.disconnect() finally: self.client = None class BullTronGui(tk.Tk): def __init__(self) -> None: super().__init__() self.title("BullTron BLE Dashboard") self.geometry("1050x720") self.queue: queue.Queue[tuple[str, Any]] = queue.Queue() self.worker = BleWorker(self.queue) self.devices: list[dict[str, Any]] = [] self.vars: dict[str, tk.StringVar] = {} self.unlocked = False self._build_ui() self.after(100, self._drain_queue) def _build_ui(self) -> None: root = ttk.PanedWindow(self, orient=tk.HORIZONTAL) root.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) left = ttk.Frame(root, width=300) root.add(left, weight=0) scan_row = ttk.Frame(left) scan_row.pack(fill=tk.X, pady=(0, 6)) ttk.Button(scan_row, text="Scan", command=self._scan).pack(side=tk.LEFT, fill=tk.X, expand=True) ttk.Label(scan_row, text="sec").pack(side=tk.RIGHT, padx=(6, 0)) self.scan_timeout = tk.StringVar(value="12") ttk.Spinbox(scan_row, from_=3, to=60, textvariable=self.scan_timeout, width=4).pack(side=tk.RIGHT) ttk.Button(left, text="Connect selected", command=self._connect_selected).pack(fill=tk.X) direct = ttk.LabelFrame(left, text="Direct connect") direct.pack(fill=tk.X, pady=(8, 0)) self.direct_address = tk.StringVar(value=BULLTRON_CAPTURE_ADDRESS) ttk.Entry(direct, textvariable=self.direct_address).pack(fill=tk.X, padx=6, pady=(6, 4)) ttk.Button(direct, text="Connect address", command=self._connect_direct).pack(fill=tk.X, padx=6, pady=(0, 6)) self.device_list = tk.Listbox(left, height=26) self.device_list.pack(fill=tk.BOTH, expand=True, pady=8) self.device_list.bind("", lambda _event: self._connect_selected()) self.status = tk.StringVar(value="Idle") ttk.Label(left, textvariable=self.status, wraplength=280).pack(fill=tk.X) right = ttk.Frame(root) root.add(right, weight=1) tabs = ttk.Notebook(right) tabs.pack(fill=tk.BOTH, expand=True) self.details_tab = ttk.Frame(tabs) self.settings_tab = ttk.Frame(tabs) self.alarms_tab = ttk.Frame(tabs) self.raw_tab = ttk.Frame(tabs) tabs.add(self.details_tab, text="Details") tabs.add(self.settings_tab, text="Settings") tabs.add(self.alarms_tab, text="Alarms") tabs.add(self.raw_tab, text="Raw") self._build_details(self.details_tab) self._build_settings(self.settings_tab) self._build_alarms(self.alarms_tab) self._build_raw(self.raw_tab) def _value_row(self, parent: ttk.Frame, row: int, label: str, key: str) -> None: ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=8, pady=4) var = self.vars.setdefault(key, tk.StringVar(value="--")) ttk.Label(parent, textvariable=var, font=("TkDefaultFont", 10, "bold")).grid(row=row, column=1, sticky="w", padx=8, pady=4) def _build_details(self, parent: ttk.Frame) -> None: grid = ttk.Frame(parent) grid.pack(anchor="nw", fill=tk.X, pady=8) labels = [ ("Battery remaining", "remaining"), ("Discharge current", "current"), ("Discharge watts", "power"), ("Voltage", "voltage"), ("Battery percent", "soc"), ("Time", "time"), ("Cell voltage max", "cell_max"), ("Cell voltage min", "cell_min"), ("Cell imbalance", "cell_delta"), ("Cell count", "cell_count"), ("Cycle count", "cycles"), ("Charge MOS", "charge_mos"), ("Discharge MOS", "discharge_mos"), ("State", "state"), ] for row, (label, key) in enumerate(labels): self._value_row(grid, row, label, key) self.cells_text = tk.Text(parent, height=8, wrap=tk.NONE) self.cells_text.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) def _build_settings(self, parent: ttk.Frame) -> None: top = ttk.Frame(parent) top.pack(fill=tk.X, pady=8) ttk.Label(top, text="PIN").pack(side=tk.LEFT, padx=8) self.pin_entry = ttk.Entry(top, show="*", width=12) self.pin_entry.pack(side=tk.LEFT) ttk.Button(top, text="Unlock", command=self._unlock).pack(side=tk.LEFT, padx=8) self.write_enabled = tk.BooleanVar(value=False) ttk.Checkbutton(top, text="Enable MOS writes", variable=self.write_enabled).pack(side=tk.LEFT, padx=16) info = ttk.Frame(parent) info.pack(anchor="nw", fill=tk.X) for row, (label, key) in enumerate([ ("Battery code", "battery_code"), ("Firmware", "firmware"), ("Production date", "production_date"), ("Battery Ah", "rated_capacity"), ("Product info", "product_info"), ("Read PIN", "read_pin"), ]): self._value_row(info, row, label, key) mos = ttk.LabelFrame(parent, text="MOS controls") mos.pack(fill=tk.X, padx=8, pady=12) ttk.Label(mos, text="Charge register").grid(row=0, column=0, padx=8, pady=6) self.charge_reg = ttk.Entry(mos, width=8) self.charge_reg.insert(0, "0x0035") self.charge_reg.grid(row=0, column=1) ttk.Button(mos, text="Charge ON", command=lambda: self._write_mos(self.charge_reg, True)).grid(row=0, column=2, padx=4) ttk.Button(mos, text="Charge OFF", command=lambda: self._write_mos(self.charge_reg, False)).grid(row=0, column=3, padx=4) ttk.Label(mos, text="Discharge register").grid(row=1, column=0, padx=8, pady=6) self.discharge_reg = ttk.Entry(mos, width=8) self.discharge_reg.insert(0, "0x0036") self.discharge_reg.grid(row=1, column=1) ttk.Button(mos, text="Discharge ON", command=lambda: self._write_mos(self.discharge_reg, True)).grid(row=1, column=2, padx=4) ttk.Button(mos, text="Discharge OFF", command=lambda: self._write_mos(self.discharge_reg, False)).grid(row=1, column=3, padx=4) def _build_alarms(self, parent: ttk.Frame) -> None: self.alarm_text = tk.Text(parent, wrap=tk.WORD) self.alarm_text.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) def _build_raw(self, parent: ttk.Frame) -> None: self.raw_text = tk.Text(parent, wrap=tk.NONE) self.raw_text.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) def _scan(self) -> None: try: timeout = float(self.scan_timeout.get()) except ValueError: messagebox.showerror("Scan", "Scan duration must be a number of seconds") return self.worker.scan(max(3.0, min(timeout, 60.0))) def _connect_selected(self) -> None: selection = self.device_list.curselection() if not selection: return self.worker.connect(self.devices[selection[0]]["address"]) def _connect_direct(self) -> None: address = self.direct_address.get().strip() if not address: messagebox.showerror("Address", "Enter a BLE address first") return self.worker.connect(address) def _unlock(self) -> None: entered = self.pin_entry.get().strip() expected = self.vars.get("read_pin", tk.StringVar(value="000000")).get() if entered and (expected in ("--", "") or entered == expected): self.unlocked = True self.status.set("Settings unlocked") else: messagebox.showerror("PIN", "PIN does not match the value read from the BMS") def _write_mos(self, entry: ttk.Entry, enabled: bool) -> None: if not self.unlocked or not self.write_enabled.get(): messagebox.showwarning("Writes disabled", "Enter the PIN and enable MOS writes first.") return try: register = int(entry.get(), 0) except ValueError: messagebox.showerror("Register", "Register must be a number like 0x0035") return action = "ON" if enabled else "OFF" if not messagebox.askyesno("Confirm write", f"Write {action} to register 0x{register:04X}?"): return self.worker.write_mos(register, enabled) def _drain_queue(self) -> None: while True: try: kind, payload = self.queue.get_nowait() except queue.Empty: break if kind == "status": self.status.set(str(payload)) elif kind == "error": self.status.set(str(payload)) messagebox.showerror("BullTron BLE", str(payload)) elif kind == "scan": self.devices = payload self.device_list.delete(0, tk.END) for row in self.devices: mark = "*" if row["likely"] else " " reason = f" [{row['reason']}]" if row["reason"] else "" self.device_list.insert(tk.END, f"{mark} {row['name']} {row['address']} RSSI {row['rssi']}{reason}") elif kind == "connected": self.status.set(f"Connected: {payload}") elif kind == "state": self._render_state(payload) elif kind in ("rx", "tx", "log"): prefix = kind.upper() self.raw_text.insert(tk.END, f"{prefix}: {payload}\n") self.raw_text.see(tk.END) self.after(100, self._drain_queue) def _set(self, key: str, value: str) -> None: self.vars.setdefault(key, tk.StringVar()).set(value) def _render_state(self, state: DeviceState) -> None: t = state.telemetry self._set("remaining", f"{t.remaining_ah:.1f} Ah" if t.remaining_ah is not None else "--") self._set("current", f"{t.current:.1f} A" if t.current is not None else "--") self._set("power", f"{t.power_w:.0f} W" if t.power_w is not None else "--") self._set("voltage", f"{t.voltage:.1f} V" if t.voltage is not None else "--") self._set("soc", f"{t.soc:.1f} %" if t.soc is not None else "--") self._set("time", f"{t.time_label}: {t.time_value}") self._set("cell_max", f"{t.cell_max:.3f} V" if t.cell_max is not None else "--") self._set("cell_min", f"{t.cell_min:.3f} V" if t.cell_min is not None else "--") self._set("cell_delta", f"{t.cell_delta:.3f} V" if t.cell_delta is not None else "--") self._set("cell_count", str(t.cell_count) if t.cell_count is not None else "--") self._set("cycles", str(t.cycle_count) if t.cycle_count is not None else "--") self._set("charge_mos", "ON" if t.charge_mos else "OFF" if t.charge_mos is not None else "--") self._set("discharge_mos", "ON" if t.discharge_mos else "OFF" if t.discharge_mos is not None else "--") self._set("state", {1: "charging", 2: "discharging"}.get(t.state, str(t.state) if t.state is not None else "--")) self._set("battery_code", state.battery_code or "--") self._set("firmware", state.firmware or "--") self._set("production_date", state.production_date or "--") self._set("rated_capacity", f"{state.rated_capacity_ah:.1f} Ah" if state.rated_capacity_ah is not None else "--") self._set("product_info", state.product_info or "--") self._set("read_pin", state.control_pin or "--") self.cells_text.delete("1.0", tk.END) if t.cells: self.cells_text.insert(tk.END, "\n".join(f"Cell {i + 1:02d}: {v:.3f} V" for i, v in enumerate(t.cells))) self.alarm_text.delete("1.0", tk.END) if state.alarms: self.alarm_text.insert(tk.END, "\n".join(state.alarms)) elif t.alarm_words: self.alarm_text.insert(tk.END, "No decoded active alarms\n\nRaw words: " + " ".join(f"{w:04X}" for w in t.alarm_words)) def destroy(self) -> None: self.worker.submit(self.worker.disconnect()) super().destroy() def main() -> None: parser = argparse.ArgumentParser(description="BullTron BLE desktop dashboard") parser.parse_args() if ttk is None or messagebox is None: raise SystemExit( "tkinter is not installed. On Debian/Ubuntu install it with: sudo apt install python3-tk" ) app = BullTronGui() app.mainloop() if __name__ == "__main__": main()