Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions eara/bluez.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,31 +207,44 @@ 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:
try:
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
Expand All @@ -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:
Expand All @@ -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}"
Expand Down
23 changes: 20 additions & 3 deletions eara/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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 {}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions eara/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": "Прозорість",
Expand Down
21 changes: 14 additions & 7 deletions eara/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading