diff --git a/docs/index.md b/docs/index.md index 3a825ff66..47f8d76ae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -133,21 +133,22 @@ for the step-by-step procedure for manual installation. ## Known Issues -- Bluez 5.73 interacts badly with Solaar (and with the Linux driver for Logitech devices). - Bluetooth-connected devices will revert to default settings when reconnecting after going into power-saving mode or being turned off. - One way to recover is to quit Solaar and restart it. - -- Solaar expects that it has exclusive control over settings that are not ignored. - Running other programs that modify these settings, such as logiops, - will likely result in unexpected device behavior. +- Bluez 5.73 does not remove Bluetooth devices when they disconnect. + Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect. + The HID++ driver does not re-initialize devices, which causes problems with smooth scrolling. + Until the problem is resolved having Scroll Wheel Resolution set to true (and not ignored) may be helpful. - The Linux HID++ driver modifies the Scroll Wheel Resolution setting to - implement smooth scrolling. If Solaar later changes this setting, scrolling + implement smooth scrolling. If Solaar changes this setting, scrolling can be either very fast or very slow. To fix this problem click on the icon at the right edge of the setting to set it to "Ignore this setting", which is the default for new devices. The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect. +- Solaar expects that it has exclusive control over settings that are not ignored. + Running other programs that modify these settings, such as logiops, + will likely result in unexpected device behavior. + - The driver also sets the scrolling direction to its normal setting when implementing smooth scrolling. This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth to restore reversed scrolling. diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index b7be8095b..8f9371853 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -85,6 +85,7 @@ def __init__(self, receiver, number, online, pairing_info=None, handle=None, dev self.hidpp_short = device_info.hidpp_short if device_info else None self.hidpp_long = device_info.hidpp_long if device_info else None self.bluetooth = device_info.bus_id == 0x0005 if device_info else False # Bluetooth needs long messages + self.hid_serial = device_info.serial if device_info else None self.setting_callback = setting_callback # for changes to settings self.status_callback = None # for changes to other potentially visible aspects self.wpid = pairing_info["wpid"] if pairing_info else None # the Wireless PID is unique per device model @@ -111,6 +112,7 @@ def __init__(self, receiver, number, online, pairing_info=None, handle=None, dev self._settings_lock = _threading.Lock() self._persister_lock = _threading.Lock() self._notification_handlers = {} # See `add_notification_handler` + self.cleanups = [] # functions to run on the device when it is closed if not self.path: self.path = _hid.find_paired_node(receiver.path, number, 1) if receiver else None @@ -510,6 +512,8 @@ def close(self): handle, self.handle = self.handle, None if self in Device.instances: Device.instances.remove(self) + for cleanup in self.cleanups: + cleanup(self) return handle and base.close(handle) def __index__(self): diff --git a/lib/solaar/upower.py b/lib/solaar/dbus.py similarity index 53% rename from lib/solaar/upower.py rename to lib/solaar/dbus.py index 650f5ae8c..a1e0517c8 100644 --- a/lib/solaar/upower.py +++ b/lib/solaar/dbus.py @@ -1,4 +1,5 @@ ## 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 @@ -18,64 +19,59 @@ logger = logging.getLogger(__name__) -# -# As suggested here: http://stackoverflow.com/a/13548984 -# +try: + import dbus -_suspend_callback = None + from dbus.mainloop.glib import DBusGMainLoop # integration into the main GLib loop + DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + assert bus -def _suspend(): - if _suspend_callback: - if logger.isEnabledFor(logging.INFO): - logger.info("received suspend event") - _suspend_callback() +except Exception: + # Either the dbus library is not available or the system dbus is not running + logger.warning("failed to set up dbus") + pass +_suspend_callback = None _resume_callback = None -def _resume(): - if _resume_callback: - if logger.isEnabledFor(logging.INFO): - logger.info("received resume event") +def _suspend_or_resume(suspend): + if suspend is True and _suspend_callback: + _suspend_callback() + if suspend is False and _resume_callback: _resume_callback() -def _suspend_or_resume(suspend): - _suspend() if suspend else _resume() +_LOGIND_PATH = "/org/freedesktop/login1" +_LOGIND_INTERFACE = "org.freedesktop.login1.Manager" -def watch(on_resume_callback=None, on_suspend_callback=None): +def watch_suspend_resume(on_resume_callback=None, on_suspend_callback=None): """Register callback for suspend/resume events. They are called only if the system DBus is running, and the Login daemon is available.""" global _resume_callback, _suspend_callback _suspend_callback = on_suspend_callback _resume_callback = on_resume_callback + if on_resume_callback is not None or on_suspend_callback is not None: + bus.add_signal_receiver(_suspend_or_resume, "PrepareForSleep", dbus_interface=_LOGIND_INTERFACE, path=_LOGIND_PATH) + if logger.isEnabledFor(logging.INFO): + logger.info("connected to system dbus, watching for suspend/resume events") -try: - import dbus - - _LOGIND_BUS = "org.freedesktop.login1" - _LOGIND_INTERFACE = "org.freedesktop.login1.Manager" - - # integration into the main GLib loop - from dbus.mainloop.glib import DBusGMainLoop - - DBusGMainLoop(set_as_default=True) - - bus = dbus.SystemBus() - assert bus +_BLUETOOTH_PATH_PREFIX = "/org/bluez/hci0/dev_" +_BLUETOOTH_INTERFACE = "org.freedesktop.DBus.Properties" - bus.add_signal_receiver(_suspend_or_resume, "PrepareForSleep", dbus_interface=_LOGIND_INTERFACE, bus_name=_LOGIND_BUS) +_bluetooth_callbacks = {} - if logger.isEnabledFor(logging.INFO): - logger.info("connected to system dbus, watching for suspend/resume events") -except Exception: - # Either: - # - the dbus library is not available - # - the system dbus is not running - logger.warning("failed to register suspend/resume callbacks") - pass +def watch_bluez_connect(serial, callback=None): + if _bluetooth_callbacks.get(serial): + _bluetooth_callbacks.get(serial).remove() + path = _BLUETOOTH_PATH_PREFIX + serial.replace(":", "_").upper() + if callback is not None: + _bluetooth_callbacks[serial] = bus.add_signal_receiver( + callback, "PropertiesChanged", path=path, dbus_interface=_BLUETOOTH_INTERFACE + ) diff --git a/lib/solaar/gtk.py b/lib/solaar/gtk.py index 9637d231f..9c16f05e4 100755 --- a/lib/solaar/gtk.py +++ b/lib/solaar/gtk.py @@ -30,11 +30,11 @@ import solaar.cli as _cli import solaar.configuration as _configuration +import solaar.dbus as _dbus import solaar.i18n as _i18n import solaar.listener as _listener import solaar.ui as _ui import solaar.ui.common as _common -import solaar.upower as _upower from solaar import NAME from solaar import __version__ @@ -172,9 +172,9 @@ def main(): _listener.setup_scanner(_ui.status_changed, _ui.setting_changed, _common.error_dialog) if args.restart_on_wake_up: - _upower.watch(_listener.start_all, _listener.stop_all) + _dbus.watch_suspend_resume(_listener.start_all, _listener.stop_all) else: - _upower.watch(lambda: _listener.ping_all(True)) + _dbus.watch_suspend_resume(lambda: _listener.ping_all(True)) _configuration.defer_saves = True # allow configuration saves to be deferred diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index 649bd420c..859f6e757 100644 --- a/lib/solaar/listener.py +++ b/lib/solaar/listener.py @@ -21,6 +21,7 @@ import time from collections import namedtuple +from functools import partial import gi import logitech_receiver.device as _device @@ -33,6 +34,8 @@ from logitech_receiver import notifications as _notifications from . import configuration +from . import dbus +from . import i18n gi.require_version("Gtk", "3.0") # NOQA: E402 from gi.repository import GLib # NOQA: E402 # isort:skip @@ -52,8 +55,8 @@ def _ghost(device): return _GHOST_DEVICE(receiver=device.receiver, number=device.number, name=device.name, kind=device.kind, online=False) -class ReceiverListener(_listener.EventsListener): - """Keeps the status of a Receiver or Device.""" +class SolaarListener(_listener.EventsListener): + """Keeps the status of a Receiver or Device (member name is receiver but it can also be a device).""" def __init__(self, receiver, status_changed_callback): assert status_changed_callback @@ -226,7 +229,28 @@ def _notifications_handler(self, n): dev.ping() def __str__(self): - return f"" + return f"" + + +def _process_bluez_dbus(device, path, dictionary, signature): + """Process bluez dbus property changed signals for device status changes to discover disconnections and connections""" + if device: + if dictionary.get("Connected") is not None: + connected = dictionary.get("Connected") + if logger.isEnabledFor(logging.INFO): + logger.info("bluez dbus for %s: %s", device, "CONNECTED" if connected else "DISCONNECTED") + device.changed(connected, reason=i18n._("connected") if connected else i18n._("disconnected")) + elif device is not None: + if logger.isEnabledFor(logging.INFO): + logger.info("bluez cleanup for %s", device) + _cleanup_bluez_dbus(device) + + +def _cleanup_bluez_dbus(device): + """Remove dbus signal receiver for device""" + if logger.isEnabledFor(logging.INFO): + logger.info("bluez cleanup for %s", device) + dbus.watch_bluez_connect(device.hid_serial, None) _all_listeners = {} # all known receiver listeners, listeners that stop on their own may remain here @@ -239,10 +263,14 @@ def _start(device_info): receiver = _receiver.ReceiverFactory.create_receiver(device_info, _setting_callback) else: receiver = _device.DeviceFactory.create_device(device_info, _setting_callback) - configuration.attach_to(receiver) + if receiver: + configuration.attach_to(receiver) + if receiver.bluetooth and receiver.hid_serial: + dbus.watch_bluez_connect(receiver.hid_serial, partial(_process_bluez_dbus, receiver)) + receiver.cleanups.append(_cleanup_bluez_dbus) if receiver: - rl = ReceiverListener(receiver, _status_callback) + rl = SolaarListener(receiver, _status_callback) rl.start() _all_listeners[device_info.path] = rl return rl @@ -343,7 +371,7 @@ def _process_receiver_event(action, device_info): # whatever the action, stop any previous receivers at this path listener_thread = _all_listeners.pop(device_info.path, None) if listener_thread is not None: - assert isinstance(listener_thread, ReceiverListener) + assert isinstance(listener_thread, SolaarListener) listener_thread.stop() if action == "add": _process_add(device_info, 3)