615 lines
24 KiB
Python
615 lines
24 KiB
Python
## Copyright (C) 2012-2013 Daniel Pavel
|
|
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
|
|
##
|
|
## This program is free software; you can redistribute it and/or modify
|
|
## it under the terms of the GNU General Public License as published by
|
|
## the Free Software Foundation; either version 2 of the License, or
|
|
## (at your option) any later version.
|
|
##
|
|
## This program is distributed in the hope that it will be useful,
|
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
## GNU General Public License for more details.
|
|
##
|
|
## You should have received a copy of the GNU General Public License along
|
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
|
|
import logging
|
|
import math
|
|
import time
|
|
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
from gi.repository import GLib
|
|
from gi.repository import Gtk
|
|
from logitech_receiver import hidpp10_constants
|
|
|
|
from solaar.i18n import _
|
|
from solaar.i18n import ngettext
|
|
|
|
from . import icons
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_PAIRING_TIMEOUT = 30 # seconds
|
|
_STATUS_CHECK = 500 # milliseconds
|
|
_PROGRESS_GRACE = 3 # seconds to wait for a first progress notification before giving up on them
|
|
|
|
# steps the user has to perform to enter a passkey
|
|
_STEP_LEFT = "left"
|
|
_STEP_RIGHT = "right"
|
|
_STEP_BOTH = "both"
|
|
_STEP_ENTER = "enter"
|
|
|
|
# widget state kept between the periodic checks, stashed on the widgets themselves
|
|
_PASSKEY_PAGE = "_solaar_passkey_page"
|
|
_PAIRED_ADDRESS = "_solaar_paired_address"
|
|
_ON_RETRY = "_solaar_on_retry"
|
|
_STEPS = "_solaar_steps"
|
|
_PROGRESS = "_solaar_progress"
|
|
|
|
# step strip metrics, in pixels
|
|
_CELL_WIDTH = 28
|
|
_CELL_HEIGHT = 38
|
|
_CELL_GAP = 8
|
|
_CELL_RADIUS = 9
|
|
_NUMBER_GAP = 6
|
|
_NUMBER_HEIGHT = 12
|
|
_ROW_GAP = 8
|
|
_SEPARATOR_GAP = 10
|
|
_STRIP_MARGIN = 8
|
|
_STEPS_PER_ROW = 5
|
|
_FINAL_STEP_SCALE = 1.25
|
|
|
|
|
|
class GtkSignal(Enum):
|
|
CANCEL = "cancel"
|
|
CLOSE = "close"
|
|
CLICKED = "clicked"
|
|
DRAW = "draw"
|
|
|
|
|
|
def create(receiver):
|
|
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.")
|
|
else:
|
|
if receiver.receiver_kind == "unifying":
|
|
text = _("Unifying receivers are only compatible with Unifying devices.")
|
|
else:
|
|
text = _("Other receivers are only compatible with a few devices.")
|
|
text += "\n\n"
|
|
text += _("For most devices, turn on the device you want to pair.")
|
|
text += _("If the device is already turned on, turn it off and on again.")
|
|
text += "\n"
|
|
text += _("The device must not be paired with a nearby powered-on receiver.")
|
|
text += "\n"
|
|
text += _(
|
|
"For devices with multiple channels, "
|
|
"press, hold, and release the button for the channel you wish to pair"
|
|
"\n"
|
|
"or use the channel switch button to select a channel "
|
|
"and then press, hold, and release the channel switch button."
|
|
)
|
|
text += "\n"
|
|
text += _("The channel indicator light should be blinking rapidly.")
|
|
if receiver.remaining_pairings() and receiver.remaining_pairings() >= 0:
|
|
text += (
|
|
ngettext(
|
|
"\n\nThis receiver has %d pairing remaining.",
|
|
"\n\nThis receiver has %d pairings remaining.",
|
|
receiver.remaining_pairings(),
|
|
)
|
|
% receiver.remaining_pairings()
|
|
)
|
|
text += _("\nCancelling at this point will not use up a pairing.")
|
|
ok = prepare(receiver)
|
|
assistant = _create_assistant(receiver, ok, _finish, title, text)
|
|
if ok:
|
|
GLib.timeout_add(_STATUS_CHECK, check_lock_state, assistant, receiver)
|
|
return assistant
|
|
|
|
|
|
def prepare(receiver):
|
|
if receiver.receiver_kind == "bolt":
|
|
if receiver.discover(timeout=_PAIRING_TIMEOUT):
|
|
return True
|
|
else:
|
|
receiver.pairing.error = "discovery did not start"
|
|
return False
|
|
elif receiver.set_lock(False, timeout=_PAIRING_TIMEOUT):
|
|
return True
|
|
else:
|
|
receiver.pairing.error = "the pairing lock did not open"
|
|
return False
|
|
|
|
|
|
def check_lock_state(assistant, receiver, count=2):
|
|
if not assistant.is_drawable():
|
|
logger.debug("assistant %s destroyed, bailing out", assistant)
|
|
return False
|
|
return _check_lock_state(assistant, receiver, count)
|
|
|
|
|
|
def _check_lock_state(assistant, receiver, count):
|
|
if receiver.pairing.error:
|
|
_pairing_failed(assistant, receiver, receiver.pairing.error)
|
|
return False
|
|
elif receiver.pairing.new_device:
|
|
receiver.remaining_pairings(False) # Update remaining pairings
|
|
_pairing_succeeded(assistant, receiver, receiver.pairing.new_device)
|
|
return False
|
|
elif not receiver.pairing.lock_open and not receiver.pairing.discovering:
|
|
if count > 0:
|
|
# the actual device notification may arrive later so have a little patience
|
|
GLib.timeout_add(_STATUS_CHECK, check_lock_state, assistant, receiver, count - 1)
|
|
else:
|
|
_pairing_failed(assistant, receiver, "failed to open pairing lock")
|
|
return False
|
|
elif receiver.pairing.lock_open and receiver.pairing.device_passkey:
|
|
_show_passcode(assistant, receiver, receiver.pairing.device_passkey)
|
|
return True
|
|
elif receiver.pairing.discovering and receiver.pairing.device_address and receiver.pairing.device_name:
|
|
add = receiver.pairing.device_address
|
|
if getattr(assistant, _PAIRED_ADDRESS, None) == add:
|
|
return True # pairing was already requested for this device
|
|
ent = 20 if receiver.pairing.device_kind == hidpp10_constants.DEVICE_KIND.keyboard else 10
|
|
setattr(assistant, _PAIRED_ADDRESS, add)
|
|
if receiver.pair_device(address=add, authentication=receiver.pairing.device_authentication, entropy=ent):
|
|
return True
|
|
else:
|
|
_pairing_failed(assistant, receiver, "failed to open pairing lock")
|
|
return False
|
|
return True
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _pairing_succeeded(assistant, receiver, device):
|
|
assistant.remove_page(0) # needed to reset the window size
|
|
logger.debug("%s success: %s", receiver, device)
|
|
_create_success_page(assistant, device)
|
|
|
|
|
|
def _finish(assistant, receiver):
|
|
logger.debug("finish %s", assistant)
|
|
assistant.destroy()
|
|
receiver.pairing.new_device = None
|
|
if receiver.pairing.lock_open:
|
|
if receiver.receiver_kind == "bolt":
|
|
receiver.pair_device("cancel")
|
|
else:
|
|
receiver.set_lock()
|
|
if receiver.pairing.discovering:
|
|
receiver.discover(True)
|
|
if not receiver.pairing.lock_open and not receiver.pairing.discovering:
|
|
receiver.pairing.error = None
|
|
|
|
|
|
def _passkey_steps(passkey, authentication):
|
|
"""Returns the ordered steps needed to enter the passkey on the device.
|
|
|
|
Mice and touchpads take the passkey as ten button presses followed by a
|
|
simultaneous press of both buttons, keyboards as its digits followed by the
|
|
enter key. Returns None when the passkey cannot be interpreted, so that
|
|
callers running inside a periodic check never have to handle an exception.
|
|
"""
|
|
if passkey is None:
|
|
return None
|
|
if authentication & 0x01: # keyboards spell the passcode out
|
|
return [character for character in str(passkey)] + [_STEP_ENTER]
|
|
try:
|
|
bits = f"{int(passkey):010b}"
|
|
except (TypeError, ValueError):
|
|
return None
|
|
# keep this mapping as it is: the receiver never reports which button was
|
|
# pressed, so a changed polarity cannot be validated from inside Solaar
|
|
return [_STEP_RIGHT if bit == "1" else _STEP_LEFT for bit in bits] + [_STEP_BOTH]
|
|
|
|
|
|
def _passkey_description(steps, passkey, authentication):
|
|
"""Renders the steps as the sentence used for the tooltip and for screen readers."""
|
|
if authentication & 0x01:
|
|
return _("Type %(passcode)s and then press the enter key.") % {"passcode": passkey}
|
|
passcode = ", ".join(_("right") if step == _STEP_RIGHT else _("left") for step in steps[:-1])
|
|
return _("Press %(code)s\nand then press left and right buttons simultaneously.") % {"code": passcode}
|
|
|
|
|
|
@dataclass
|
|
class _StepCell:
|
|
"""Placement of a single step inside the step strip."""
|
|
|
|
x: float
|
|
y: float
|
|
width: float
|
|
height: float
|
|
step: str
|
|
index: int
|
|
|
|
|
|
def _step_strip_cells(steps):
|
|
"""Lays the steps out in rows, with the final step alone below a separator."""
|
|
leading = steps[:-1] # the final step gets a band of its own, below the separator
|
|
rows = [leading[index : index + _STEPS_PER_ROW] for index in range(0, len(leading), _STEPS_PER_ROW)]
|
|
columns = max((len(row) for row in rows), default=1)
|
|
width = 2 * _STRIP_MARGIN + columns * _CELL_WIDTH + (columns - 1) * _CELL_GAP
|
|
row_height = _CELL_HEIGHT + _NUMBER_GAP + _NUMBER_HEIGHT
|
|
cells = []
|
|
index = 0
|
|
y = float(_STRIP_MARGIN)
|
|
for row in rows:
|
|
row_width = len(row) * _CELL_WIDTH + (len(row) - 1) * _CELL_GAP
|
|
x = (width - row_width) / 2
|
|
for step in row:
|
|
cells.append(_StepCell(x, y, _CELL_WIDTH, _CELL_HEIGHT, step, index))
|
|
x += _CELL_WIDTH + _CELL_GAP
|
|
index += 1
|
|
y += row_height + _ROW_GAP
|
|
separator_y = y - _ROW_GAP + _SEPARATOR_GAP
|
|
final_width = _CELL_WIDTH * _FINAL_STEP_SCALE
|
|
final_height = _CELL_HEIGHT * _FINAL_STEP_SCALE
|
|
final_y = separator_y + _SEPARATOR_GAP
|
|
cells.append(_StepCell((width - final_width) / 2, final_y, final_width, final_height, steps[-1], index))
|
|
height = final_y + final_height + _NUMBER_GAP + _NUMBER_HEIGHT + _STRIP_MARGIN
|
|
return cells, separator_y, width, height
|
|
|
|
|
|
def _strip_accent_color(style):
|
|
"""Takes the highlight colour from the theme, so the strip stays legible everywhere."""
|
|
found, color = style.lookup_color("theme_selected_bg_color")
|
|
if found:
|
|
return color
|
|
color = style.get_color(Gtk.StateFlags.SELECTED)
|
|
if color is not None:
|
|
return color
|
|
return style.get_color(Gtk.StateFlags.NORMAL)
|
|
|
|
|
|
def _rounded_rectangle(cr, x, y, width, height, radius):
|
|
radius = min(radius, width / 2, height / 2)
|
|
cr.new_sub_path()
|
|
cr.arc(x + width - radius, y + radius, radius, -0.5 * math.pi, 0.0)
|
|
cr.arc(x + width - radius, y + height - radius, radius, 0.0, 0.5 * math.pi)
|
|
cr.arc(x + radius, y + height - radius, radius, 0.5 * math.pi, math.pi)
|
|
cr.arc(x + radius, y + radius, radius, math.pi, 1.5 * math.pi)
|
|
cr.close_path()
|
|
|
|
|
|
def _draw_mouse_cell(cr, cell, accent, outline, fill_alpha, outline_alpha):
|
|
"""Draws a mouse seen from above, with the buttons this step needs filled in."""
|
|
radius = _CELL_RADIUS * cell.width / _CELL_WIDTH
|
|
split_y = cell.y + cell.height * 0.42
|
|
split_x = cell.x + cell.width / 2
|
|
cr.save()
|
|
_rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius)
|
|
cr.clip()
|
|
cr.set_source_rgba(accent.red, accent.green, accent.blue, fill_alpha)
|
|
if cell.step in (_STEP_LEFT, _STEP_BOTH):
|
|
cr.rectangle(cell.x, cell.y, cell.width / 2, split_y - cell.y)
|
|
cr.fill()
|
|
if cell.step in (_STEP_RIGHT, _STEP_BOTH):
|
|
cr.rectangle(split_x, cell.y, cell.width / 2, split_y - cell.y)
|
|
cr.fill()
|
|
cr.restore()
|
|
wheel_width = cell.width * 0.20
|
|
wheel_height = cell.height * 0.28
|
|
cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha)
|
|
_rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius)
|
|
cr.stroke()
|
|
cr.move_to(cell.x, split_y)
|
|
cr.line_to(cell.x + cell.width, split_y)
|
|
cr.stroke()
|
|
cr.move_to(split_x, cell.y)
|
|
cr.line_to(split_x, split_y - wheel_height / 2)
|
|
cr.stroke()
|
|
_rounded_rectangle(cr, split_x - wheel_width / 2, split_y - wheel_height / 2, wheel_width, wheel_height, wheel_width / 2)
|
|
cr.stroke()
|
|
|
|
|
|
def _draw_key_cell(cr, cell, accent, outline, fill_alpha, outline_alpha):
|
|
"""Draws a key cap carrying either a digit of the passcode or an enter arrow."""
|
|
radius = _CELL_RADIUS * cell.width / _CELL_WIDTH / 2
|
|
cr.set_source_rgba(accent.red, accent.green, accent.blue, fill_alpha)
|
|
_rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius)
|
|
cr.fill()
|
|
cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha)
|
|
_rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius)
|
|
cr.stroke()
|
|
if cell.step == _STEP_ENTER:
|
|
# an arrow pointing down and then left, the usual shape of an enter key
|
|
top = cell.y + cell.height * 0.32
|
|
bottom = cell.y + cell.height * 0.62
|
|
left = cell.x + cell.width * 0.28
|
|
right = cell.x + cell.width * 0.72
|
|
cr.move_to(right, top)
|
|
cr.line_to(right, bottom)
|
|
cr.line_to(left, bottom)
|
|
cr.stroke()
|
|
head = cell.width * 0.14
|
|
cr.move_to(left + head, bottom - head)
|
|
cr.line_to(left, bottom)
|
|
cr.line_to(left + head, bottom + head)
|
|
cr.stroke()
|
|
else:
|
|
cr.set_font_size(cell.height * 0.46)
|
|
extents = cr.text_extents(cell.step)
|
|
cr.move_to(
|
|
cell.x + (cell.width - extents.width) / 2 - extents.x_bearing,
|
|
cell.y + (cell.height - extents.height) / 2 - extents.y_bearing,
|
|
)
|
|
cr.show_text(cell.step)
|
|
|
|
|
|
def _draw_check_mark(cr, cell, outline, outline_alpha):
|
|
"""Marks a step the receiver has already accepted."""
|
|
size = cell.width * 0.22
|
|
x = cell.x + cell.width * 0.72
|
|
y = cell.y + cell.height * 0.78
|
|
cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha)
|
|
cr.move_to(x - size, y)
|
|
cr.line_to(x - size / 3, y + size * 0.7)
|
|
cr.line_to(x + size, y - size * 0.8)
|
|
cr.stroke()
|
|
|
|
|
|
def _draw_step_strip(area, cr):
|
|
"""Paints which button every step needs, and how far entry has got.
|
|
|
|
The two are kept apart on purpose: the filled quadrant always says which
|
|
button to press, and only the emphasis says where the user is.
|
|
"""
|
|
steps = getattr(area, _STEPS, None)
|
|
if not steps:
|
|
return False
|
|
progress = getattr(area, _PROGRESS, None)
|
|
# without progress notifications no step is singled out, and the strip is
|
|
# simply the whole sequence, numbered and always readable
|
|
done = 0 if progress is None else min(progress, len(steps))
|
|
current = None if progress is None else min(progress, len(steps) - 1)
|
|
cells, separator_y, width, _height = _step_strip_cells(steps)
|
|
style = area.get_style_context()
|
|
accent = _strip_accent_color(style)
|
|
outline = style.get_color(Gtk.StateFlags.NORMAL)
|
|
is_mouse = steps[-1] == _STEP_BOTH
|
|
cr.save()
|
|
cr.translate(max(0, (area.get_allocated_width() - width) / 2), 0)
|
|
cr.set_line_width(1.0)
|
|
cr.set_line_join(0) # cairo.LINE_JOIN_MITER, spelled out to avoid importing cairo
|
|
cr.select_font_face("sans-serif")
|
|
cr.set_source_rgba(outline.red, outline.green, outline.blue, 0.25)
|
|
cr.move_to(_STRIP_MARGIN, separator_y)
|
|
cr.line_to(width - _STRIP_MARGIN, separator_y)
|
|
cr.stroke()
|
|
for cell in cells:
|
|
if cell.index < done:
|
|
fill_alpha, outline_alpha = 0.45, 0.35
|
|
elif cell.index == current:
|
|
fill_alpha, outline_alpha = 1.0, 1.0
|
|
else:
|
|
fill_alpha, outline_alpha = 0.22, 0.55
|
|
if cell.index == current:
|
|
cr.set_source_rgba(accent.red, accent.green, accent.blue, 1.0)
|
|
cr.set_line_width(2.0)
|
|
_rounded_rectangle(cr, cell.x - 3, cell.y - 3, cell.width + 6, cell.height + 6, _CELL_RADIUS)
|
|
cr.stroke()
|
|
cr.set_line_width(1.0)
|
|
if is_mouse:
|
|
_draw_mouse_cell(cr, cell, accent, outline, fill_alpha, outline_alpha)
|
|
else:
|
|
_draw_key_cell(cr, cell, accent, outline, fill_alpha, outline_alpha)
|
|
if cell.index < done:
|
|
_draw_check_mark(cr, cell, outline, 0.8)
|
|
number = str(cell.index + 1)
|
|
cr.set_font_size(_NUMBER_HEIGHT)
|
|
cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha)
|
|
extents = cr.text_extents(number)
|
|
cr.move_to(
|
|
cell.x + (cell.width - extents.width) / 2 - extents.x_bearing,
|
|
cell.y + cell.height + _NUMBER_GAP - extents.y_bearing,
|
|
)
|
|
cr.show_text(number)
|
|
cr.restore()
|
|
return False
|
|
|
|
|
|
def _create_step_strip(steps, description):
|
|
area = Gtk.DrawingArea()
|
|
setattr(area, _STEPS, steps)
|
|
setattr(area, _PROGRESS, 0)
|
|
_cells, _separator_y, width, height = _step_strip_cells(steps)
|
|
area.set_size_request(width, int(math.ceil(height)))
|
|
area.connect(GtkSignal.DRAW.value, _draw_step_strip)
|
|
# a drawing area is invisible to screen readers, so the sequence has to
|
|
# stay available as text as well
|
|
area.set_tooltip_text(description)
|
|
area.get_accessible().set_description(description)
|
|
return area
|
|
|
|
|
|
def _show_passcode(assistant, receiver, passkey):
|
|
"""Shows the passkey page, creating it on the first check and refreshing it afterwards."""
|
|
page = getattr(assistant, _PASSKEY_PAGE, None)
|
|
if page is None:
|
|
page = _create_passcode_page(assistant, receiver, passkey)
|
|
setattr(assistant, _PASSKEY_PAGE, page)
|
|
assistant.set_page_complete(page, True)
|
|
assistant.next_page()
|
|
_update_passcode_page(page, receiver)
|
|
|
|
|
|
def _create_passcode_page(assistant, receiver, passkey):
|
|
logger.debug("%s show passkey: %s", receiver, passkey)
|
|
name = receiver.pairing.device_name
|
|
authentication = receiver.pairing.device_authentication
|
|
intro_text = _("%(receiver_name)s: pair new device") % {"receiver_name": receiver.name}
|
|
page_text = _("Enter passcode on %(name)s.") % {"name": name}
|
|
steps = _passkey_steps(passkey, authentication)
|
|
if steps is None: # unreadable passkey, so fall back to the plain instructions
|
|
page_text += "\n"
|
|
page_text += _("Type %(passcode)s and then press the enter key.") % {"passcode": passkey}
|
|
elif authentication & 0x01: # for keyboards the passcode is meant to be read
|
|
page_text += "\n"
|
|
page_text += _passkey_description(steps, passkey, authentication)
|
|
page = _create_page(
|
|
assistant,
|
|
Gtk.AssistantPageType.PROGRESS,
|
|
intro_text,
|
|
"preferences-desktop-peripherals",
|
|
page_text,
|
|
)
|
|
if steps is None:
|
|
return page
|
|
strip = _create_step_strip(steps, _passkey_description(steps, passkey, authentication))
|
|
page.pack_start(strip, False, False, 0)
|
|
status = Gtk.Label(label=_("Waiting for the first click…"))
|
|
status.set_line_wrap(True)
|
|
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.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.get_style_context().add_class("dim-label")
|
|
page.pack_start(reminder, False, False, 0)
|
|
page.show_all()
|
|
page.strip = strip
|
|
page.status = status
|
|
page.started = time.monotonic()
|
|
return page
|
|
|
|
|
|
def _update_passcode_page(page, receiver):
|
|
"""Refreshes the page in place, so that the periodic check never appends another one."""
|
|
strip = getattr(page, "strip", None)
|
|
if strip is None:
|
|
return
|
|
steps = getattr(strip, _STEPS)
|
|
total = len(steps) - 1 # the receiver counts presses, not the final combined one
|
|
entered = receiver.pairing.passkey_entered
|
|
if receiver.pairing.passkey_complete:
|
|
progress = len(steps)
|
|
text = _("The receiver is checking the sequence…")
|
|
elif entered > 0:
|
|
progress = entered
|
|
if entered >= total and steps[-1] == _STEP_BOTH:
|
|
text = _("Finally, press the left and right buttons at the same time.")
|
|
else:
|
|
text = _("Clicks registered by the receiver: %(done)d of %(total)d") % {
|
|
"done": min(entered, total),
|
|
"total": total,
|
|
}
|
|
elif time.monotonic() - page.started < _PROGRESS_GRACE:
|
|
progress = 0
|
|
text = _("Waiting for the first click…")
|
|
else: # this receiver never reports progress, so highlight nothing at all
|
|
progress = None
|
|
text = _("This receiver does not report entry progress. Follow the numbered steps in order.")
|
|
page.status.set_label(text)
|
|
setattr(strip, _PROGRESS, progress)
|
|
strip.queue_draw()
|
|
|
|
|
|
def _create_assistant(receiver, ok, finish, title, text):
|
|
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")
|
|
if ok:
|
|
page_intro = _create_page(
|
|
assistant,
|
|
Gtk.AssistantPageType.PROGRESS,
|
|
title,
|
|
"preferences-desktop-peripherals",
|
|
text,
|
|
)
|
|
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)
|
|
assistant.connect(GtkSignal.CANCEL.value, finish, receiver)
|
|
assistant.connect(GtkSignal.CLOSE.value, finish, receiver)
|
|
return assistant
|
|
|
|
|
|
def _create_success_page(assistant, device):
|
|
def _check_encrypted(device, assistant, hbox):
|
|
if assistant.is_drawable() and device.link_encrypted is False:
|
|
hbox.pack_start(Gtk.Image.new_from_icon_name("security-low", Gtk.IconSize.MENU), False, False, 0)
|
|
hbox.pack_start(Gtk.Label(label=_("The wireless link is not encrypted")), False, False, 0)
|
|
hbox.show_all()
|
|
return False
|
|
|
|
page = _create_page(assistant, Gtk.AssistantPageType.SUMMARY)
|
|
header = Gtk.Label(label=_("Found a new device:"))
|
|
page.pack_start(header, False, False, 0)
|
|
device_icon = Gtk.Image()
|
|
icon_name = icons.device_icon_name(device.name, device.kind)
|
|
device_icon.set_from_icon_name(icon_name, icons.LARGE_SIZE)
|
|
page.pack_start(device_icon, True, True, 0)
|
|
device_label = Gtk.Label()
|
|
device_label.set_markup(f"<b>{device.name}</b>")
|
|
page.pack_start(device_label, True, True, 0)
|
|
hbox = Gtk.HBox(homogeneous=False, spacing=8)
|
|
hbox.pack_start(Gtk.Label(label=" "), False, False, 0)
|
|
hbox.set_property("expand", False)
|
|
hbox.set_property("halign", Gtk.Align.CENTER)
|
|
page.pack_start(hbox, False, False, 0)
|
|
GLib.timeout_add(_STATUS_CHECK, _check_encrypted, device, assistant, hbox) # wait a bit to check link status
|
|
page.show_all()
|
|
assistant.next_page()
|
|
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)
|
|
assistant.next_page()
|
|
assistant.commit()
|
|
|
|
|
|
def _create_page(assistant, kind, header=None, icon_name=None, text=None) -> Gtk.VBox:
|
|
p = Gtk.VBox(homogeneous=False, spacing=8)
|
|
assistant.append_page(p)
|
|
assistant.set_page_type(p, kind)
|
|
if header:
|
|
item = Gtk.HBox(homogeneous=False, spacing=16)
|
|
p.pack_start(item, False, True, 0)
|
|
label = Gtk.Label(label=header)
|
|
label.set_line_wrap(True)
|
|
item.pack_start(label, True, True, 0)
|
|
if icon_name:
|
|
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
|
|
item.pack_start(icon, False, False, 0)
|
|
if text:
|
|
label = Gtk.Label(label=text)
|
|
label.set_line_wrap(True)
|
|
p.pack_start(label, False, False, 0)
|
|
p.show_all()
|
|
return p
|