solaar: use bluez dbus signals to disconnect and connect bluetooth devices

This commit is contained in:
Peter F. Patel-Schneider 2024-04-18 13:24:01 -04:00
parent d7ce636917
commit e667d41c7b
6 changed files with 93 additions and 62 deletions

View File

@ -133,21 +133,22 @@ for the step-by-step procedure for manual installation.
## Known Issues ## Known Issues
- Bluez 5.73 interacts badly with Solaar (and with the Linux driver for Logitech devices). - Bluez 5.73 does not remove Bluetooth devices when they disconnect.
Bluetooth-connected devices will revert to default settings when reconnecting after going into power-saving mode or being turned off. Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect.
One way to recover is to quit Solaar and restart it. 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.
- 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 Linux HID++ driver modifies the Scroll Wheel Resolution setting to - 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 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 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. "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. 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. - 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 This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
to restore reversed scrolling. to restore reversed scrolling.

View File

@ -85,6 +85,7 @@ class Device:
self.hidpp_short = device_info.hidpp_short if device_info else None 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.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.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.setting_callback = setting_callback # for changes to settings
self.status_callback = None # for changes to other potentially visible aspects 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 self.wpid = pairing_info["wpid"] if pairing_info else None # the Wireless PID is unique per device model
@ -111,6 +112,7 @@ class Device:
self._settings_lock = _threading.Lock() self._settings_lock = _threading.Lock()
self._persister_lock = _threading.Lock() self._persister_lock = _threading.Lock()
self._notification_handlers = {} # See `add_notification_handler` self._notification_handlers = {} # See `add_notification_handler`
self.cleanups = [] # functions to run on the device when it is closed
if not self.path: if not self.path:
self.path = _hid.find_paired_node(receiver.path, number, 1) if receiver else None self.path = _hid.find_paired_node(receiver.path, number, 1) if receiver else None
@ -510,6 +512,9 @@ class Device:
handle, self.handle = self.handle, None handle, self.handle = self.handle, None
if self in Device.instances: if self in Device.instances:
Device.instances.remove(self) Device.instances.remove(self)
if hasattr(self, "cleanups"):
for cleanup in self.cleanups:
cleanup(self)
return handle and base.close(handle) return handle and base.close(handle)
def __index__(self): def __index__(self):

View File

@ -1,4 +1,5 @@
## Copyright (C) 2012-2013 Daniel Pavel ## 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 ## 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 ## it under the terms of the GNU General Public License as published by
@ -18,64 +19,59 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# try:
# As suggested here: http://stackoverflow.com/a/13548984 import dbus
#
from dbus.mainloop.glib import DBusGMainLoop # integration into the main GLib loop
DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
assert bus
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 _suspend_callback = None
def _suspend():
if _suspend_callback:
if logger.isEnabledFor(logging.INFO):
logger.info("received suspend event")
_suspend_callback()
_resume_callback = None _resume_callback = None
def _resume(): def _suspend_or_resume(suspend):
if _resume_callback: if suspend is True and _suspend_callback:
if logger.isEnabledFor(logging.INFO): _suspend_callback()
logger.info("received resume event") if suspend is False and _resume_callback:
_resume_callback() _resume_callback()
def _suspend_or_resume(suspend): _LOGIND_PATH = "/org/freedesktop/login1"
_suspend() if suspend else _resume() _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. """Register callback for suspend/resume events.
They are called only if the system DBus is running, and the Login daemon is available.""" They are called only if the system DBus is running, and the Login daemon is available."""
global _resume_callback, _suspend_callback global _resume_callback, _suspend_callback
_suspend_callback = on_suspend_callback _suspend_callback = on_suspend_callback
_resume_callback = on_resume_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)
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
bus.add_signal_receiver(_suspend_or_resume, "PrepareForSleep", dbus_interface=_LOGIND_INTERFACE, bus_name=_LOGIND_BUS)
if logger.isEnabledFor(logging.INFO): if logger.isEnabledFor(logging.INFO):
logger.info("connected to system dbus, watching for suspend/resume events") logger.info("connected to system dbus, watching for suspend/resume events")
except Exception:
# Either: _BLUETOOTH_PATH_PREFIX = "/org/bluez/hci0/dev_"
# - the dbus library is not available _BLUETOOTH_INTERFACE = "org.freedesktop.DBus.Properties"
# - the system dbus is not running
logger.warning("failed to register suspend/resume callbacks") _bluetooth_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
)

View File

@ -30,11 +30,11 @@ from traceback import format_exc
import solaar.cli as _cli import solaar.cli as _cli
import solaar.configuration as _configuration import solaar.configuration as _configuration
import solaar.dbus as _dbus
import solaar.i18n as _i18n import solaar.i18n as _i18n
import solaar.listener as _listener import solaar.listener as _listener
import solaar.ui as _ui import solaar.ui as _ui
import solaar.ui.common as _common import solaar.ui.common as _common
import solaar.upower as _upower
from solaar import NAME from solaar import NAME
from solaar import __version__ from solaar import __version__
@ -172,9 +172,9 @@ def main():
_listener.setup_scanner(_ui.status_changed, _ui.setting_changed, _common.error_dialog) _listener.setup_scanner(_ui.status_changed, _ui.setting_changed, _common.error_dialog)
if args.restart_on_wake_up: 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: 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 _configuration.defer_saves = True # allow configuration saves to be deferred

View File

@ -21,6 +21,7 @@ import subprocess
import time import time
from collections import namedtuple from collections import namedtuple
from functools import partial
import gi import gi
import logitech_receiver.device as _device import logitech_receiver.device as _device
@ -33,6 +34,8 @@ from logitech_receiver import listener as _listener
from logitech_receiver import notifications as _notifications from logitech_receiver import notifications as _notifications
from . import configuration from . import configuration
from . import dbus
from . import i18n
gi.require_version("Gtk", "3.0") # NOQA: E402 gi.require_version("Gtk", "3.0") # NOQA: E402
from gi.repository import GLib # NOQA: E402 # isort:skip 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) return _GHOST_DEVICE(receiver=device.receiver, number=device.number, name=device.name, kind=device.kind, online=False)
class ReceiverListener(_listener.EventsListener): class SolaarListener(_listener.EventsListener):
"""Keeps the status of a Receiver or Device.""" """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): def __init__(self, receiver, status_changed_callback):
assert status_changed_callback assert status_changed_callback
@ -226,7 +229,28 @@ class ReceiverListener(_listener.EventsListener):
dev.ping() dev.ping()
def __str__(self): def __str__(self):
return f"<ReceiverListener({self.receiver.path},{self.receiver.handle})>" return f"<SolaarListener({self.receiver.path},{self.receiver.handle})>"
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 _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) receiver = _receiver.ReceiverFactory.create_receiver(device_info, _setting_callback)
else: else:
receiver = _device.DeviceFactory.create_device(device_info, _setting_callback) receiver = _device.DeviceFactory.create_device(device_info, _setting_callback)
if receiver:
configuration.attach_to(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: if receiver:
rl = ReceiverListener(receiver, _status_callback) rl = SolaarListener(receiver, _status_callback)
rl.start() rl.start()
_all_listeners[device_info.path] = rl _all_listeners[device_info.path] = rl
return rl return rl
@ -343,7 +371,7 @@ def _process_receiver_event(action, device_info):
# whatever the action, stop any previous receivers at this path # whatever the action, stop any previous receivers at this path
listener_thread = _all_listeners.pop(device_info.path, None) listener_thread = _all_listeners.pop(device_info.path, None)
if listener_thread is not None: if listener_thread is not None:
assert isinstance(listener_thread, ReceiverListener) assert isinstance(listener_thread, SolaarListener)
listener_thread.stop() listener_thread.stop()
if action == "add": if action == "add":
_process_add(device_info, 3) _process_add(device_info, 3)

View File

@ -44,6 +44,7 @@ class DeviceInfo:
hidpp_short: bool = False hidpp_short: bool = False
hidpp_long: bool = True hidpp_long: bool = True
bus_id: int = 0x0003 # USB bus_id: int = 0x0003 # USB
serial: str = "aa:aa:aa;aa"
di_bad_handle = DeviceInfo(None, product_id=0xCCCC) di_bad_handle = DeviceInfo(None, product_id=0xCCCC)
@ -258,7 +259,7 @@ class TestDevice(device.Device): # a fully functional Device but its HID++ func
], ],
) )
def test_Device_complex(device_info, responses, protocol, led, keys, remap, gestures, backlight, profiles, mocker): def test_Device_complex(device_info, responses, protocol, led, keys, remap, gestures, backlight, profiles, mocker):
test_device = TestDevice(responses, None, None, online=True, device_info=device_info) test_device = TestDevice(responses, None, None, True, device_info=device_info)
test_device._name = "TestDevice" test_device._name = "TestDevice"
test_device._protocol = protocol test_device._protocol = protocol
spy_request = mocker.spy(test_device, "request") spy_request = mocker.spy(test_device, "request")
@ -297,7 +298,7 @@ def test_Device_complex(device_info, responses, protocol, led, keys, remap, gest
) )
def test_Device_settings(device_info, responses, protocol, p, persister, settings, mocker): def test_Device_settings(device_info, responses, protocol, p, persister, settings, mocker):
mocker.patch("solaar.configuration.persister", return_value=p) mocker.patch("solaar.configuration.persister", return_value=p)
test_device = TestDevice(responses, None, None, online=True, device_info=device_info) test_device = TestDevice(responses, None, None, True, device_info=device_info)
test_device._name = "TestDevice" test_device._name = "TestDevice"
test_device._protocol = protocol test_device._protocol = protocol