Filter likely BullTron BLE devices

This commit is contained in:
2026-07-10 20:53:48 +02:00
parent e3ef3dbfc5
commit 5a249ecb86
3 changed files with 64 additions and 4 deletions
+7
View File
@@ -144,6 +144,13 @@ The default write registers are the observed live MOS status registers
`0x0035` and `0x0036`; keep them editable until those write registers are
verified on your exact hardware/firmware.
Scan results are sorted so likely BullTron devices appear first. The Android app
accepts scanned devices whose BLE name contains `DL` or `B35`, or whose legacy
advertising payload contains marker bytes for `DL`, `PU`, or `JHB`. The desktop
app mirrors those name/advertising hints and also treats the confirmed BMS
service UUID `fff0` as a strong match when the host Bluetooth stack exposes it
during scanning.
## App Workflow
The app's normal telemetry flow is:
+45 -3
View File
@@ -55,6 +55,47 @@ 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_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:
@@ -296,8 +337,8 @@ class BleWorker:
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 [])]
likely = "bull" in name.lower() or MAIN_SERVICE in service_uuids
rows.append({"name": name, "address": dev.address, "rssi": adv.rssi, "likely": likely})
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"))
@@ -547,7 +588,8 @@ class BullTronGui(tk.Tk):
self.device_list.delete(0, tk.END)
for row in self.devices:
mark = "*" if row["likely"] else " "
self.device_list.insert(tk.END, f"{mark} {row['name']} {row['address']} RSSI {row['rssi']}")
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":
+12 -1
View File
@@ -1,6 +1,6 @@
import unittest
from bulltron_gui import DeviceState, decode_response, read_frame, split_frames
from bulltron_gui import DeviceState, bulltron_match_reason, decode_response, read_frame, split_frames
class BullTronParserTest(unittest.TestCase):
@@ -41,6 +41,17 @@ class BullTronParserTest(unittest.TestCase):
self.assertTrue(state.telemetry.discharge_mos)
self.assertEqual(state.telemetry.cell_count, 4)
def test_bulltron_scan_filter_matches_apk_markers(self):
self.assertEqual(bulltron_match_reason("DL-1234", [], []), "name:DL")
self.assertEqual(bulltron_match_reason("B35-1234", [], []), "name:B35")
self.assertEqual(
bulltron_match_reason("unknown", ["0000fff0-0000-1000-8000-00805f9b34fb"], []),
"service:fff0",
)
payload = bytes.fromhex("00" * 14 + "4A4842" + "00")
self.assertEqual(bulltron_match_reason("unknown", [], [payload]), "adv:JHB")
self.assertIsNone(bulltron_match_reason("keyboard", [], [b"\x01\x02\x03"]))
if __name__ == "__main__":
unittest.main()