diff --git a/CMakeLists.txt b/CMakeLists.txt index 846a280c..7161c4e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -70,6 +70,7 @@ boption(SERVICE_PAM "Pam" ON) boption(SERVICE_GREETD "Greetd" ON) boption(SERVICE_UPOWER "UPower" ON) boption(SERVICE_NOTIFICATIONS "Notifications" ON) +boption(BLUETOOTH "Bluetooth" ON) include(cmake/install-qml-module.cmake) include(cmake/util.cmake) @@ -116,7 +117,7 @@ if (WAYLAND) list(APPEND QT_FPDEPS WaylandClient) endif() -if (SERVICE_STATUS_NOTIFIER OR SERVICE_MPRIS OR SERVICE_UPOWER OR SERVICE_NOTIFICATIONS) +if (SERVICE_STATUS_NOTIFIER OR SERVICE_MPRIS OR SERVICE_UPOWER OR SERVICE_NOTIFICATIONS OR BLUETOOTH) set(DBUS ON) endif() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d3070b6b..52db00a5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -29,3 +29,7 @@ if (X11) endif() add_subdirectory(services) + +if (BLUETOOTH) + add_subdirectory(bluetooth) +endif() diff --git a/src/bluetooth/CMakeLists.txt b/src/bluetooth/CMakeLists.txt new file mode 100644 index 00000000..806ff04d --- /dev/null +++ b/src/bluetooth/CMakeLists.txt @@ -0,0 +1,42 @@ +set_source_files_properties(org.bluez.Adapter.xml PROPERTIES + CLASSNAME DBusBluezAdapterInterface +) + +set_source_files_properties(org.bluez.Device.xml PROPERTIES + CLASSNAME DBusBluezDeviceInterface +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.bluez.Adapter.xml + dbus_adapter +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.bluez.Device.xml + dbus_device +) + +qt_add_library(quickshell-bluetooth STATIC + adapter.cpp + bluez.cpp + device.cpp + ${DBUS_INTERFACES} +) + +qt_add_qml_module(quickshell-bluetooth + URI Quickshell.Bluetooth + VERSION 0.1 + DEPENDENCIES QtQml +) + +install_qml_module(quickshell-bluetooth) + +# dbus headers +target_include_directories(quickshell-bluetooth PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +target_link_libraries(quickshell-bluetooth PRIVATE Qt::Qml Qt::DBus) +qs_add_link_dependencies(quickshell-bluetooth quickshell-dbus) + +qs_module_pch(quickshell-bluetooth SET dbus) + +target_link_libraries(quickshell PRIVATE quickshell-bluetoothplugin) diff --git a/src/bluetooth/adapter.cpp b/src/bluetooth/adapter.cpp new file mode 100644 index 00000000..e24b13ab --- /dev/null +++ b/src/bluetooth/adapter.cpp @@ -0,0 +1,217 @@ +#include "adapter.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../dbus/properties.hpp" +#include "dbus_adapter.h" + +namespace qs::bluetooth { + +namespace { +Q_LOGGING_CATEGORY(logAdapter, "quickshell.bluetooth.adapter", QtWarningMsg); +} + +QString BluetoothAdapterState::toString(BluetoothAdapterState::Enum state) { + switch (state) { + case BluetoothAdapterState::Disabled: return QStringLiteral("Disabled"); + case BluetoothAdapterState::Enabled: return QStringLiteral("Enabled"); + case BluetoothAdapterState::Enabling: return QStringLiteral("Enabling"); + case BluetoothAdapterState::Disabling: return QStringLiteral("Disabling"); + case BluetoothAdapterState::Blocked: return QStringLiteral("Blocked"); + default: return QStringLiteral("Unknown"); + } +} + +BluetoothAdapter::BluetoothAdapter(const QString& path, QObject* parent): QObject(parent) { + this->mInterface = + new DBusBluezAdapterInterface("org.bluez", path, QDBusConnection::systemBus(), this); + + if (!this->mInterface->isValid()) { + qCWarning(logAdapter) << "Could not create DBus interface for adapter at" << path; + this->mInterface = nullptr; + return; + } + + this->properties.setInterface(this->mInterface); +} + +QString BluetoothAdapter::adapterId() const { + auto path = this->path(); + return path.sliced(path.lastIndexOf('/') + 1); +} + +void BluetoothAdapter::setEnabled(bool enabled) { + if (enabled == this->bEnabled) return; + this->bEnabled = enabled; + this->pEnabled.write(); +} + +void BluetoothAdapter::setDiscoverable(bool discoverable) { + if (discoverable == this->bDiscoverable) return; + this->bDiscoverable = discoverable; + this->pDiscoverable.write(); +} + +void BluetoothAdapter::setDiscovering(bool discovering) { + if (discovering) { + this->startDiscovery(); + } else { + this->stopDiscovery(); + } +} + +void BluetoothAdapter::setDiscoverableTimeout(quint32 timeout) { + if (timeout == this->bDiscoverableTimeout) return; + this->bDiscoverableTimeout = timeout; + this->pDiscoverableTimeout.write(); +} + +void BluetoothAdapter::setPairable(bool pairable) { + if (pairable == this->bPairable) return; + this->bPairable = pairable; + this->pPairable.write(); +} + +void BluetoothAdapter::setPairableTimeout(quint32 timeout) { + if (timeout == this->bPairableTimeout) return; + this->bPairableTimeout = timeout; + this->pPairableTimeout.write(); +} + +void BluetoothAdapter::addInterface(const QString& interface, const QVariantMap& properties) { + if (interface == "org.bluez.Adapter1") { + this->properties.updatePropertySet(properties, false); + qCDebug(logAdapter) << "Updated Adapter properties for" << this; + } +} + +void BluetoothAdapter::removeDevice(const QString& devicePath) { + qCDebug(logAdapter) << "Removing device" << devicePath << "from adapter" << this; + + auto reply = this->mInterface->RemoveDevice(QDBusObjectPath(devicePath)); + + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this, devicePath](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to remove device " << devicePath << " from adapter" << this << ": " + << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully removed device" << devicePath << "from adapter" + << this; + } + + delete watcher; + } + ); +} + +void BluetoothAdapter::startDiscovery() { + if (this->bDiscovering) return; + qCDebug(logAdapter) << "Starting discovery for adapter" << this; + + auto reply = this->mInterface->StartDiscovery(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to start discovery on adapter" << this << ": " << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully started discovery on adapter" << this; + } + + delete watcher; + } + ); +} + +void BluetoothAdapter::stopDiscovery() { + if (!this->bDiscovering) return; + qCDebug(logAdapter) << "Stopping discovery for adapter" << this; + + auto reply = this->mInterface->StopDiscovery(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to stop discovery on adapter " << this << ": " << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully stopped discovery on adapter" << this; + } + + delete watcher; + } + ); +} + +} // namespace qs::bluetooth + +namespace qs::dbus { + +using namespace qs::bluetooth; + +DBusResult +DBusDataTransform::fromWire(const Wire& wire) { + if (wire == QStringLiteral("off")) { + return BluetoothAdapterState::Disabled; + } else if (wire == QStringLiteral("on")) { + return BluetoothAdapterState::Enabled; + } else if (wire == QStringLiteral("off-enabling")) { + return BluetoothAdapterState::Enabling; + } else if (wire == QStringLiteral("on-disabling")) { + return BluetoothAdapterState::Disabling; + } else if (wire == QStringLiteral("off-blocked")) { + return BluetoothAdapterState::Blocked; + } else { + return QDBusError( + QDBusError::InvalidArgs, + QString("Invalid BluetoothAdapterState: %1").arg(wire) + ); + } +} + +} // namespace qs::dbus + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothAdapter* adapter) { + auto saver = QDebugStateSaver(debug); + + if (adapter) { + debug.nospace() << "BluetoothAdapter(" << static_cast(adapter) + << ", path=" << adapter->path() << ")"; + } else { + debug << "BluetoothAdapter(nullptr)"; + } + + return debug; +} diff --git a/src/bluetooth/adapter.hpp b/src/bluetooth/adapter.hpp new file mode 100644 index 00000000..d7f21d7e --- /dev/null +++ b/src/bluetooth/adapter.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "../dbus/properties.hpp" +#include "dbus_adapter.h" + +namespace qs::bluetooth { + +///! Power state of a Bluetooth adapter. +class BluetoothAdapterState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The adapter is powered off. + Disabled = 0, + /// The adapter is powered on. + Enabled = 1, + /// The adapter is transitioning from off to on. + Enabling = 2, + /// The adapter is transitioning from on to off. + Disabling = 3, + /// The adapter is blocked by rfkill. + Blocked = 4, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(BluetoothAdapterState::Enum state); +}; + +} // namespace qs::bluetooth + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = QString; + using Data = qs::bluetooth::BluetoothAdapterState::Enum; + static DBusResult fromWire(const Wire& wire); +}; + +} // namespace qs::dbus + +namespace qs::bluetooth { + +class BluetoothAdapter; +class BluetoothDevice; + +///! A Bluetooth adapter +class BluetoothAdapter: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// System provided name of the adapter. See @@adapterId for the internal identifier. + Q_PROPERTY(QString name READ default NOTIFY nameChanged BINDABLE bindableName); + /// True if the adapter is currently enabled. More detailed state is available from @@state. + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged); + /// Detailed power state of the adapter. + Q_PROPERTY(BluetoothAdapterState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// True if the adapter can be discovered by other bluetooth devices. + Q_PROPERTY(bool discoverable READ discoverable WRITE setDiscoverable NOTIFY discoverableChanged); + /// Timeout in seconds for how long the adapter stays discoverable after @@discoverable is set to true. + /// A value of 0 means the adapter stays discoverable forever. + Q_PROPERTY(quint32 discoverableTimeout READ discoverableTimeout WRITE setDiscoverableTimeout NOTIFY discoverableTimeoutChanged); + /// True if the adapter is scanning for new devices. + Q_PROPERTY(bool discovering READ discovering WRITE setDiscovering NOTIFY discoveringChanged); + /// True if the adapter is accepting incoming pairing requests. + /// + /// This only affects incoming pairing requests and should typically only be changed + /// by system settings applications. Defaults to true. + Q_PROPERTY(bool pairable READ pairable WRITE setPairable NOTIFY pairableChanged); + /// Timeout in seconds for how long the adapter stays pairable after @@pairable is set to true. + /// A value of 0 means the adapter stays pairable forever. Defaults to 0. + Q_PROPERTY(quint32 pairableTimeout READ pairableTimeout WRITE setPairableTimeout NOTIFY pairableTimeoutChanged); + /// Bluetooth devices connected to this adapter. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + /// The internal ID of the adapter (e.g., "hci0"). + Q_PROPERTY(QString adapterId READ adapterId CONSTANT); + /// DBus path of the adapter under the `org.bluez` system service. + Q_PROPERTY(QString dbusPath READ path CONSTANT); + // clang-format on + +public: + explicit BluetoothAdapter(const QString& path, QObject* parent = nullptr); + + [[nodiscard]] bool isValid() const { return this->mInterface->isValid(); } + [[nodiscard]] QString path() const { return this->mInterface->path(); } + [[nodiscard]] QString adapterId() const; + + [[nodiscard]] bool enabled() const { return this->bEnabled; } + void setEnabled(bool enabled); + + [[nodiscard]] bool discoverable() const { return this->bDiscoverable; } + void setDiscoverable(bool discoverable); + + [[nodiscard]] bool discovering() const { return this->bDiscovering; } + void setDiscovering(bool discovering); + + [[nodiscard]] quint32 discoverableTimeout() const { return this->bDiscoverableTimeout; } + void setDiscoverableTimeout(quint32 timeout); + + [[nodiscard]] bool pairable() const { return this->bPairable; } + void setPairable(bool pairable); + + [[nodiscard]] quint32 pairableTimeout() const { return this->bPairableTimeout; } + void setPairableTimeout(quint32 timeout); + + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableEnabled() { return &this->bEnabled; } + [[nodiscard]] QBindable bindableState() { return &this->bState; } + [[nodiscard]] QBindable bindableDiscoverable() { return &this->bDiscoverable; } + [[nodiscard]] QBindable bindableDiscoverableTimeout() { + return &this->bDiscoverableTimeout; + } + [[nodiscard]] QBindable bindableDiscovering() { return &this->bDiscovering; } + [[nodiscard]] QBindable bindablePairable() { return &this->bPairable; } + [[nodiscard]] QBindable bindablePairableTimeout() { return &this->bPairableTimeout; } + [[nodiscard]] ObjectModel* devices() { return &this->mDevices; } + + void addInterface(const QString& interface, const QVariantMap& properties); + void removeDevice(const QString& devicePath); + + void startDiscovery(); + void stopDiscovery(); + +signals: + void nameChanged(); + void enabledChanged(); + void stateChanged(); + void discoverableChanged(); + void discoverableTimeoutChanged(); + void discoveringChanged(); + void pairableChanged(); + void pairableTimeoutChanged(); + +private: + DBusBluezAdapterInterface* mInterface = nullptr; + ObjectModel mDevices {this}; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, QString, bName, &BluetoothAdapter::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bEnabled, &BluetoothAdapter::enabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, BluetoothAdapterState::Enum, bState, &BluetoothAdapter::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bDiscoverable, &BluetoothAdapter::discoverableChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, quint32, bDiscoverableTimeout, &BluetoothAdapter::discoverableTimeoutChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bDiscovering, &BluetoothAdapter::discoveringChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bPairable, &BluetoothAdapter::pairableChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, quint32, bPairableTimeout, &BluetoothAdapter::pairableTimeoutChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothAdapter, properties); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pName, bName, properties, "Alias"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pEnabled, bEnabled, properties, "Powered"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pState, bState, properties, "PowerState"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscoverable, bDiscoverable, properties, "Discoverable"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscoverableTimeout, bDiscoverableTimeout, properties, "DiscoverableTimeout"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscovering, bDiscovering, properties, "Discovering"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pPairable, bPairable, properties, "Pairable"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pPairableTimeout, bPairableTimeout, properties, "PairableTimeout"); + // clang-format on +}; + +} // namespace qs::bluetooth + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothAdapter* adapter); diff --git a/src/bluetooth/bluez.cpp b/src/bluetooth/bluez.cpp new file mode 100644 index 00000000..3c8bf949 --- /dev/null +++ b/src/bluetooth/bluez.cpp @@ -0,0 +1,156 @@ +#include "bluez.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "../dbus/dbus_objectmanager_types.hpp" +#include "../dbus/objectmanager.hpp" +#include "adapter.hpp" +#include "device.hpp" + +namespace qs::bluetooth { + +namespace { +Q_LOGGING_CATEGORY(logBluetooth, "quickshell.bluetooth", QtWarningMsg); +} + +Bluez* Bluez::instance() { + static auto* instance = new Bluez(); + return instance; +} + +Bluez::Bluez() { this->init(); } + +void Bluez::init() { + qCDebug(logBluetooth) << "Connecting to BlueZ"; + + auto bus = QDBusConnection::systemBus(); + + if (!bus.isConnected()) { + qCWarning(logBluetooth) << "Could not connect to DBus. Bluetooth integration is not available."; + return; + } + + this->objectManager = new qs::dbus::DBusObjectManager(this); + + QObject::connect( + this->objectManager, + &qs::dbus::DBusObjectManager::interfacesAdded, + this, + &Bluez::onInterfacesAdded + ); + + QObject::connect( + this->objectManager, + &qs::dbus::DBusObjectManager::interfacesRemoved, + this, + &Bluez::onInterfacesRemoved + ); + + if (!this->objectManager->setInterface("org.bluez", "/", bus)) { + qCDebug(logBluetooth) << "BlueZ is not running. Bluetooth integration will not work."; + return; + } +} + +void Bluez::onInterfacesAdded( + const QDBusObjectPath& path, + const DBusObjectManagerInterfaces& interfaces +) { + if (auto* adapter = this->mAdapterMap.value(path.path())) { + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + adapter->addInterface(interface, properties); + } + } else if (auto* device = this->mDeviceMap.value(path.path())) { + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + device->addInterface(interface, properties); + } + } else if (interfaces.contains("org.bluez.Adapter1")) { + auto* adapter = new BluetoothAdapter(path.path(), this); + + if (!adapter->isValid()) { + qCWarning(logBluetooth) << "Adapter path is not valid, cannot track: " << device; + delete adapter; + return; + } + + qCDebug(logBluetooth) << "Tracked new adapter" << adapter; + + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + adapter->addInterface(interface, properties); + } + + for (auto* device: this->mDevices.valueList()) { + if (device->adapterPath() == path) { + adapter->devices()->insertObject(device); + qCDebug(logBluetooth) << "Added tracked device" << device << "to new adapter" << adapter; + emit device->adapterChanged(); + } + } + + this->mAdapterMap.insert(path.path(), adapter); + this->mAdapters.insertObject(adapter); + } else if (interfaces.contains("org.bluez.Device1")) { + auto* device = new BluetoothDevice(path.path(), this); + + if (!device->isValid()) { + qCWarning(logBluetooth) << "Device path is not valid, cannot track: " << device; + delete device; + return; + } + + qCDebug(logBluetooth) << "Tracked new device" << device; + + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + device->addInterface(interface, properties); + } + + if (auto* adapter = device->adapter()) { + adapter->devices()->insertObject(device); + qCDebug(logBluetooth) << "Added device" << device << "to adapter" << adapter; + } + + this->mDeviceMap.insert(path.path(), device); + this->mDevices.insertObject(device); + } +} + +void Bluez::onInterfacesRemoved(const QDBusObjectPath& path, const QStringList& interfaces) { + if (auto* adapter = this->mAdapterMap.value(path.path())) { + if (interfaces.contains("org.bluez.Adapter1")) { + qCDebug(logBluetooth) << "Adapter removed:" << adapter; + + this->mAdapterMap.remove(path.path()); + this->mAdapters.removeObject(adapter); + delete adapter; + } + } else if (auto* device = this->mDeviceMap.value(path.path())) { + if (interfaces.contains("org.bluez.Device1")) { + qCDebug(logBluetooth) << "Device removed:" << device; + + if (auto* adapter = device->adapter()) { + adapter->devices()->removeObject(device); + } + + this->mDeviceMap.remove(path.path()); + this->mDevices.removeObject(device); + delete device; + } else { + for (const auto& interface: interfaces) { + device->removeInterface(interface); + } + } + } +} + +BluetoothAdapter* Bluez::defaultAdapter() const { + const auto& adapters = this->mAdapters.valueList(); + return adapters.isEmpty() ? nullptr : adapters.first(); +} + +} // namespace qs::bluetooth diff --git a/src/bluetooth/bluez.hpp b/src/bluetooth/bluez.hpp new file mode 100644 index 00000000..d888e8fe --- /dev/null +++ b/src/bluetooth/bluez.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "../dbus/dbus_objectmanager_types.hpp" +#include "../dbus/objectmanager.hpp" + +namespace qs::bluetooth { + +class BluetoothAdapter; +class BluetoothDevice; + +class Bluez: public QObject { + Q_OBJECT; + +public: + [[nodiscard]] ObjectModel* adapters() { return &this->mAdapters; } + [[nodiscard]] ObjectModel* devices() { return &this->mDevices; } + [[nodiscard]] BluetoothAdapter* defaultAdapter() const; + + [[nodiscard]] BluetoothAdapter* adapter(const QString& path) { + return this->mAdapterMap.value(path); + } + + static Bluez* instance(); + +private slots: + void + onInterfacesAdded(const QDBusObjectPath& path, const DBusObjectManagerInterfaces& interfaces); + void onInterfacesRemoved(const QDBusObjectPath& path, const QStringList& interfaces); + +private: + explicit Bluez(); + void init(); + + qs::dbus::DBusObjectManager* objectManager = nullptr; + QHash mAdapterMap; + QHash mDeviceMap; + ObjectModel mAdapters {this}; + ObjectModel mDevices {this}; +}; + +///! Bluetooth manager +/// Provides access to bluetooth devices and adapters. +class BluezQml: public QObject { + Q_OBJECT; + /// The default bluetooth adapter. Usually there is only one. + Q_PROPERTY(BluetoothAdapter* defaultAdapter READ defaultAdapter CONSTANT); + QSDOC_TYPE_OVERRIDE(ObjectModel*); + /// A list of all bluetooth adapters. See @@defaultAdapter for the default. + Q_PROPERTY(UntypedObjectModel* adapters READ adapters CONSTANT); + QSDOC_TYPE_OVERRIDE(ObjectModel*); + /// A list of all connected bluetooth devices across all adapters. + /// See @@BluetoothAdapter.devices for the devices connected to a single adapter. + Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + QML_NAMED_ELEMENT(Bluetooth); + QML_SINGLETON; + +public: + explicit BluezQml(QObject* parent = nullptr): QObject(parent) {} + + [[nodiscard]] static ObjectModel* adapters() { + return Bluez::instance()->adapters(); + } + + [[nodiscard]] static ObjectModel* devices() { + return Bluez::instance()->devices(); + } + + [[nodiscard]] static BluetoothAdapter* defaultAdapter() { + return Bluez::instance()->defaultAdapter(); + } +}; + +} // namespace qs::bluetooth diff --git a/src/bluetooth/device.cpp b/src/bluetooth/device.cpp new file mode 100644 index 00000000..30008a32 --- /dev/null +++ b/src/bluetooth/device.cpp @@ -0,0 +1,318 @@ +#include "device.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../dbus/properties.hpp" +#include "adapter.hpp" +#include "bluez.hpp" +#include "dbus_device.h" + +namespace qs::bluetooth { + +namespace { +Q_LOGGING_CATEGORY(logDevice, "quickshell.bluetooth.device", QtWarningMsg); +} + +QString BluetoothDeviceState::toString(BluetoothDeviceState::Enum state) { + switch (state) { + case BluetoothDeviceState::Disconnected: return QStringLiteral("Disconnected"); + case BluetoothDeviceState::Connected: return QStringLiteral("Connected"); + case BluetoothDeviceState::Disconnecting: return QStringLiteral("Disconnecting"); + case BluetoothDeviceState::Connecting: return QStringLiteral("Connecting"); + default: return QStringLiteral("Unknown"); + } +} + +BluetoothDevice::BluetoothDevice(const QString& path, QObject* parent): QObject(parent) { + this->mInterface = + new DBusBluezDeviceInterface("org.bluez", path, QDBusConnection::systemBus(), this); + + if (!this->mInterface->isValid()) { + qCWarning(logDevice) << "Could not create DBus interface for device at" << path; + delete this->mInterface; + this->mInterface = nullptr; + return; + } + + this->properties.setInterface(this->mInterface); +} + +BluetoothAdapter* BluetoothDevice::adapter() const { + return Bluez::instance()->adapter(this->bAdapterPath.value().path()); +} + +void BluetoothDevice::setConnected(bool connected) { + if (connected == this->bConnected) return; + + if (connected) { + this->connect(); + } else { + this->disconnect(); + } +} + +void BluetoothDevice::setTrusted(bool trusted) { + if (trusted == this->bTrusted) return; + this->bTrusted = trusted; + this->pTrusted.write(); +} + +void BluetoothDevice::setBlocked(bool blocked) { + if (blocked == this->bBlocked) return; + this->bBlocked = blocked; + this->pBlocked.write(); +} + +void BluetoothDevice::setName(const QString& name) { + if (name == this->bName) return; + this->bName = name; + this->pName.write(); +} + +void BluetoothDevice::setWakeAllowed(bool wakeAllowed) { + if (wakeAllowed == this->bWakeAllowed) return; + this->bWakeAllowed = wakeAllowed; + this->pWakeAllowed.write(); +} + +void BluetoothDevice::connect() { + if (this->bConnected) { + qCCritical(logDevice) << "Device" << this << "is already connected"; + return; + } + + if (this->bState == BluetoothDeviceState::Connecting) { + qCCritical(logDevice) << "Device" << this << "is already connecting"; + return; + } + + qCDebug(logDevice) << "Connecting to device" << this; + this->bState = BluetoothDeviceState::Connecting; + + auto reply = this->mInterface->Connect(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to connect to device " << this << ": " << reply.error().message(); + + this->bState = this->bConnected ? BluetoothDeviceState::Connected + : BluetoothDeviceState::Disconnected; + } else { + qCDebug(logDevice) << "Successfully connected to to device" << this; + } + + delete watcher; + } + ); +} + +void BluetoothDevice::disconnect() { + if (!this->bConnected) { + qCCritical(logDevice) << "Device" << this << "is already disconnected"; + return; + } + + if (this->bState == BluetoothDeviceState::Disconnecting) { + qCCritical(logDevice) << "Device" << this << "is already disconnecting"; + return; + } + + qCDebug(logDevice) << "Disconnecting from device" << this; + this->bState = BluetoothDeviceState::Disconnecting; + + auto reply = this->mInterface->Disconnect(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to disconnect from device " << this << ": " << reply.error().message(); + + this->bState = this->bConnected ? BluetoothDeviceState::Connected + : BluetoothDeviceState::Disconnected; + } else { + qCDebug(logDevice) << "Successfully disconnected from from device" << this; + } + + delete watcher; + } + ); +} + +void BluetoothDevice::pair() { + if (this->bPaired) { + qCCritical(logDevice) << "Device" << this << "is already paired"; + return; + } + + if (this->bPairing) { + qCCritical(logDevice) << "Device" << this << "is already pairing"; + return; + } + + qCDebug(logDevice) << "Pairing with device" << this; + this->bPairing = true; + + auto reply = this->mInterface->Pair(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to pair with device " << this << ": " << reply.error().message(); + } else { + qCDebug(logDevice) << "Successfully initiated pairing with device" << this; + } + + this->bPairing = false; + delete watcher; + } + ); +} + +void BluetoothDevice::cancelPair() { + if (!this->bPairing) { + qCCritical(logDevice) << "Device" << this << "is not currently pairing"; + return; + } + + qCDebug(logDevice) << "Cancelling pairing with device" << this; + + auto reply = this->mInterface->CancelPairing(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + if (reply.isError()) { + qCWarning(logDevice) << "Failed to cancel pairing with device" << this << ":" + << reply.error().message(); + } else { + qCDebug(logDevice) << "Successfully cancelled pairing with device" << this; + } + + this->bPairing = false; + delete watcher; + } + ); +} + +void BluetoothDevice::forget() { + if (!this->mInterface || !this->mInterface->isValid()) { + qCCritical(logDevice) << "Cannot forget - device interface is invalid"; + return; + } + + if (auto* adapter = Bluez::instance()->adapter(this->bAdapterPath.value().path())) { + qCDebug(logDevice) << "Forgetting device" << this << "via adapter" << adapter; + adapter->removeDevice(this->path()); + } else { + qCCritical(logDevice) << "Could not find adapter for path" << this->bAdapterPath.value().path() + << "to forget from"; + } +} + +void BluetoothDevice::addInterface(const QString& interface, const QVariantMap& properties) { + if (interface == "org.bluez.Device1") { + this->properties.updatePropertySet(properties, false); + qCDebug(logDevice) << "Updated Device properties for" << this; + } else if (interface == "org.bluez.Battery1") { + if (!this->mBatteryInterface) { + this->mBatteryInterface = new QDBusInterface( + "org.bluez", + this->path(), + "org.bluez.Battery1", + QDBusConnection::systemBus(), + this + ); + + if (!this->mBatteryInterface->isValid()) { + qCWarning(logDevice) << "Could not create Battery interface for device at" << this; + delete this->mBatteryInterface; + this->mBatteryInterface = nullptr; + return; + } + } + + this->batteryProperties.setInterface(this->mBatteryInterface); + this->batteryProperties.updatePropertySet(properties, false); + + emit this->batteryAvailableChanged(); + qCDebug(logDevice) << "Updated Battery properties for" << this; + } +} + +void BluetoothDevice::removeInterface(const QString& interface) { + if (interface == "org.bluez.Battery1" && this->mBatteryInterface) { + this->batteryProperties.setInterface(nullptr); + delete this->mBatteryInterface; + this->mBatteryInterface = nullptr; + this->bBattery = 0; + + emit this->batteryAvailableChanged(); + qCDebug(logDevice) << "Battery interface removed from device" << this; + } +} + +void BluetoothDevice::onConnectedChanged() { + this->bState = + this->bConnected ? BluetoothDeviceState::Connected : BluetoothDeviceState::Disconnected; + emit this->connectedChanged(); +} + +} // namespace qs::bluetooth + +namespace qs::dbus { + +using namespace qs::bluetooth; + +DBusResult DBusDataTransform::fromWire(quint8 percentage) { + return DBusResult(percentage * 0.01); +} + +} // namespace qs::dbus + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothDevice* device) { + auto saver = QDebugStateSaver(debug); + + if (device) { + debug.nospace() << "BluetoothDevice(" << static_cast(device) + << ", path=" << device->path() << ")"; + } else { + debug << "BluetoothDevice(nullptr)"; + } + + return debug; +} diff --git a/src/bluetooth/device.hpp b/src/bluetooth/device.hpp new file mode 100644 index 00000000..23f230f5 --- /dev/null +++ b/src/bluetooth/device.hpp @@ -0,0 +1,225 @@ +#pragma once + +#include +#include +#include +#include + +#include "../dbus/properties.hpp" +#include "dbus_device.h" + +namespace qs::bluetooth { + +///! Connection state of a Bluetooth device. +class BluetoothDeviceState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The device is not connected. + Disconnected = 0, + /// The device is connected. + Connected = 1, + /// The device is disconnecting. + Disconnecting = 2, + /// The device is connecting. + Connecting = 3, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(BluetoothDeviceState::Enum state); +}; + +struct BatteryPercentage {}; + +} // namespace qs::bluetooth + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = quint8; + using Data = qreal; + static DBusResult fromWire(Wire percentage); +}; + +} // namespace qs::dbus + +namespace qs::bluetooth { + +class BluetoothAdapter; + +///! A tracked Bluetooth device. +class BluetoothDevice: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// MAC address of the device. + Q_PROPERTY(QString address READ default NOTIFY addressChanged BINDABLE bindableAddress); + /// The name of the Bluetooth device. This property may be written to create an alias, or set to + /// an empty string to fall back to the device provided name. + /// + /// See @@deviceName for the name provided by the device. + Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged); + /// The name of the Bluetooth device, ignoring user provided aliases. See also @@name + /// which returns a user provided alias if set. + Q_PROPERTY(QString deviceName READ default NOTIFY deviceNameChanged BINDABLE bindableDeviceName); + /// System icon representing the device type. Use @@Quickshell.Quickshell.iconPath() to display this in an image. + Q_PROPERTY(QString icon READ default NOTIFY iconChanged BINDABLE bindableIcon); + /// Connection state of the device. + Q_PROPERTY(BluetoothDeviceState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// True if the device is currently connected to the computer. + /// + /// Setting this property is equivalent to calling @@connect() and @@disconnect(). + /// + /// > [!NOTE] @@state provides more detailed information if required. + Q_PROPERTY(bool connected READ connected WRITE setConnected NOTIFY connectedChanged); + /// True if the device is paired to the computer. + /// + /// > [!NOTE] @@pair() can be used to pair a device, however you must @@forget() the device to unpair it. + Q_PROPERTY(bool paired READ default NOTIFY pairedChanged BINDABLE bindablePaired); + /// True if pairing information is stored for future connections. + Q_PROPERTY(bool bonded READ default NOTIFY bondedChanged BINDABLE bindableBonded); + /// True if the device is currently being paired. + /// + /// > [!NOTE] @@cancelPair() can be used to cancel the pairing process. + Q_PROPERTY(bool pairing READ pairing NOTIFY pairingChanged); + /// True if the device is considered to be trusted by the system. + /// Trusted devices are allowed to reconnect themselves to the system without intervention. + Q_PROPERTY(bool trusted READ trusted WRITE setTrusted NOTIFY trustedChanged); + /// True if the device is blocked from connecting. + /// If a device is blocked, any connection attempts will be immediately rejected by the system. + Q_PROPERTY(bool blocked READ blocked WRITE setBlocked NOTIFY blockedChanged); + /// True if the device is allowed to wake up the host system from suspend. + Q_PROPERTY(bool wakeAllowed READ wakeAllowed WRITE setWakeAllowed NOTIFY wakeAllowedChanged); + /// True if the connected device reports its battery level. Battery level can be accessed via @@battery. + Q_PROPERTY(bool batteryAvailable READ batteryAvailable NOTIFY batteryAvailableChanged); + /// Battery level of the connected device, from `0.0` to `1.0`. Only valid if @@batteryAvailable is true. + Q_PROPERTY(qreal battery READ default NOTIFY batteryChanged BINDABLE bindableBattery); + /// The Bluetooth adapter this device belongs to. + Q_PROPERTY(BluetoothAdapter* adapter READ adapter NOTIFY adapterChanged); + /// DBus path of the device under the `org.bluez` system service. + Q_PROPERTY(QString dbusPath READ path CONSTANT); + // clang-format on + +public: + explicit BluetoothDevice(const QString& path, QObject* parent = nullptr); + + /// Attempt to connect to the device. + Q_INVOKABLE void connect(); + /// Disconnect from the device. + Q_INVOKABLE void disconnect(); + /// Attempt to pair the device. + /// + /// > [!NOTE] @@paired and @@pairing return the current pairing status of the device. + Q_INVOKABLE void pair(); + /// Cancel an active pairing attempt. + Q_INVOKABLE void cancelPair(); + /// Forget the device. + Q_INVOKABLE void forget(); + + [[nodiscard]] bool isValid() const { return this->mInterface && this->mInterface->isValid(); } + [[nodiscard]] QString path() const { + return this->mInterface ? this->mInterface->path() : QString(); + } + + [[nodiscard]] bool batteryAvailable() const { return this->mBatteryInterface != nullptr; } + [[nodiscard]] BluetoothAdapter* adapter() const; + [[nodiscard]] QDBusObjectPath adapterPath() const { return this->bAdapterPath.value(); } + + [[nodiscard]] bool connected() const { return this->bConnected; } + void setConnected(bool connected); + + [[nodiscard]] bool trusted() const { return this->bTrusted; } + void setTrusted(bool trusted); + + [[nodiscard]] bool blocked() const { return this->bBlocked; } + void setBlocked(bool blocked); + + [[nodiscard]] QString name() const { return this->bName; } + void setName(const QString& name); + + [[nodiscard]] bool wakeAllowed() const { return this->bWakeAllowed; } + void setWakeAllowed(bool wakeAllowed); + + [[nodiscard]] bool pairing() const { return this->bPairing; } + + [[nodiscard]] QBindable bindableAddress() { return &this->bAddress; } + [[nodiscard]] QBindable bindableDeviceName() { return &this->bDeviceName; } + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableConnected() { return &this->bConnected; } + [[nodiscard]] QBindable bindablePaired() { return &this->bPaired; } + [[nodiscard]] QBindable bindableBonded() { return &this->bBonded; } + [[nodiscard]] QBindable bindableTrusted() { return &this->bTrusted; } + [[nodiscard]] QBindable bindableBlocked() { return &this->bBlocked; } + [[nodiscard]] QBindable bindableWakeAllowed() { return &this->bWakeAllowed; } + [[nodiscard]] QBindable bindableIcon() { return &this->bIcon; } + [[nodiscard]] QBindable bindableBattery() { return &this->bBattery; } + [[nodiscard]] QBindable bindableState() { return &this->bState; } + + void addInterface(const QString& interface, const QVariantMap& properties); + void removeInterface(const QString& interface); + +signals: + void addressChanged(); + void deviceNameChanged(); + void nameChanged(); + void connectedChanged(); + void stateChanged(); + void pairedChanged(); + void bondedChanged(); + void pairingChanged(); + void trustedChanged(); + void blockedChanged(); + void wakeAllowedChanged(); + void iconChanged(); + void batteryAvailableChanged(); + void batteryChanged(); + void adapterChanged(); + +private: + void onConnectedChanged(); + + DBusBluezDeviceInterface* mInterface = nullptr; + QDBusInterface* mBatteryInterface = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bAddress, &BluetoothDevice::addressChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bDeviceName, &BluetoothDevice::deviceNameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bName, &BluetoothDevice::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bConnected, &BluetoothDevice::onConnectedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bPaired, &BluetoothDevice::pairedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bBonded, &BluetoothDevice::bondedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bTrusted, &BluetoothDevice::trustedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bBlocked, &BluetoothDevice::blockedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bWakeAllowed, &BluetoothDevice::wakeAllowedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bIcon, &BluetoothDevice::iconChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QDBusObjectPath, bAdapterPath); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, qreal, bBattery, &BluetoothDevice::batteryChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, BluetoothDeviceState::Enum, bState, &BluetoothDevice::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bPairing, &BluetoothDevice::pairingChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothDevice, properties); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pAddress, bAddress, properties, "Address"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pDeviceName, bDeviceName, properties, "Name"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pName, bName, properties, "Alias"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pConnected, bConnected, properties, "Connected"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pPaired, bPaired, properties, "Paired"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pBonded, bBonded, properties, "Bonded"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pTrusted, bTrusted, properties, "Trusted"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pBlocked, bBlocked, properties, "Blocked"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pWakeAllowed, bWakeAllowed, properties, "WakeAllowed"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pIcon, bIcon, properties, "Icon"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pAdapterPath, bAdapterPath, properties, "Adapter"); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothDevice, batteryProperties); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, BatteryPercentage, pBattery, bBattery, batteryProperties, "Percentage", true); + // clang-format on +}; + +} // namespace qs::bluetooth + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothDevice* device); diff --git a/src/bluetooth/module.md b/src/bluetooth/module.md new file mode 100644 index 00000000..eb797d93 --- /dev/null +++ b/src/bluetooth/module.md @@ -0,0 +1,12 @@ +name = "Quickshell.Bluetooth" +description = "Bluetooth API" +headers = [ + "bluez.hpp", + "adapter.hpp", + "device.hpp", +] +----- +This module exposes Bluetooth management APIs provided by the BlueZ DBus interface. +Both DBus and BlueZ must be running to use it. + +See the @@Quickshell.Bluetooth.Bluetooth singleton. diff --git a/src/bluetooth/org.bluez.Adapter.xml b/src/bluetooth/org.bluez.Adapter.xml new file mode 100644 index 00000000..286991e1 --- /dev/null +++ b/src/bluetooth/org.bluez.Adapter.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/bluetooth/org.bluez.Device.xml b/src/bluetooth/org.bluez.Device.xml new file mode 100644 index 00000000..274e9fde --- /dev/null +++ b/src/bluetooth/org.bluez.Device.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/bluetooth/test/manual/test.qml b/src/bluetooth/test/manual/test.qml new file mode 100644 index 00000000..21c53b1d --- /dev/null +++ b/src/bluetooth/test/manual/test.qml @@ -0,0 +1,200 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Bluetooth + +FloatingWindow { + color: contentItem.palette.window + + ListView { + anchors.fill: parent + anchors.margins: 5 + model: Bluetooth.adapters + + delegate: WrapperRectangle { + width: parent.width + color: "transparent" + border.color: palette.button + border.width: 1 + margin: 5 + + ColumnLayout { + Label { text: `Adapter: ${modelData.name} (${modelData.adapterId})` } + + RowLayout { + Layout.fillWidth: true + + CheckBox { + text: "Enable" + checked: modelData.enabled + onToggled: modelData.enabled = checked + } + + Label { + color: modelData.state === BluetoothAdapterState.Blocked ? palette.errorText : palette.placeholderText + text: BluetoothAdapterState.toString(modelData.state) + } + + CheckBox { + text: "Discoverable" + checked: modelData.discoverable + onToggled: modelData.discoverable = checked + } + + CheckBox { + text: "Discovering" + checked: modelData.discovering + onToggled: modelData.discovering = checked + } + + CheckBox { + text: "Pairable" + checked: modelData.pairable + onToggled: modelData.pairable = checked + } + } + + RowLayout { + Layout.fillWidth: true + + Label { text: "Discoverable timeout:" } + + SpinBox { + from: 0 + to: 3600 + value: modelData.discoverableTimeout + onValueModified: modelData.discoverableTimeout = value + textFromValue: time => time === 0 ? "∞" : time + "s" + } + + Label { text: "Pairable timeout:" } + + SpinBox { + from: 0 + to: 3600 + value: modelData.pairableTimeout + onValueModified: modelData.pairableTimeout = value + textFromValue: time => time === 0 ? "∞" : time + "s" + } + } + + Repeater { + model: modelData.devices + + WrapperRectangle { + Layout.fillWidth: true + color: palette.button + border.color: palette.mid + border.width: 1 + margin: 5 + + RowLayout { + ColumnLayout { + Layout.fillWidth: true + + RowLayout { + IconImage { + Layout.fillHeight: true + implicitWidth: height + source: Quickshell.iconPath(modelData.icon) + } + + TextField { + text: modelData.name + font.bold: true + background: null + readOnly: false + selectByMouse: true + onEditingFinished: modelData.name = text + } + + Label { + visible: modelData.name && modelData.name !== modelData.deviceName + text: `(${modelData.deviceName})` + color: palette.placeholderText + } + } + + RowLayout { + Label { + text: modelData.address + color: palette.placeholderText + } + + Label { + visible: modelData.batteryAvailable + text: `| Battery: ${Math.round(modelData.battery * 100)}%` + color: palette.placeholderText + } + } + + RowLayout { + Label { + text: BluetoothDeviceState.toString(modelData.state) + + color: modelData.connected ? palette.link : palette.placeholderText + } + + Label { + text: modelData.pairing ? "Pairing" : (modelData.paired ? "Paired" : "Not Paired") + color: modelData.paired || modelData.pairing ? palette.link : palette.placeholderText + } + + Label { + visible: modelData.bonded + text: "| Bonded" + color: palette.link + } + + CheckBox { + text: "Trusted" + checked: modelData.trusted + onToggled: modelData.trusted = checked + } + + CheckBox { + text: "Blocked" + checked: modelData.blocked + onToggled: modelData.blocked = checked + } + + CheckBox { + text: "Wake Allowed" + checked: modelData.wakeAllowed + onToggled: modelData.wakeAllowed = checked + } + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignRight + + Button { + Layout.alignment: Qt.AlignRight + text: modelData.connected ? "Disconnect" : "Connect" + onClicked: modelData.connected = !modelData.connected + } + + Button { + Layout.alignment: Qt.AlignRight + text: modelData.pairing ? "Cancel" : (modelData.paired ? "Forget" : "Pair") + onClicked: { + if (modelData.pairing) { + modelData.cancelPair(); + } else if (modelData.paired) { + modelData.forget(); + } else { + modelData.pair(); + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/dbus/CMakeLists.txt b/src/dbus/CMakeLists.txt index 9948ea74..fc004f3d 100644 --- a/src/dbus/CMakeLists.txt +++ b/src/dbus/CMakeLists.txt @@ -2,13 +2,24 @@ set_source_files_properties(org.freedesktop.DBus.Properties.xml PROPERTIES CLASSNAME DBusPropertiesInterface ) +set_source_files_properties(org.freedesktop.DBus.ObjectManager.xml PROPERTIES + CLASSNAME DBusObjectManagerInterface + INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/dbus_objectmanager_types.hpp +) + qt_add_dbus_interface(DBUS_INTERFACES org.freedesktop.DBus.Properties.xml dbus_properties ) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.DBus.ObjectManager.xml + dbus_objectmanager +) + qt_add_library(quickshell-dbus STATIC properties.cpp + objectmanager.cpp bus.cpp ${DBUS_INTERFACES} ) diff --git a/src/dbus/dbus_objectmanager_types.hpp b/src/dbus/dbus_objectmanager_types.hpp new file mode 100644 index 00000000..5e0869c1 --- /dev/null +++ b/src/dbus/dbus_objectmanager_types.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include +#include +#include + +using DBusObjectManagerInterfaces = QHash; +using DBusObjectManagerObjects = QHash; diff --git a/src/dbus/objectmanager.cpp b/src/dbus/objectmanager.cpp new file mode 100644 index 00000000..d7acb74a --- /dev/null +++ b/src/dbus/objectmanager.cpp @@ -0,0 +1,86 @@ +#include "objectmanager.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dbus_objectmanager.h" +#include "dbus_objectmanager_types.hpp" + +namespace { +Q_LOGGING_CATEGORY(logDbusObjectManager, "quickshell.dbus.objectmanager", QtWarningMsg); +} + +namespace qs::dbus { + +DBusObjectManager::DBusObjectManager(QObject* parent): QObject(parent) { + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); +} + +bool DBusObjectManager::setInterface( + const QString& service, + const QString& path, + const QDBusConnection& connection +) { + delete this->mInterface; + this->mInterface = new DBusObjectManagerInterface(service, path, connection, this); + + if (!this->mInterface->isValid()) { + qCWarning(logDbusObjectManager) << "Failed to create DBusObjectManagerInterface for" << service + << path << ":" << this->mInterface->lastError(); + delete this->mInterface; + this->mInterface = nullptr; + return false; + } + + QObject::connect( + this->mInterface, + &DBusObjectManagerInterface::InterfacesAdded, + this, + &DBusObjectManager::interfacesAdded + ); + + QObject::connect( + this->mInterface, + &DBusObjectManagerInterface::InterfacesRemoved, + this, + &DBusObjectManager::interfacesRemoved + ); + + this->fetchInitialObjects(); + return true; +} + +void DBusObjectManager::fetchInitialObjects() { + if (!this->mInterface) return; + + auto reply = this->mInterface->GetManagedObjects(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + + if (reply.isError()) { + qCWarning(logDbusObjectManager) << "Failed to get managed objects:" << reply.error(); + return; + } + + for (const auto& [path, interfaces]: reply.value().asKeyValueRange()) { + emit this->interfacesAdded(path, interfaces); + } + } + ); +} + +} // namespace qs::dbus diff --git a/src/dbus/objectmanager.hpp b/src/dbus/objectmanager.hpp new file mode 100644 index 00000000..4246ea28 --- /dev/null +++ b/src/dbus/objectmanager.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include "dbus_objectmanager_types.hpp" + +class DBusObjectManagerInterface; + +namespace qs::dbus { + +class DBusObjectManager: public QObject { + Q_OBJECT; + +public: + explicit DBusObjectManager(QObject* parent = nullptr); + + bool setInterface( + const QString& service, + const QString& path, + const QDBusConnection& connection = QDBusConnection::sessionBus() + ); + +signals: + void + interfacesAdded(const QDBusObjectPath& objectPath, const DBusObjectManagerInterfaces& interfaces); + void interfacesRemoved(const QDBusObjectPath& objectPath, const QStringList& interfaces); + +private: + void fetchInitialObjects(); + + DBusObjectManagerInterface* mInterface = nullptr; +}; + +} // namespace qs::dbus \ No newline at end of file diff --git a/src/dbus/org.freedesktop.DBus.ObjectManager.xml b/src/dbus/org.freedesktop.DBus.ObjectManager.xml new file mode 100644 index 00000000..24749f22 --- /dev/null +++ b/src/dbus/org.freedesktop.DBus.ObjectManager.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/dbus/properties.cpp b/src/dbus/properties.cpp index 52f50060..46528d52 100644 --- a/src/dbus/properties.cpp +++ b/src/dbus/properties.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include "dbus_properties.h" @@ -326,3 +327,10 @@ void DBusPropertyGroup::onPropertiesChanged( } } // namespace qs::dbus + +#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) +QDebug operator<<(QDebug debug, const QDBusObjectPath& path) { + debug.nospace() << "QDBusObjectPath(" << path.path() << ")"; + return debug; +} +#endif diff --git a/src/dbus/properties.hpp b/src/dbus/properties.hpp index 5c26a194..9cfaee98 100644 --- a/src/dbus/properties.hpp +++ b/src/dbus/properties.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include "../core/util.hpp" @@ -234,6 +235,7 @@ public: void attachProperty(DBusPropertyCore* property); void updateAllDirect(); void updateAllViaGetAll(); + void updatePropertySet(const QVariantMap& properties, bool complainMissing = true); [[nodiscard]] QString toString() const; [[nodiscard]] bool isConnected() const { return this->interface; } @@ -252,7 +254,6 @@ private slots: ); private: - void updatePropertySet(const QVariantMap& properties, bool complainMissing); void tryUpdateProperty(DBusPropertyCore* property, const QVariant& variant) const; [[nodiscard]] QString propertyString(const DBusPropertyCore* property) const; @@ -265,6 +266,10 @@ private: } // namespace qs::dbus +#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) +QDebug operator<<(QDebug debug, const QDBusObjectPath& path); +#endif + // NOLINTBEGIN #define QS_DBUS_BINDABLE_PROPERTY_GROUP(Class, name) qs::dbus::DBusPropertyGroup name {this}; diff --git a/src/services/status_notifier/dbus_item_types.cpp b/src/services/status_notifier/dbus_item_types.cpp index c751ca26..6678e943 100644 --- a/src/services/status_notifier/dbus_item_types.cpp +++ b/src/services/status_notifier/dbus_item_types.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include bool DBusSniIconPixmap::operator==(const DBusSniIconPixmap& other) const { @@ -122,10 +121,3 @@ QDebug operator<<(QDebug debug, const DBusSniTooltip& tooltip) { return debug; } - -#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) -QDebug operator<<(QDebug debug, const QDBusObjectPath& path) { - debug.nospace() << "QDBusObjectPath(" << path.path() << ")"; - return debug; -} -#endif diff --git a/src/services/status_notifier/dbus_item_types.hpp b/src/services/status_notifier/dbus_item_types.hpp index e81a2acc..cef38f36 100644 --- a/src/services/status_notifier/dbus_item_types.hpp +++ b/src/services/status_notifier/dbus_item_types.hpp @@ -35,7 +35,3 @@ const QDBusArgument& operator<<(QDBusArgument& argument, const DBusSniTooltip& t QDebug operator<<(QDebug debug, const DBusSniIconPixmap& pixmap); QDebug operator<<(QDebug debug, const DBusSniTooltip& tooltip); - -#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) -QDebug operator<<(QDebug debug, const QDBusObjectPath& path); -#endif