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
- 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.

View File

@ -85,6 +85,7 @@ class Device:
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 @@ class Device:
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,9 @@ class Device:
handle, self.handle = self.handle, None
if self in Device.instances:
Device.instances.remove(self)
if hasattr(self, "cleanups"):
for cleanup in self.cleanups:
cleanup(self)
return handle and base.close(handle)
def __index__(self):

View File

@ -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 @@ import logging
logger = logging.getLogger(__name__)
#
# As suggested here: http://stackoverflow.com/a/13548984
#
try:
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
def _suspend():
if _suspend_callback:
if logger.isEnabledFor(logging.INFO):
logger.info("received suspend event")
_suspend_callback()
_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
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 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")
except Exception:
# Either:
# - the dbus library is not available
# - the system dbus is not running
logger.warning("failed to register suspend/resume callbacks")
pass
_BLUETOOTH_PATH_PREFIX = "/org/bluez/hci0/dev_"
_BLUETOOTH_INTERFACE = "org.freedesktop.DBus.Properties"
_bluetooth_callbacks = {}
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.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

View File

@ -21,6 +21,7 @@ import subprocess
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 listener as _listener
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 @@ class ReceiverListener(_listener.EventsListener):
dev.ping()
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
@ -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)
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)

View File

@ -44,6 +44,7 @@ class DeviceInfo:
hidpp_short: bool = False
hidpp_long: bool = True
bus_id: int = 0x0003 # USB
serial: str = "aa:aa:aa;aa"
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):
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._protocol = protocol
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):
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._protocol = protocol