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
|
||||
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
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user