diff --git a/README.md b/README.md index 6cffc6a..efec8f8 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/bulltron_gui.py b/bulltron_gui.py index 6be51a2..9c28063 100644 --- a/bulltron_gui.py +++ b/bulltron_gui.py @@ -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": diff --git a/tests/test_bulltron_gui.py b/tests/test_bulltron_gui.py index a359207..c284cab 100644 --- a/tests/test_bulltron_gui.py +++ b/tests/test_bulltron_gui.py @@ -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()