Filter likely BullTron BLE devices
This commit is contained in:
@@ -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
|
`0x0035` and `0x0036`; keep them editable until those write registers are
|
||||||
verified on your exact hardware/firmware.
|
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
|
## App Workflow
|
||||||
|
|
||||||
The app's normal telemetry flow is:
|
The app's normal telemetry flow is:
|
||||||
|
|||||||
+45
-3
@@ -55,6 +55,47 @@ WRITE_CHAR = "0000fff2-0000-1000-8000-00805f9b34fb"
|
|||||||
AT_CHAR = "0000fff3-0000-1000-8000-00805f9b34fb"
|
AT_CHAR = "0000fff3-0000-1000-8000-00805f9b34fb"
|
||||||
SECRET_KEY_CHAR = "02f00000-0000-0000-0000-00000000ff05"
|
SECRET_KEY_CHAR = "02f00000-0000-0000-0000-00000000ff05"
|
||||||
VERSION_CHAR = "02f00000-0000-0000-0000-00000000ff04"
|
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:
|
def crc16_modbus_swapped(data: bytes) -> int:
|
||||||
@@ -296,8 +337,8 @@ class BleWorker:
|
|||||||
for _, (dev, adv) in devices.items():
|
for _, (dev, adv) in devices.items():
|
||||||
name = dev.name or adv.local_name or "(unnamed)"
|
name = dev.name or adv.local_name or "(unnamed)"
|
||||||
service_uuids = [u.lower() for u in (adv.service_uuids or [])]
|
service_uuids = [u.lower() for u in (adv.service_uuids or [])]
|
||||||
likely = "bull" in name.lower() or MAIN_SERVICE in service_uuids
|
reason = bulltron_match_reason(name, service_uuids, iter_advertisement_payloads(adv))
|
||||||
rows.append({"name": name, "address": dev.address, "rssi": adv.rssi, "likely": likely})
|
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)))
|
rows.sort(key=lambda row: (not row["likely"], -(row["rssi"] or -999)))
|
||||||
self.ui_queue.put(("scan", rows))
|
self.ui_queue.put(("scan", rows))
|
||||||
self.ui_queue.put(("status", f"Found {len(rows)} BLE devices"))
|
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)
|
self.device_list.delete(0, tk.END)
|
||||||
for row in self.devices:
|
for row in self.devices:
|
||||||
mark = "*" if row["likely"] else " "
|
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":
|
elif kind == "connected":
|
||||||
self.status.set(f"Connected: {payload}")
|
self.status.set(f"Connected: {payload}")
|
||||||
elif kind == "state":
|
elif kind == "state":
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import unittest
|
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):
|
class BullTronParserTest(unittest.TestCase):
|
||||||
@@ -41,6 +41,17 @@ class BullTronParserTest(unittest.TestCase):
|
|||||||
self.assertTrue(state.telemetry.discharge_mos)
|
self.assertTrue(state.telemetry.discharge_mos)
|
||||||
self.assertEqual(state.telemetry.cell_count, 4)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user