From 1d4d78428e571d8326a8cdf582c2672b3588a6a3 Mon Sep 17 00:00:00 2001 From: oneydef Date: Sun, 20 Sep 2026 11:38:08 +0300 Subject: [PATCH] Fix Connect button hanging by shortening BT retries and unlocking UI on timeout. Co-authored-by: Cursor --- eara/bluez.py | 31 ++++++++++++++++++++++--------- eara/gui.py | 23 ++++++++++++++++++++--- eara/i18n.py | 4 ++++ eara/session.py | 21 ++++++++++++++------- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/eara/bluez.py b/eara/bluez.py index 4055433..b7a8521 100644 --- a/eara/bluez.py +++ b/eara/bluez.py @@ -207,18 +207,31 @@ def trust_and_connect( if is_connected(address) and has_audio_sink(address): return + # Connected but PulseAudio card lagging — wait before tearing the link down. + if is_connected(address): + from eara.audio import ensure_bluez_card + + ensure_bluez_card(address) + if has_audio_sink(address): + return + last_err = "" - per_round = max(12.0, timeout / max_rounds) + per_round = max(10.0, timeout / max_rounds) for round_num in range(max_rounds): if scan: - scan_for_device(address, dwell=5.0 if round_num == 0 else 7.0) + scan_for_device(address, dwell=3.5 if round_num == 0 else 5.0) if is_connected(address): + if has_audio_sink(address): + return + from eara.audio import ensure_bluez_card + + ensure_bluez_card(address) if has_audio_sink(address): return dbus_disconnect(address) - time.sleep(1.5) + time.sleep(1.0) round_deadline = time.monotonic() + per_round while time.monotonic() < round_deadline: @@ -226,12 +239,12 @@ def trust_and_connect( dbus_connect(address) except Exception as exc: last_err = str(exc) - out = _ctl("connect", address, timeout=15) + out = _ctl("connect", address, timeout=12) if out.strip(): last_err = out.strip() - time.sleep(1.2) + time.sleep(0.8) if is_connected(address): - settle_deadline = time.monotonic() + 16 + settle_deadline = time.monotonic() + 12 while time.monotonic() < settle_deadline: if has_audio_sink(address): return @@ -242,10 +255,10 @@ def trust_and_connect( ensure_bluez_card(address) if has_audio_sink(address): return - time.sleep(0.45) + time.sleep(0.4) if has_audio_sink(address): return - time.sleep(0.8) + time.sleep(0.5) if round_num + 1 < max_rounds: try: @@ -255,7 +268,7 @@ def trust_and_connect( from eara.audio import reload_bluetooth_modules reload_bluetooth_modules(force=True) - time.sleep(1.2 + round_num * 0.5) + time.sleep(1.0 + round_num * 0.4) raise TimeoutError( f"Bluetooth connect timed out for {address} after {max_rounds} attempts: {last_err}" diff --git a/eara/gui.py b/eara/gui.py index ac2ad22..36504d9 100644 --- a/eara/gui.py +++ b/eara/gui.py @@ -1540,10 +1540,12 @@ def _set_busy(self, busy: bool) -> None: for widget in self._busy_widgets(): widget.set_sensitive(not busy) - def _run(self, fn) -> None: + def _run(self, fn, *, soft_timeout: float = 90.0) -> None: if self._busy: return self._set_busy(True) + generation = getattr(self, "_busy_generation", 0) + 1 + self._busy_generation = generation def worker(): err = None @@ -1553,15 +1555,28 @@ def worker(): err = str(exc) def done(): + if getattr(self, "_busy_generation", 0) != generation: + return False self._set_busy(False) if err: self._show(err) else: self._paint() + return False GLib.idle_add(done) + def watchdog(): + if getattr(self, "_busy_generation", 0) != generation or not self._busy: + return False + self._busy_generation = generation + 1 + self._set_busy(False) + self._show(t("connect_timeout") if fn == self._do_connect else t("busy_timeout")) + return False + threading.Thread(target=worker, daemon=True).start() + if soft_timeout > 0: + GLib.timeout_add(int(soft_timeout * 1000), watchdog) def _bootstrap(self) -> bool: self._fill_devices() @@ -1579,7 +1594,8 @@ def _bootstrap(self) -> bool: def _do_connect(self) -> None: GLib.idle_add(lambda: self._show(t("connect_busy")) or False) - result = self._require().connect_audio(False) + # Keep the header button responsive: fewer retries, fast path if already linked. + result = self._require().connect_audio(False, attempts=2) if not result.get("ok"): raise RuntimeError(str(result.get("error"))) self._last = self.device.status() if self.device else {} @@ -1869,7 +1885,8 @@ def do_shutdown(self) -> None: # noqa: N802 win = self.props.active_window if isinstance(win, EarAWindow): win._shutdown_bluetooth() - super().do_shutdown() + # PyGObject: super().do_shutdown() can call Gio.Application.shutdown() incorrectly. + Adw.Application.do_shutdown(self) def do_activate(self) -> None: # noqa: N802 win = self.props.active_window diff --git a/eara/i18n.py b/eara/i18n.py index 37d9ce5..3766326 100644 --- a/eara/i18n.py +++ b/eara/i18n.py @@ -88,6 +88,8 @@ "reset_ok": "Connection reset. Audio should be on the earbuds.", "connect_ok": "Connected. Sound routed to the earbuds.", "connect_busy": "Connecting… scanning and retrying if needed.", + "connect_timeout": "Connect is taking too long — buttons unlocked. Try again or use Reset.", + "busy_timeout": "Action timed out — buttons unlocked. Try again.", "mic_ok": "Calls use A2DP like music. In Discord keep Nothing Ear (a) as speaker. The bud mic needs HFP (eara audio hfp) and drops quality.", "off": "Off", "transparency": "Transparency", @@ -194,6 +196,8 @@ "reset_ok": "З’єднання скинуто. Звук має йти в наушники.", "connect_ok": "Підключено. Звук іде в наушники.", "connect_busy": "Підключаю… сканую та повторюю при потребі.", + "connect_timeout": "Підключення занадто довге — кнопки розблоковано. Спробуй ще або Reset.", + "busy_timeout": "Дія зависла — кнопки розблоковано. Спробуй ще раз.", "mic_ok": "Дзвінки йдуть A2DP, як музика. У Discord залиште Nothing Ear (a) як динамік. Мікрофон з вух — лише HFP (`eara audio hfp`), тоді звук гірший.", "off": "Вимк", "transparency": "Прозорість", diff --git a/eara/session.py b/eara/session.py index 5ca1cc7..718934c 100644 --- a/eara/session.py +++ b/eara/session.py @@ -107,29 +107,36 @@ def _resolve_model(serial: str, named: Model) -> Model: def connected(self) -> bool: return bool(self.address) and bluez.is_connected(self.address) - def connect_audio(self, with_mic: bool = False, attempts: int = 5) -> dict[str, object]: + def connect_audio(self, with_mic: bool = False, attempts: int = 3) -> dict[str, object]: if not self.address: raise RuntimeError("No Bluetooth address") fn = audio.ensure_call_audio if with_mic else audio.ensure_music_audio last: dict[str, object] = {"ok": False, "error": "Connect failed"} + # Already linked with A2DP — just (re)apply the audio profile. + if self.connected and bluez.has_audio_sink(self.address): + result = fn(self.address) + if result.get("ok"): + result["attempt"] = 0 + return result + for attempt in range(attempts): self.close_control() try: bluez.trust_and_connect( self.address, - timeout=45, - scan=True, - max_rounds=4 if attempt == 0 else 5, + timeout=28 if attempt == 0 else 36, + scan=attempt > 0 or not self.connected, + max_rounds=2 if attempt == 0 else 3, ) except TimeoutError as exc: last = {"ok": False, "error": str(exc), "attempt": attempt + 1} bluez.dbus_disconnect(self.address) audio.reload_bluetooth_modules(force=True) - time.sleep(1.5 + attempt) + time.sleep(1.0 + attempt) continue - time.sleep(0.8) + time.sleep(0.5) result = fn(self.address) if result.get("ok"): result["attempt"] = attempt + 1 @@ -139,7 +146,7 @@ def connect_audio(self, with_mic: bool = False, attempts: int = 5) -> dict[str, last["attempt"] = attempt + 1 audio.reload_bluetooth_modules(force=True) bluez.dbus_disconnect(self.address) - time.sleep(1.2 + attempt * 0.6) + time.sleep(1.0 + attempt * 0.5) err = str(last.get("error") or "Connect failed") return {