diff --git a/docs/usage.md b/docs/usage.md index 11c3d3d3..eab7272e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -63,6 +63,12 @@ Bolt devices and some Lightspeed devices pair by pressing a special pairing butt To pair with a Bolt receiver you have to type a passcode followed by enter or click the left and right buttons in the correct sequence followed by clicking both buttons simultaneously. +Solaar shows this sequence as numbered steps, each one indicating which button +to press, with the final both-buttons step set apart from the rest. +Receivers that report entry progress also advance a highlight as each press is +accepted. That highlight only counts presses: the receiver never reports which +button was pressed, so Solaar cannot tell you whether a press was the right one, +and only reports whether the whole sequence was accepted. ![Solaar-main-window-receiver](screenshots/Solaar-main-window-receiver.png) diff --git a/lib/solaar/ui/action.py b/lib/solaar/ui/action.py index 6751f92b..957f8ffc 100644 --- a/lib/solaar/ui/action.py +++ b/lib/solaar/ui/action.py @@ -67,7 +67,7 @@ def pair(window, receiver): assert receiver assert receiver.kind is None - pair_dialog = pair_window.create(receiver) + pair_dialog = pair_window.create(receiver, on_retry=lambda: pair(window, receiver)) pair_dialog.set_transient_for(window) pair_dialog.set_destroy_with_parent(True) pair_dialog.set_modal(True) diff --git a/lib/solaar/ui/pair_window.py b/lib/solaar/ui/pair_window.py index 1b4eeb20..bd673619 100644 --- a/lib/solaar/ui/pair_window.py +++ b/lib/solaar/ui/pair_window.py @@ -47,6 +47,7 @@ _STEP_ENTER = "enter" _PASSKEY_PAGE = "_solaar_passkey_page" _PAIRED_ADDRESS = "_solaar_paired_address" _ON_RETRY = "_solaar_on_retry" +_COUNTDOWN = "_solaar_countdown" _STEPS = "_solaar_steps" _PROGRESS = "_solaar_progress" @@ -62,6 +63,7 @@ _SEPARATOR_GAP = 10 _STRIP_MARGIN = 8 _STEPS_PER_ROW = 5 _FINAL_STEP_SCALE = 1.25 +_LABEL_WIDTH_CHARS = 40 # keeps the wrapped labels inside the width of the dialog class GtkSignal(Enum): @@ -71,13 +73,15 @@ class GtkSignal(Enum): DRAW = "draw" -def create(receiver): +def create(receiver, on_retry=None): receiver.reset_pairing() # clear out any information on previous pairing title = _("%(receiver_name)s: pair new device") % {"receiver_name": receiver.name} if receiver.receiver_kind == "bolt": text = _("Bolt receivers are only compatible with Bolt devices.") text += "\n\n" text += _("Press a pairing button or key until the pairing light flashes quickly.") + text += "\n" + text += _("Press and hold the pairing button on the device for about three seconds.") else: if receiver.receiver_kind == "unifying": text = _("Unifying receivers are only compatible with Unifying devices.") @@ -109,12 +113,35 @@ def create(receiver): ) text += _("\nCancelling at this point will not use up a pairing.") ok = prepare(receiver) - assistant = _create_assistant(receiver, ok, _finish, title, text) + assistant = _create_assistant(receiver, ok, _finish, title, text, on_retry) if ok: GLib.timeout_add(_STATUS_CHECK, check_lock_state, assistant, receiver) + deadline = time.monotonic() + _PAIRING_TIMEOUT + GLib.timeout_add(_STATUS_CHECK, _update_countdown, assistant, receiver, deadline) return assistant +def _update_countdown(assistant, receiver, deadline): + """Counts down the time left to find a device. + + Only this phase gets a countdown: Solaar sets its timeout itself, whereas the + passkey entry timeout lives in the receiver's firmware and is never reported, + so any timer shown during entry would be made up. + """ + countdown = getattr(assistant, _COUNTDOWN, None) + if countdown is None: + return False + remaining = deadline - time.monotonic() + found = receiver.pairing.device_address or getattr(assistant, _PASSKEY_PAGE, None) is not None + if not assistant.is_drawable() or remaining <= 0 or found: + countdown.hide() + return False + seconds = int(math.ceil(remaining)) + countdown.set_fraction(remaining / _PAIRING_TIMEOUT) + countdown.set_text(ngettext("%d second left", "%d seconds left", seconds) % seconds) + return True + + def prepare(receiver): if receiver.receiver_kind == "bolt": if receiver.discover(timeout=_PAIRING_TIMEOUT): @@ -171,7 +198,7 @@ def _check_lock_state(assistant, receiver, count): def _pairing_failed(assistant, receiver, error): assistant.remove_page(0) # needed to reset the window size logger.debug("%s fail: %s", receiver, error) - _create_failure_page(assistant, error) + _create_failure_page(assistant, receiver, error) def _pairing_succeeded(assistant, receiver, device): @@ -472,16 +499,22 @@ def _create_passcode_page(assistant, receiver, passkey): page.pack_start(strip, False, False, 0) status = Gtk.Label(label=_("Waiting for the first click…")) status.set_line_wrap(True) + status.set_max_width_chars(_LABEL_WIDTH_CHARS) + status.set_halign(Gtk.Align.CENTER) page.pack_start(status, False, False, 0) if steps[-1] == _STEP_BOTH: # the receiver reports that a button was pressed but never which one, # so say so rather than let a counted click look like a checked one honesty = Gtk.Label(label=_("Solaar cannot tell which button was pressed — only that the receiver accepted a click.")) honesty.set_line_wrap(True) + honesty.set_max_width_chars(_LABEL_WIDTH_CHARS) + honesty.set_halign(Gtk.Align.CENTER) honesty.get_style_context().add_class("dim-label") page.pack_start(honesty, False, False, 0) reminder = Gtk.Label(label=_("Keep the device switched on and within range until pairing finishes.")) reminder.set_line_wrap(True) + reminder.set_max_width_chars(_LABEL_WIDTH_CHARS) + reminder.set_halign(Gtk.Align.CENTER) reminder.get_style_context().add_class("dim-label") page.pack_start(reminder, False, False, 0) page.show_all() @@ -522,13 +555,16 @@ def _update_passcode_page(page, receiver): strip.queue_draw() -def _create_assistant(receiver, ok, finish, title, text): +def _create_assistant(receiver, ok, finish, title, text, on_retry=None): assistant = Gtk.Assistant() assistant.set_title(title) assistant.set_icon_name("list-add") assistant.set_size_request(400, 240) assistant.set_resizable(False) assistant.set_role("pair-device") + # stash the callback before any page is built, so that a failure while preparing + # also gets a retry button + setattr(assistant, _ON_RETRY, on_retry) if ok: page_intro = _create_page( assistant, @@ -537,13 +573,18 @@ def _create_assistant(receiver, ok, finish, title, text): "preferences-desktop-peripherals", text, ) + countdown = Gtk.ProgressBar() + countdown.set_show_text(True) + countdown.set_visible(True) + page_intro.pack_end(countdown, False, False, 0) + setattr(assistant, _COUNTDOWN, countdown) spinner = Gtk.Spinner() spinner.set_visible(True) spinner.start() page_intro.pack_end(spinner, True, True, 24) assistant.set_page_complete(page_intro, True) else: - page_intro = _create_failure_page(assistant, receiver.pairing.error) + page_intro = _create_failure_page(assistant, receiver, receiver.pairing.error) assistant.connect(GtkSignal.CANCEL.value, finish, receiver) assistant.connect(GtkSignal.CLOSE.value, finish, receiver) return assistant @@ -578,19 +619,65 @@ def _create_success_page(assistant, device): assistant.commit() -def _create_failure_page(assistant, error) -> None: - header = _("Pairing failed") + ": " + _(str(error)) + "." - if "timeout" in str(error): - text = _("Make sure your device is within range, and has a decent battery charge.") - elif str(error) == "device not supported": - text = _("A new device was detected, but it is not compatible with this receiver.") - elif "many" in str(error): - text = _("More paired devices than receiver can support.") - else: - text = _("No further details are available about the error.") - _create_page(assistant, Gtk.AssistantPageType.SUMMARY, header, "dialog-error", text) +def _failure_text(label): + """Describes a pairing failure, matching the error labels exactly. + + The cause has to be chosen from literals rather than translated at runtime, + because gettext can only translate strings it saw when the catalogs were built. + """ + if label == "device timeout" or label == "failed to open pairing lock": + return _("Make sure your device is within range, and has a decent battery charge.") + if label == "device not supported": + return _("A new device was detected, but it is not compatible with this receiver.") + if label == "too many devices": + return _("More paired devices than receiver can support.") + if label == "sequence timeout": + return _("Sequence entry timed out.") + "\n" + _("Press the pairing button on your device again and retry.") + if label == "failed": + # the receiver reports nothing beyond this, so do not guess at a cause: a + # wrong button, a mistimed press and a wrong sequence all arrive as "failed" + return ( + _("The click sequence was not accepted.") + + "\n" + + _("The receiver only reports that verification failed; it cannot tell which buttons were pressed.") + + "\n" + + _("Press the pairing button on your device again and retry.") + ) + return _("No further details are available about the error.") + + +def _retry_pairing(_button, assistant, receiver, on_retry): + _finish(assistant, receiver) + on_retry() + + +def _create_failure_page(assistant, receiver, error) -> Gtk.VBox: + page = _create_page( + assistant, + Gtk.AssistantPageType.SUMMARY, + _("Pairing failed"), + "dialog-error", + _failure_text(str(error)), + ) + token = Gtk.Label() + token.set_markup(f"{GLib.markup_escape_text(str(error))}") + token.set_line_wrap(True) + token.set_max_width_chars(_LABEL_WIDTH_CHARS) + token.set_halign(Gtk.Align.CENTER) + token.get_style_context().add_class("dim-label") + page.pack_start(token, False, False, 0) + on_retry = getattr(assistant, _ON_RETRY, None) + if on_retry is not None: + # packed into the page rather than added as an assistant action widget, so + # that this path keeps working wherever the assistant is only duck-typed + retry = Gtk.Button(label=_("Try again")) + retry.set_halign(Gtk.Align.CENTER) + retry.connect(GtkSignal.CLICKED.value, _retry_pairing, assistant, receiver, on_retry) + page.pack_start(retry, False, False, 0) + page.show_all() assistant.next_page() assistant.commit() + return page def _create_page(assistant, kind, header=None, icon_name=None, text=None) -> Gtk.VBox: diff --git a/tests/solaar/ui/test_pair_window.py b/tests/solaar/ui/test_pair_window.py index 1e2cefa0..004f8242 100644 --- a/tests/solaar/ui/test_pair_window.py +++ b/tests/solaar/ui/test_pair_window.py @@ -1,3 +1,5 @@ +import time + from dataclasses import dataclass from dataclasses import field from typing import Any @@ -8,6 +10,8 @@ import gi import pytest from logitech_receiver import receiver +from logitech_receiver.hidpp10_constants import BoltPairingError +from logitech_receiver.hidpp10_constants import PairingError from solaar.ui import pair_window gi.require_version("Gtk", "3.0") @@ -400,3 +404,150 @@ def test_draw_step_strip_without_steps(): surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10) assert pair_window._draw_step_strip(strip, cairo.Context(surface)) is False + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +@pytest.mark.parametrize( + "error", + [ + PairingError.DEVICE_TIMEOUT.label, + PairingError.DEVICE_NOT_SUPPORTED.label, + PairingError.TOO_MANY_DEVICES.label, + PairingError.SEQUENCE_TIMEOUT.label, + BoltPairingError.DEVICE_TIMEOUT.label, + BoltPairingError.FAILED.label, + "discovery did not start", + "the pairing lock did not open", + "failed to open pairing lock", + ], +) +def test_create_failure_page_covers_every_error(error, mocker): + spy_create = mocker.spy(pair_window, "_create_page") + + pair_window._pairing_failed(Assistant(True), Receiver("nano", "nano"), error) + + assert spy_create.call_count == 1 + + +@pytest.mark.parametrize( + "error", + [ + PairingError.DEVICE_TIMEOUT.label, + PairingError.DEVICE_NOT_SUPPORTED.label, + PairingError.TOO_MANY_DEVICES.label, + PairingError.SEQUENCE_TIMEOUT.label, + BoltPairingError.FAILED.label, + "failed to open pairing lock", + ], +) +def test_failure_text_is_specific(error): + """A protocol error must never fall through to the generic message.""" + assert pair_window._failure_text(error) != pair_window._failure_text("something unheard of") + + +def test_failure_text_explains_a_rejected_sequence(): + """Bolt reports only that verification failed, so the page must not diagnose a cause.""" + text = pair_window._failure_text(BoltPairingError.FAILED.label) + + assert "not accepted" in text + assert "cannot tell which buttons were pressed" in text + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_failure_page_has_no_retry_button_without_a_callback(mocker): + """The periodic check reaches this path with a duck-typed assistant, which must + never be asked for anything beyond the methods it already provides.""" + spy_create = mocker.spy(pair_window, "_create_page") + assistant = Assistant(True) + + pair_window._pairing_failed(assistant, Receiver("nano", "nano"), "failed") + + assert spy_create.call_count == 1 + assert not any(isinstance(child, Gtk.Button) for child in assistant.pages[0].get_children()) + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_failure_page_offers_retry_when_wired(): + r = Receiver("nano", "nano") + retried = [] + assistant = Gtk.Assistant() + setattr(assistant, pair_window._ON_RETRY, lambda: retried.append(True)) + + page = pair_window._create_failure_page(assistant, r, "failed") + buttons = [child for child in page.get_children() if isinstance(child, Gtk.Button)] + + assert len(buttons) == 1 + assert buttons[0].get_label() == "Try again" + + buttons[0].clicked() + + assert retried == [True] + + +def _countdown_assistant(drawable=True): + assistant = Assistant(drawable) + countdown = Gtk.ProgressBar() + setattr(assistant, pair_window._COUNTDOWN, countdown) + return assistant, countdown + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +@pytest.mark.parametrize("receiver_kind", ["bolt", "unifying"]) +def test_create_adds_a_discovery_countdown(receiver_kind): + r = Receiver(receiver_kind, receiver_kind, True) + + assistant = pair_window.create(r) + + countdown = getattr(assistant, pair_window._COUNTDOWN, None) + assert isinstance(countdown, Gtk.ProgressBar) + assert countdown in assistant.get_nth_page(0).get_children() + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_update_countdown_drains_and_stops(): + r = Receiver("bolt", "bolt", True) + assistant, countdown = _countdown_assistant() + deadline = time.monotonic() + pair_window._PAIRING_TIMEOUT + + assert pair_window._update_countdown(assistant, r, deadline) is True + assert 0 < countdown.get_fraction() <= 1 + assert countdown.get_text() + + # a device was found, so the discovery timeout no longer applies + r.pairing.device_address = b"\x01\x02\x03\x04\x05\x06" + assert pair_window._update_countdown(assistant, r, deadline) is False + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_update_countdown_stops_when_the_deadline_passes(): + assistant, _countdown = _countdown_assistant() + + assert pair_window._update_countdown(assistant, Receiver("bolt", "bolt", True), time.monotonic() - 1) is False + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_update_countdown_stops_when_the_dialog_is_gone(): + assistant, _countdown = _countdown_assistant(drawable=False) + deadline = time.monotonic() + pair_window._PAIRING_TIMEOUT + + assert pair_window._update_countdown(assistant, Receiver("bolt", "bolt", True), deadline) is False + + +def test_update_countdown_without_a_progress_bar(): + deadline = time.monotonic() + pair_window._PAIRING_TIMEOUT + + assert pair_window._update_countdown(Assistant(True), Receiver("bolt", "bolt", True), deadline) is False + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_no_countdown_during_passkey_entry(): + """The entry timeout lives in the receiver's firmware and is never reported, so + showing a countdown there would mean inventing one.""" + r = Receiver("bolt", "bolt", True, receiver.Pairing(lock_open=True, device_passkey="50", device_authentication=0x02)) + assistant, _countdown = _countdown_assistant() + + pair_window._check_lock_state(assistant, r, 0) + + assert pair_window._update_countdown(assistant, r, time.monotonic() + pair_window._PAIRING_TIMEOUT) is False + page = getattr(assistant, pair_window._PASSKEY_PAGE) + assert not any(isinstance(child, Gtk.ProgressBar) for child in page.get_children())