Fix BullTron GUI telemetry and MOS writes
This commit is contained in:
+63
-21
@@ -144,6 +144,10 @@ def format_duration(minutes: float | None) -> str:
|
||||
return f"{hours} h {mins:02d} m"
|
||||
|
||||
|
||||
def clean_ascii(data: bytes) -> str:
|
||||
return printable_ascii(data).replace("\x00", "").strip()
|
||||
|
||||
|
||||
def split_frames(buffer: bytearray) -> list[bytes]:
|
||||
frames: list[bytes] = []
|
||||
while True:
|
||||
@@ -182,6 +186,10 @@ class Telemetry:
|
||||
soc: float | None = None
|
||||
remaining_ah: float | None = None
|
||||
power_w: float | None = None
|
||||
charge_current: float | None = None
|
||||
discharge_current: float | None = None
|
||||
charge_power_w: float | None = None
|
||||
discharge_power_w: float | None = None
|
||||
state: int | None = None
|
||||
cycle_count: int | None = None
|
||||
charge_mos: bool | None = None
|
||||
@@ -245,16 +253,21 @@ def decode_live(payload: bytes, state: DeviceState) -> None:
|
||||
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)
|
||||
current_abs = abs(tel.current)
|
||||
tel.power_w = current_abs * tel.voltage
|
||||
tel.charge_current = current_abs if tel.state == 1 else 0.0 if tel.state == 2 else None
|
||||
tel.discharge_current = current_abs if tel.state == 2 else 0.0 if tel.state == 1 else None
|
||||
tel.charge_power_w = tel.voltage * tel.charge_current if tel.charge_current is not None else None
|
||||
tel.discharge_power_w = tel.voltage * tel.discharge_current if tel.discharge_current is not None else None
|
||||
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
|
||||
minutes = capacity * ((100 - tel.soc) / 100) / abs(tel.current) * 60
|
||||
elif tel.state == 2:
|
||||
tel.time_label = "Time till empty"
|
||||
minutes = capacity * tel.soc / abs(tel.current) * 60
|
||||
minutes = capacity * (tel.soc / 100) / abs(tel.current) * 60
|
||||
else:
|
||||
tel.time_label = "Time"
|
||||
tel.time_value = format_duration(minutes)
|
||||
@@ -282,29 +295,52 @@ def decode_response(frame: bytes, state: DeviceState) -> None:
|
||||
if byte_count == 0x7C:
|
||||
decode_live(payload, state)
|
||||
elif byte_count == 0x06:
|
||||
text = printable_ascii(payload)
|
||||
text = clean_ascii(payload)
|
||||
state.control_pin = text or payload.hex().upper()
|
||||
elif byte_count == 0x40:
|
||||
state.firmware = printable_ascii(payload) or payload.hex().upper()
|
||||
parse_version_block(payload, state)
|
||||
elif byte_count == 0x18:
|
||||
state.battery_code = printable_ascii(payload) or payload.hex().upper()
|
||||
state.battery_code = clean_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}"
|
||||
state.production_date = parse_production_date(payload)
|
||||
elif byte_count == 0x4A:
|
||||
parse_device_parameter_block(payload, state)
|
||||
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)
|
||||
if words:
|
||||
state.rated_capacity_ah = words[0] * 0.1
|
||||
|
||||
|
||||
def parse_version_block(payload: bytes, state: DeviceState) -> None:
|
||||
app = clean_ascii(payload[0:16])
|
||||
mcu = clean_ascii(payload[16:32])
|
||||
machine = clean_ascii(payload[32:64])
|
||||
if app or mcu:
|
||||
state.firmware = " / ".join(part for part in (app[::-1] if app else "", mcu) if part)
|
||||
if machine:
|
||||
state.battery_code = machine
|
||||
|
||||
|
||||
def parse_production_date(payload: bytes) -> str:
|
||||
if len(payload) < 3 or payload[:3] in (b"\xff\xff\xff", b"\x00\x00\x00"):
|
||||
return "2000-01-01"
|
||||
year, month, day = payload[0], payload[1], payload[2]
|
||||
if not 1 <= month <= 12 or not 1 <= day <= 31:
|
||||
return payload.hex(" ").upper()
|
||||
return f"20{year:02d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
def parse_device_parameter_block(payload: bytes, state: DeviceState) -> None:
|
||||
if len(payload) >= 64:
|
||||
parse_version_block(payload[:64], state)
|
||||
if len(payload) >= 70:
|
||||
state.control_pin = clean_ascii(payload[64:70]) or payload[64:70].hex().upper()
|
||||
if len(payload) >= 74:
|
||||
state.production_date = parse_production_date(payload[70:74])
|
||||
|
||||
|
||||
class BleWorker:
|
||||
@@ -500,7 +536,8 @@ class BullTronGui(tk.Tk):
|
||||
grid = ttk.Frame(parent)
|
||||
grid.pack(anchor="nw", fill=tk.X, pady=8)
|
||||
labels = [
|
||||
("Battery remaining", "remaining"), ("Discharge current", "current"), ("Discharge watts", "power"),
|
||||
("Battery remaining", "remaining"), ("Charge current", "charge_current"), ("Charge watts", "charge_power"),
|
||||
("Discharge current", "discharge_current"), ("Discharge watts", "discharge_power"), ("Signed current", "current"),
|
||||
("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"),
|
||||
@@ -532,13 +569,13 @@ class BullTronGui(tk.Tk):
|
||||
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.insert(0, "0x00A5")
|
||||
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.insert(0, "0x00A6")
|
||||
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)
|
||||
@@ -575,7 +612,8 @@ class BullTronGui(tk.Tk):
|
||||
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):
|
||||
normalized_expected = "".join(ch for ch in expected if ch.isdigit())
|
||||
if entered and (expected in ("--", "") or not normalized_expected or entered == normalized_expected):
|
||||
self.unlocked = True
|
||||
self.status.set("Settings unlocked")
|
||||
else:
|
||||
@@ -588,7 +626,7 @@ class BullTronGui(tk.Tk):
|
||||
try:
|
||||
register = int(entry.get(), 0)
|
||||
except ValueError:
|
||||
messagebox.showerror("Register", "Register must be a number like 0x0035")
|
||||
messagebox.showerror("Register", "Register must be a number like 0x00A5")
|
||||
return
|
||||
action = "ON" if enabled else "OFF"
|
||||
if not messagebox.askyesno("Confirm write", f"Write {action} to register 0x{register:04X}?"):
|
||||
@@ -631,6 +669,10 @@ class BullTronGui(tk.Tk):
|
||||
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("charge_current", f"{t.charge_current:.1f} A" if t.charge_current is not None else "--")
|
||||
self._set("discharge_current", f"{t.discharge_current:.1f} A" if t.discharge_current is not None else "--")
|
||||
self._set("charge_power", f"{t.charge_power_w:.0f} W" if t.charge_power_w is not None else "--")
|
||||
self._set("discharge_power", f"{t.discharge_power_w:.0f} W" if t.discharge_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}")
|
||||
|
||||
Reference in New Issue
Block a user