From ef35cb6f65874379e3496aea6ed29537d07afe72 Mon Sep 17 00:00:00 2001 From: Joshua Goins Date: Wed, 30 Oct 2024 22:37:28 -0400 Subject: [PATCH] Add initial files --- .gitignore | 10 +++ CMakeLists.txt | 36 +++++++++ README.md | 7 ++ deskcontrol.cpp | 146 +++++++++++++++++++++++++++++++++++ deskcontrol.h | 50 ++++++++++++ package/contents/desk.svg | 66 ++++++++++++++++ package/contents/ui/main.qml | 64 +++++++++++++++ package/metadata.json | 20 +++++ 8 files changed, 399 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 deskcontrol.cpp create mode 100644 deskcontrol.h create mode 100644 package/contents/desk.svg create mode 100644 package/contents/ui/main.qml create mode 100644 package/metadata.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c4f1f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: None +# SPDX-License-Identifier: CC0-1.0 + +.clang-format +/build*/ +CMakeLists.txt.user +compile_commands.json +.cache +src/resources.generated.qrc +.flatpak-builder/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0fedad5 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.16) +project(desk_applet) + +set(QT_MIN_VERSION "6.5.0") +set(KF_MIN_VERSION "6.0.0") + +find_package(ECM ${KF_MIN_VERSION} REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) + +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) +include(FeatureSummary) + +find_package(KF6 ${KF6_MIN_VERSION} REQUIRED COMPONENTS + I18n + Config +) +find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS + Quick + Core + Bluetooth +) +find_package(Plasma REQUIRED) + +add_library(com.redstrate.desk-control MODULE deskcontrol.cpp) + +target_link_libraries(com.redstrate.desk-control + Qt6::Gui + Qt6::Bluetooth + Plasma::Plasma + KF6::I18n) + +install(TARGETS com.redstrate.desk-control DESTINATION ${KDE_INSTALL_PLUGINDIR}/plasma/applets) + +plasma_install_package(package com.redstrate.desk-control) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..948ca07 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Tisch + +This is an applet to control your [IKEA IDÅSEN](https://www.ikea.com/us/en/p/idasen-desk-sit-stand-black-dark-gray-s79280998/) desk. Still WIP but does work. + +## Credits + +* [Stephen Lineker-Miller's ideasen-desk-lib](https://github.com/stephen-slm/idasen-desk-lib) for protocol reference. diff --git a/deskcontrol.cpp b/deskcontrol.cpp new file mode 100644 index 0000000..7217f9f --- /dev/null +++ b/deskcontrol.cpp @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2024 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "deskcontrol.h" + +#include +#include + +const auto DESK_UUID_KEY = QStringLiteral("DeskUUID"); + +const auto ADV_SVC = QBluetoothUuid("99fa0001-338a-1024-8a49-009c0215f78a"); + +const auto COMMAND_CHARACTERISTIC = QBluetoothUuid("99fa0002-338a-1024-8a49-009c0215f78a"); +const auto POSITION_CHARACTERISTIC = QBluetoothUuid("99fa0021-338a-1024-8a49-009c0215f78a"); + +const auto upCommand = QByteArray::fromHex("4700"); +const auto downCommand = QByteArray::fromHex("4600"); + +constexpr auto MAX_HEIGHT = 1.27; +constexpr auto MIN_HEIGHT = 0.62; + +DeskControl::DeskControl(QObject *parent, const KPluginMetaData &data, const QVariantList &args) + : Applet(parent, data, args) { + m_localDevice.powerOn(); + // If we have connected to the desk previously, connect. Otherwise discover one. + if (config().hasKey(DESK_UUID_KEY)) { + connectToDesk(); + } else { + searchForDesk(); + } +} + +DeskControl::~DeskControl() = default; + +DeskControl::DeskState DeskControl::state() const { + return DeskState::NotFound; +} + +void DeskControl::up() const { + Q_ASSERT(m_commandService); + Q_ASSERT(m_commandCharacteristic.isValid()); + + m_commandService->writeCharacteristic(m_commandCharacteristic, upCommand); +} + +void DeskControl::down() const { + Q_ASSERT(m_commandService); + Q_ASSERT(m_commandCharacteristic.isValid()); + + m_commandService->writeCharacteristic(m_commandCharacteristic, downCommand); +} + +void DeskControl::connectToDesk() { + const auto lastDeskUUID = config().readEntry(DESK_UUID_KEY); + qInfo() << "Connecting to desk:" << lastDeskUUID; + + m_controller = QLowEnergyController::createCentral(QBluetoothDeviceInfo(QBluetoothAddress(lastDeskUUID), QStringLiteral("Desk"), 0)); + + connect(m_controller, &QLowEnergyController::connected, this, [this]() { + m_controller->discoverServices(); + }); + connect(m_controller, &QLowEnergyController::discoveryFinished, this, [this]() { + for (auto service: m_controller->services()) { + if (service.toString() == QStringLiteral("{99fa0001-338a-1024-8a49-009c0215f78a}")) { + m_commandService = m_controller->createServiceObject(service); + m_commandService->discoverDetails(); + + connect(m_commandService, &QLowEnergyService::stateChanged, this, [this](QLowEnergyService::ServiceState state) { + m_commandCharacteristic = m_commandService->characteristic(COMMAND_CHARACTERISTIC); + }); + connect(m_commandService, &QLowEnergyService::errorOccurred, [](QLowEnergyService::ServiceError error) { + qWarning() << error; + }); + } + if (service.toString() == QStringLiteral("{99fa0020-338a-1024-8a49-009c0215f78a}")) { + m_positionCommandService = m_controller->createServiceObject(service); + m_positionCommandService->discoverDetails(); + + connect(m_positionCommandService, &QLowEnergyService::errorOccurred, [](QLowEnergyService::ServiceError error) { + qWarning() << error; + }); + connect(m_positionCommandService, &QLowEnergyService::stateChanged, this, [this](QLowEnergyService::ServiceState state) { + QLowEnergyCharacteristic batteryLevel = m_positionCommandService->characteristic(POSITION_CHARACTERISTIC); + if (!batteryLevel.isValid()) + return; + + QLowEnergyDescriptor notification = batteryLevel.descriptor( + QBluetoothUuid::DescriptorType::ClientCharacteristicConfiguration); + if (!notification.isValid()) + return; + + m_positionCommandService->writeDescriptor(notification, QByteArray::fromHex("0100")); + }); + + connect(m_positionCommandService, &QLowEnergyService::characteristicChanged, this, [this](const QLowEnergyCharacteristic &info, const QByteArray &value) { + const int32_t highByte = value.at(1); + const int32_t lowByte = value.at(0); + const int32_t number = (highByte << 8) + lowByte; + + m_height = (static_cast(number) / 1000.0) + MIN_HEIGHT; + Q_EMIT heightChanged(); + }); + } + } + }); + + m_controller->connectToDevice(); +} + +void DeskControl::searchForDesk() { + qInfo() << "Searching for desk..."; + + QList remotes = m_localDevice.connectedDevices(); + if (remotes.empty()) { + return; + } + + for (const auto &address : remotes) { + auto controller = QLowEnergyController::createCentral(QBluetoothDeviceInfo(address, QStringLiteral("Desk"), 0)); + + qInfo() << controller->services(); + + connect(controller, &QLowEnergyController::connected, this, [this, controller]() { + controller->discoverServices(); + }); + connect(controller, &QLowEnergyController::discoveryFinished, this, [this, controller, address]() { + controller->deleteLater(); + + for (auto service: controller->services()) { + if (service == ADV_SVC) { + qInfo() << "Found desk:" << DESK_UUID_KEY; + config().writeEntry(DESK_UUID_KEY, address.toString()); + connectToDesk(); + break; + } + } + }); + + controller->connectToDevice(); + } +} + +K_PLUGIN_CLASS(DeskControl) + +#include "deskcontrol.moc" +#include "moc_deskcontrol.cpp" diff --git a/deskcontrol.h b/deskcontrol.h new file mode 100644 index 0000000..a5ad25c --- /dev/null +++ b/deskcontrol.h @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2024 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include + +#include +#include +#include + +class DeskControl : public Plasma::Applet +{ + Q_OBJECT + Q_PROPERTY(DeskState state READ state NOTIFY stateChanged) + Q_PROPERTY(qreal height MEMBER m_height NOTIFY heightChanged) + +public: + explicit DeskControl(QObject *parent, const KPluginMetaData &data, const QVariantList &args); + ~DeskControl() override; + + enum DeskState { + NotFound, + Searching, + Connected + }; + Q_ENUM(DeskState) + + DeskState state() const; + +public Q_SLOTS: + void up() const; + void down() const; + void connectToDesk(); + +Q_SIGNALS: + void stateChanged(); + void heightChanged(); + +private: + void searchForDesk(); + + QLowEnergyController *m_controller = nullptr; + QLowEnergyService *m_commandService = nullptr; + QLowEnergyService *m_positionCommandService = nullptr; + qreal m_height = 0.0; + QLowEnergyCharacteristic m_commandCharacteristic; + QBluetoothDeviceDiscoveryAgent * m_discoveryAgent = nullptr; + QBluetoothLocalDevice m_localDevice; +}; diff --git a/package/contents/desk.svg b/package/contents/desk.svg new file mode 100644 index 0000000..bf5164e --- /dev/null +++ b/package/contents/desk.svg @@ -0,0 +1,66 @@ + + + + + + + + + + diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml new file mode 100644 index 0000000..30b47b8 --- /dev/null +++ b/package/contents/ui/main.qml @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2024 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.plasmoid +import org.kde.plasma.components as PlasmaComponents3 +import org.kde.kirigami as Kirigami + +PlasmoidItem { + fullRepresentation: StackLayout { + anchors.fill: parent + + currentIndex: Plasmoid.state + + // No desk discovered + Item { + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - (Kirigami.Units.largeSpacing * 4) + + text: "No Desk Connected" + explanation: "Manually connect to the desk in the Bluetooth applet." + icon.name: "network-bluetooth-inactive-symbolic" + + helpfulAction: Kirigami.Action { + icon.name: "view-refresh-symbolic" + text: "Refresh" + onTriggered: Plasmoid.connectToDesk() + } + } + } + + // Searching for desk + Item { + Kirigami.LoadingPlaceholder { + anchors.centerIn: parent + width: parent.width - (Kirigami.Units.largeSpacing * 4) + } + } + + // Normal + ColumnLayout { + Image { + source: "../desk.svg" + } + PlasmaComponents3.Label { + text: Plasmoid.height + } + PlasmaComponents3.Button { + text: "Up" + icon.name: "arrow-up-symbolic" + autoRepeat: true + onClicked: Plasmoid.up() + } + PlasmaComponents3.Button { + text: "Down" + icon.name: "arrow-down-symbolic" + autoRepeat: true + onClicked: Plasmoid.down() + } + } + } +} diff --git a/package/metadata.json b/package/metadata.json new file mode 100644 index 0000000..2fab9f0 --- /dev/null +++ b/package/metadata.json @@ -0,0 +1,20 @@ +{ + "KPackageStructure": "Plasma/Applet", + "X-Plasma-API-Minimum-Version": "6.0", + "KPlugin": { + "Authors": [ + { + "Email": "josh@redstrate.com", + "Name": "Joshua Goins" + } + ], + "Category": "Utilities", + "Description": "what your app does in a few words", + "Icon": "applications-system", + "Id": "com.redstrate.desk-control", + "License": "LGPL-2.1+", + "Name": "Desk", + "Version": "1.0", + "Website": "https://plasma.kde.org/" + } +}