diff --git a/README.md b/README.md index efec8f8..6ecd803 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,16 @@ permission issue rather than a protocol issue: sudo .venv/bin/python bulltron_gui.py ``` +The scan list shows every BLE device returned by the OS; BullTron-looking +devices are only sorted first and marked with `*`. If the battery is not shown +at all, close the Android BullTron app so it is not holding the GATT connection, +increase the GUI scan duration, and try the `Direct connect` field with the +address observed in the HCI capture: + +```text +7C:3E:82:1C:7A:86 +``` + ### Windows 11 Install Python 3.11 or newer from @@ -127,6 +137,12 @@ Windows Settings and that the BullTron battery is nearby and not already held by the Android app. Pairing in Windows should not be required for normal telemetry; the original Android flow also reads telemetry without bonding. +If the Windows scan does not list the battery, first close the Android app and +use the GUI's longer scan duration. If the PC can still not discover it, use the +`Direct connect` field with the captured address `7C:3E:82:1C:7A:86`. Some +Windows Bluetooth adapters will only connect to an address after they have seen +at least one advertisement from that peripheral in the current session. + The GUI shows: - live pack voltage, current, discharge watts, SOC, remaining Ah, cell max/min, diff --git a/bulltron_gui.py b/bulltron_gui.py index 9c28063..723e595 100644 --- a/bulltron_gui.py +++ b/bulltron_gui.py @@ -55,6 +55,7 @@ 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_CAPTURE_ADDRESS = "7C:3E:82:1C:7A:86" BULLTRON_NAME_MARKERS = ("dl", "b35") BULLTRON_ADV_MARKERS = (b"DL", b"PU", b"JHB") @@ -324,7 +325,7 @@ class BleWorker: def submit(self, coro: Any) -> None: asyncio.run_coroutine_threadsafe(coro, self.loop) - def scan(self, timeout: float = 6.0) -> None: + def scan(self, timeout: float = 12.0) -> None: self.submit(self._scan(timeout)) async def _scan(self, timeout: float) -> None: @@ -454,8 +455,18 @@ class BullTronGui(tk.Tk): left = ttk.Frame(root, width=300) root.add(left, weight=0) - ttk.Button(left, text="Scan", command=self._scan).pack(fill=tk.X, pady=(0, 6)) - ttk.Button(left, text="Connect", command=self._connect_selected).pack(fill=tk.X) + scan_row = ttk.Frame(left) + scan_row.pack(fill=tk.X, pady=(0, 6)) + ttk.Button(scan_row, text="Scan", command=self._scan).pack(side=tk.LEFT, fill=tk.X, expand=True) + ttk.Label(scan_row, text="sec").pack(side=tk.RIGHT, padx=(6, 0)) + self.scan_timeout = tk.StringVar(value="12") + ttk.Spinbox(scan_row, from_=3, to=60, textvariable=self.scan_timeout, width=4).pack(side=tk.RIGHT) + ttk.Button(left, text="Connect selected", command=self._connect_selected).pack(fill=tk.X) + direct = ttk.LabelFrame(left, text="Direct connect") + direct.pack(fill=tk.X, pady=(8, 0)) + self.direct_address = tk.StringVar(value=BULLTRON_CAPTURE_ADDRESS) + ttk.Entry(direct, textvariable=self.direct_address).pack(fill=tk.X, padx=6, pady=(6, 4)) + ttk.Button(direct, text="Connect address", command=self._connect_direct).pack(fill=tk.X, padx=6, pady=(0, 6)) self.device_list = tk.Listbox(left, height=26) self.device_list.pack(fill=tk.BOTH, expand=True, pady=8) self.device_list.bind("", lambda _event: self._connect_selected()) @@ -541,7 +552,12 @@ class BullTronGui(tk.Tk): self.raw_text.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) def _scan(self) -> None: - self.worker.scan() + try: + timeout = float(self.scan_timeout.get()) + except ValueError: + messagebox.showerror("Scan", "Scan duration must be a number of seconds") + return + self.worker.scan(max(3.0, min(timeout, 60.0))) def _connect_selected(self) -> None: selection = self.device_list.curselection() @@ -549,6 +565,13 @@ class BullTronGui(tk.Tk): return self.worker.connect(self.devices[selection[0]]["address"]) + def _connect_direct(self) -> None: + address = self.direct_address.get().strip() + if not address: + messagebox.showerror("Address", "Enter a BLE address first") + return + self.worker.connect(address) + def _unlock(self) -> None: entered = self.pin_entry.get().strip() expected = self.vars.get("read_pin", tk.StringVar(value="000000")).get()