From 420dff0fc58defae859a936131039a9f365f0799 Mon Sep 17 00:00:00 2001 From: Arif Hasanic Date: Tue, 4 Aug 2026 21:01:38 +0200 Subject: [PATCH] mouse baterry --- CMakeLists.txt | 3 + include/bar/bar.hpp | 2 + include/components/types/icon.hpp | 12 +- include/services/hidppBattery.hpp | 64 +++ include/services/mouseBatteryService.hpp | 57 +++ include/widgets/mouseBattery.hpp | 24 ++ resources/bar.css | 24 ++ src/app.cpp | 2 + src/bar/bar.cpp | 1 + src/services/hidppBattery.cpp | 519 +++++++++++++++++++++++ src/services/mouseBatteryService.cpp | 87 ++++ src/widgets/mouseBattery.cpp | 98 +++++ 12 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 include/services/hidppBattery.hpp create mode 100644 include/services/mouseBatteryService.hpp create mode 100644 include/widgets/mouseBattery.hpp create mode 100644 src/services/hidppBattery.cpp create mode 100644 src/services/mouseBatteryService.cpp create mode 100644 src/widgets/mouseBattery.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f16f80..802b59e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,7 @@ target_sources(bar_lib src/widgets/controlCenter/mediaWidget.cpp src/widgets/controlCenter/timer.cpp src/widgets/volumeWidget.cpp + src/widgets/mouseBattery.cpp src/widgets/weather.cpp src/widgets/webWidget.cpp src/widgets/tray.cpp @@ -70,6 +71,8 @@ target_sources(bar_lib src/services/hyprland.cpp src/services/notificationController.cpp src/services/textureCache.cpp + src/services/hidppBattery.cpp + src/services/mouseBatteryService.cpp src/components/popover.cpp src/components/workspaceIndicator.cpp diff --git a/include/bar/bar.hpp b/include/bar/bar.hpp index bc5b9b3..2c9321c 100644 --- a/include/bar/bar.hpp +++ b/include/bar/bar.hpp @@ -8,6 +8,7 @@ #include "widgets/clock.hpp" #include "widgets/controlCenter/controlCenter.hpp" #include "widgets/date.hpp" +#include "widgets/mouseBattery.hpp" #include "widgets/tray.hpp" #include "widgets/volumeWidget.hpp" #include "widgets/webWidget.hpp" @@ -28,6 +29,7 @@ class Bar : public Gtk::Window { Clock clock; Date date; + MouseBatteryWidget mouseBattery; WebWidget homeAssistant{Icon::HOME_ASSISTANT, "Home Assistant", "https://home.rivercry.com"}; ControlCenter controlCenter{Icon::MENU, "Control Center"}; diff --git a/include/components/types/icon.hpp b/include/components/types/icon.hpp index bd3f839..b6e3b38 100644 --- a/include/components/types/icon.hpp +++ b/include/components/types/icon.hpp @@ -36,6 +36,11 @@ class Icon { DONE_ALL, REMOVE_DONE, + + BATTERY_FULL, + BATTERY_ALERT, + BATTERY_CHARGING_FULL, + BATTERY_UNKNOWN, }; static const std::string toString(Type type) { @@ -71,8 +76,13 @@ class Icon { {VERIFIED, "\uef76"}, {VERIFIED_OFF, "\uf30e"}, - + {DONE_ALL, "\ue877"}, {REMOVE_DONE, "\ue9d3"}, + + {BATTERY_FULL, "\ue1a5"}, + {BATTERY_ALERT, "\ue19c"}, + {BATTERY_CHARGING_FULL, "\ue1a3"}, + {BATTERY_UNKNOWN, "\ue1a6"}, }; }; \ No newline at end of file diff --git a/include/services/hidppBattery.hpp b/include/services/hidppBattery.hpp new file mode 100644 index 0000000..d112ec4 --- /dev/null +++ b/include/services/hidppBattery.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +namespace hidpp { + +/// Battery state of a wireless Logitech device. +struct BatteryStatus { + enum class Level : uint8_t { UNKNOWN, + CRITICAL, + LOW, + GOOD, + FULL }; + + bool connected = false; + int percentage = -1; // 0..100, or -1 when the device only reports a level + Level level = Level::UNKNOWN; + bool charging = false; + bool externalPower = false; +}; + +/// Reads the battery level of a Logitech HID++ 2.0 device. +/// +/// Prefers the power_supply entry that hid-logitech-hidpp exports, and falls +/// back to speaking HID++ 2.0 over /dev/hidraw* when that driver is not bound +/// (the receiver then sits on hid-generic and exports no battery at all). +/// +/// read() blocks on wireless round-trips that can take seconds when the device +/// is asleep, so it must be called off the GTK main thread. +class BatteryReader { + public: + explicit BatteryReader(std::string deviceName); + ~BatteryReader(); + + BatteryReader(const BatteryReader &) = delete; + BatteryReader &operator=(const BatteryReader &) = delete; + + BatteryStatus read(); + + private: + /// A resolved HID++ endpoint: which hidraw node, which device slot on it, + /// and the feature index the battery lives behind. + struct Endpoint { + int fd = -1; + uint8_t deviceIndex = 0; + uint8_t featureIdx = 0; + uint16_t featureId = 0; // kFeatureUnifiedBattery or kFeatureBatteryStatus + }; + + std::string deviceName; + Endpoint endpoint{}; + + std::optional readFromSysfs() const; + std::optional readFromHidpp(); + + /// Finds the hidraw node and device slot our device answers on. Expensive: + /// only called on first use and after the cached endpoint stops replying. + bool resolveEndpoint(); + void closeEndpoint(); +}; + +} // namespace hidpp diff --git a/include/services/mouseBatteryService.hpp b/include/services/mouseBatteryService.hpp new file mode 100644 index 0000000..0b3d294 --- /dev/null +++ b/include/services/mouseBatteryService.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "services/hidppBattery.hpp" + +/// Polls the mouse battery once for the whole application. +/// +/// The bar builds one window per monitor, so the widget cannot own the polling +/// itself without every monitor opening its own HID++ session against the same +/// receiver and competing for it. This service keeps a single worker thread and +/// fans the result out to every widget through updated(). +class MouseBatteryService { + public: + static constexpr const char *kDeviceName = "MX Ergo S"; + + static std::shared_ptr getInstance(); + + ~MouseBatteryService(); + + MouseBatteryService(const MouseBatteryService &) = delete; + MouseBatteryService &operator=(const MouseBatteryService &) = delete; + + /// Last reading. Safe to call from the main thread. + hidpp::BatteryStatus getStatus() const; + + /// Emitted on the main thread whenever a new reading lands. + sigc::signal &updated() { return updatedSignal; } + + /// Stops the worker thread; called from the application's shutdown handler + /// so it cannot outlive the main loop the dispatcher posts to. + void stop(); + + private: + explicit MouseBatteryService(std::string deviceName); + + void poll(); + + hidpp::BatteryReader reader; + + Glib::Dispatcher dispatcher; + sigc::signal updatedSignal; + + std::thread worker; + mutable std::mutex mutex; + std::condition_variable wakeup; + bool running = true; + hidpp::BatteryStatus status{}; + + inline static std::shared_ptr instance = nullptr; +}; diff --git a/include/widgets/mouseBattery.hpp b/include/widgets/mouseBattery.hpp new file mode 100644 index 0000000..45b4a98 --- /dev/null +++ b/include/widgets/mouseBattery.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + +#include "services/mouseBatteryService.hpp" + +/// Shows the battery level of the wireless mouse tracked by MouseBatteryService. +class MouseBatteryWidget : public Gtk::Box { + public: + MouseBatteryWidget(); + ~MouseBatteryWidget() override; + + private: + Gtk::Label icon; + Gtk::Label label; + + std::shared_ptr service; + sigc::connection updatedConn; + + void refresh(); +}; diff --git a/resources/bar.css b/resources/bar.css index 6552e14..008d16f 100644 --- a/resources/bar.css +++ b/resources/bar.css @@ -293,6 +293,30 @@ tooltip { border-radius: 4px; } +.mouse-battery { + padding: 0 6px; +} + +.mouse-battery-good { + color: #ffffff; +} + +.mouse-battery-low { + color: #ffb454; +} + +.mouse-battery-critical { + color: #ff5555; +} + +.mouse-battery-charging { + color: #5aff8f; +} + +.mouse-battery-disconnected { + color: #ffffff66; +} + .available-devices { background-color: rgba(255, 255, 255, 0.1); border-radius: 4px; diff --git a/src/app.cpp b/src/app.cpp index cfac113..5f99116 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -5,6 +5,7 @@ #include "connection/dbus/mpris.hpp" #include "connection/dbus/notification.hpp" +#include "services/mouseBatteryService.hpp" #include "services/notificationController.hpp" #include "services/textureCache.hpp" @@ -114,6 +115,7 @@ App::App() : app(Gtk::Application::create("org.example.mybar")) { app->signal_shutdown().connect([&]() { TextureCacheService::getInstance()->clear(); + MouseBatteryService::getInstance()->stop(); this->trayService->stop(); }); } diff --git a/src/bar/bar.cpp b/src/bar/bar.cpp index ec56952..17dd16c 100644 --- a/src/bar/bar.cpp +++ b/src/bar/bar.cpp @@ -75,6 +75,7 @@ void Bar::setup_center_box() { } void Bar::setup_right_box() { + right_box.append(this->mouseBattery); right_box.append(*this->trayWidget); right_box.append(this->homeAssistant); right_box.append(this->controlCenter); diff --git a/src/services/hidppBattery.cpp b/src/services/hidppBattery.cpp new file mode 100644 index 0000000..ee78d94 --- /dev/null +++ b/src/services/hidppBattery.cpp @@ -0,0 +1,519 @@ +#include "services/hidppBattery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace hidpp { +namespace { + +// HID++ transport: a short report carries 3 payload bytes, a long one 16. +constexpr uint8_t kReportShort = 0x10; +constexpr uint8_t kReportLong = 0x11; +constexpr size_t kShortLen = 7; +constexpr size_t kLongLen = 20; +constexpr size_t kHeaderLen = 4; + +// Tags our requests so replies meant for other clients (Solaar, libratbag) +// can be told apart from ours on the shared receiver node. +constexpr uint8_t kSwId = 0x0A; + +constexpr uint8_t kRootFeature = 0x00; +constexpr uint8_t kRootGetFeature = 0x00; +constexpr uint8_t kRootGetProtocolVersion = 0x01; + +constexpr uint16_t kFeatureDeviceName = 0x0005; +constexpr uint16_t kFeatureBatteryStatus = 0x1000; +constexpr uint16_t kFeatureUnifiedBattery = 0x1004; + +// UNIFIED_BATTERY function 0x00 is get_capabilities; the reading lives behind +// 0x01. BATTERY_STATUS puts its reading behind 0x00 instead. +constexpr uint8_t kUnifiedBatteryGetStatus = 0x01; +constexpr uint8_t kBatteryStatusGetLevel = 0x00; + +// Feature index 0x8F marks a HID++ 1.0 error, 0xFF a HID++ 2.0 one. +constexpr uint8_t kErrorHidpp10 = 0x8F; +constexpr uint8_t kErrorHidpp20 = 0xFF; + +// 0xFF is the device itself on a node created by hid-logitech-dj; 1..6 are the +// pairing slots when we are talking to a receiver directly. +constexpr std::array kProbeIndices = {0xFF, 1, 2, 3, 4, 5, 6}; + +constexpr uint32_t kLogitechVendorId = 0x046D; + +// A sleeping device takes seconds to answer the request that wakes it, but +// replies in milliseconds once awake. Scanning therefore costs one long timeout +// per empty pairing slot, which is why the resolved endpoint is cached. +constexpr int kWakeTimeoutMs = 2500; +constexpr int kTimeoutMs = 3000; +constexpr int kProbeTimeoutMs = 600; + +std::string readSysfsString(const std::filesystem::path &path) { + std::ifstream file(path); + if (!file.is_open()) { + return {}; + } + + std::string value; + std::getline(file, value); + return value; +} + +/// Drops notifications the receiver queued up (mouse movement, connection +/// events) so they cannot be mistaken for the reply we are about to wait for. +void drain(int fd) { + std::array scratch{}; + while (::read(fd, scratch.data(), scratch.size()) > 0) { + } +} + +/// Sends one HID++ request and waits for the reply carrying our software id. +/// Returns the payload after the 4-byte header, or nullopt on error/timeout. +std::optional> request(int fd, uint8_t deviceIndex, uint8_t featureIdx, + uint8_t function, const std::vector ¶ms = {}, + uint8_t reportId = kReportShort, int timeoutMs = kTimeoutMs) { + const size_t reportLen = (reportId == kReportLong) ? kLongLen : kShortLen; + if (params.size() > reportLen - kHeaderLen) { + return std::nullopt; + } + + const uint8_t tag = static_cast((function << 4) | kSwId); + + std::vector out(reportLen, 0); + out[0] = reportId; + out[1] = deviceIndex; + out[2] = featureIdx; + out[3] = tag; + std::copy(params.begin(), params.end(), out.begin() + kHeaderLen); + + drain(fd); + + // Transient EPIPE happens when the receiver is busy serving another HID++ + // client (logiod, Solaar); the next poll retries. + if (::write(fd, out.data(), out.size()) < 0) { + spdlog::debug("[hidpp] write failed: {}", std::strerror(errno)); + return std::nullopt; + } + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + + while (true) { + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return std::nullopt; + } + + const auto remaining = + std::chrono::duration_cast(deadline - now).count(); + + pollfd pfd{.fd = fd, .events = POLLIN, .revents = 0}; + const int ready = ::poll(&pfd, 1, static_cast(remaining)); + if (ready <= 0) { + return std::nullopt; + } + + std::array in{}; + const ssize_t n = ::read(fd, in.data(), in.size()); + if (n < static_cast(kHeaderLen)) { + continue; + } + + // Someone else's device, or an unsolicited notification. + if (in[1] != deviceIndex) { + continue; + } + + if ((in[2] == kErrorHidpp20 || in[2] == kErrorHidpp10) && in[3] == tag) { + return std::nullopt; + } + + if (in[2] == featureIdx && in[3] == tag) { + return std::vector(in.begin() + kHeaderLen, in.begin() + n); + } + } +} + +/// Resolves a feature id to the index this particular device assigned it. +/// Index 0 means the device does not implement the feature. +std::optional getFeatureIndex(int fd, uint8_t deviceIndex, uint16_t featureId, int timeoutMs) { + const std::vector params = {static_cast(featureId >> 8), + static_cast(featureId & 0xFF), 0x00}; + + const auto reply = + request(fd, deviceIndex, kRootFeature, kRootGetFeature, params, kReportShort, timeoutMs); + if (!reply || reply->empty() || (*reply)[0] == 0) { + return std::nullopt; + } + + return (*reply)[0]; +} + +std::string getDeviceName(int fd, uint8_t deviceIndex) { + const auto nameFeature = getFeatureIndex(fd, deviceIndex, kFeatureDeviceName, kProbeTimeoutMs); + if (!nameFeature) { + return {}; + } + + const auto lengthReply = request(fd, deviceIndex, *nameFeature, 0x00, {}, kReportShort, kProbeTimeoutMs); + if (!lengthReply || lengthReply->empty()) { + return {}; + } + + const size_t length = (*lengthReply)[0]; + std::string name; + + while (name.size() < length) { + const auto chunk = request(fd, deviceIndex, *nameFeature, 0x01, + {static_cast(name.size())}, kReportLong, kProbeTimeoutMs); + if (!chunk || chunk->empty()) { + break; + } + + name.append(reinterpret_cast(chunk->data()), chunk->size()); + } + + name.resize(std::min(name.size(), length)); + + // The device pads the last chunk with NULs. + name.erase(std::find(name.begin(), name.end(), '\0'), name.end()); + return name; +} + +BatteryStatus::Level levelFromMask(uint8_t mask) { + if (mask & 0x01) + return BatteryStatus::Level::CRITICAL; + if (mask & 0x02) + return BatteryStatus::Level::LOW; + if (mask & 0x04) + return BatteryStatus::Level::GOOD; + if (mask & 0x08) + return BatteryStatus::Level::FULL; + + return BatteryStatus::Level::UNKNOWN; +} + +BatteryStatus::Level levelFromPercentage(int percentage) { + if (percentage < 0) + return BatteryStatus::Level::UNKNOWN; + if (percentage <= 10) + return BatteryStatus::Level::CRITICAL; + if (percentage <= 25) + return BatteryStatus::Level::LOW; + if (percentage < 95) + return BatteryStatus::Level::GOOD; + + return BatteryStatus::Level::FULL; +} + +/// True when the hidraw node sits under a Logitech USB device. +bool isLogitechNode(const std::string &nodeName) { + std::ifstream file(std::filesystem::path("/sys/class/hidraw") / nodeName / "device/uevent"); + if (!file.is_open()) { + return false; + } + + // Looking for HID_ID=::, all zero-padded hex. It is + // not the first line of uevent, so scan for it. + std::string line; + while (std::getline(file, line)) { + constexpr std::string_view kPrefix = "HID_ID="; + if (!line.starts_with(kPrefix)) { + continue; + } + + const auto busEnd = line.find(':', kPrefix.size()); + if (busEnd == std::string::npos) { + return false; + } + + try { + return std::stoul(line.substr(busEnd + 1, 8), nullptr, 16) == kLogitechVendorId; + } catch (const std::exception &) { + return false; + } + } + + return false; +} + +/// True when the node's report descriptor declares the HID++ collection, i.e. +/// report 0x10 or 0x11 under the vendor-defined usage page 0xFF00. +/// +/// A receiver exposes several interfaces and only one of them speaks HID++; +/// checking the descriptor avoids firing writes at the plain keyboard and mouse +/// interfaces, which reject them and can leave the endpoint briefly stalled. +bool nodeSpeaksHidpp(const std::string &nodeName) { + std::ifstream file(std::filesystem::path("/sys/class/hidraw") / nodeName / "device/report_descriptor", + std::ios::binary); + if (!file.is_open()) { + return false; + } + + const std::vector descriptor((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + constexpr uint8_t kItemLong = 0xFE; + constexpr uint8_t kTypeGlobal = 1; + constexpr uint8_t kTagUsagePage = 0x0; + constexpr uint8_t kTagReportId = 0x8; + constexpr uint32_t kVendorPage = 0xFF00; + + uint32_t usagePage = 0; + + for (size_t i = 0; i < descriptor.size();) { + const uint8_t prefix = descriptor[i]; + + if (prefix == kItemLong) { + if (i + 1 >= descriptor.size()) { + break; + } + i += 3 + descriptor[i + 1]; + continue; + } + + size_t size = prefix & 0x03; + if (size == 3) { + size = 4; + } + + const uint8_t type = (prefix >> 2) & 0x03; + const uint8_t tag = (prefix >> 4) & 0x0F; + + if (i + 1 + size > descriptor.size()) { + break; + } + + uint32_t value = 0; + for (size_t b = 0; b < size; b++) { + value |= static_cast(descriptor[i + 1 + b]) << (8 * b); + } + + if (type == kTypeGlobal && tag == kTagUsagePage) { + usagePage = value; + } else if (type == kTypeGlobal && tag == kTagReportId && usagePage == kVendorPage && + (value == kReportShort || value == kReportLong)) { + return true; + } + + i += 1 + size; + } + + return false; +} + +} // namespace + +BatteryReader::BatteryReader(std::string deviceName) : deviceName(std::move(deviceName)) {} + +BatteryReader::~BatteryReader() { + closeEndpoint(); +} + +void BatteryReader::closeEndpoint() { + if (endpoint.fd >= 0) { + ::close(endpoint.fd); + } + + endpoint = Endpoint{}; +} + +BatteryStatus BatteryReader::read() { + // The kernel already polls the device for us when hid-logitech-hidpp is + // bound, which costs no HID traffic and never blocks. + if (const auto fromSysfs = readFromSysfs()) { + return *fromSysfs; + } + + if (const auto fromHidpp = readFromHidpp()) { + return *fromHidpp; + } + + return BatteryStatus{}; +} + +std::optional BatteryReader::readFromSysfs() const { + const std::filesystem::path root = "/sys/class/power_supply"; + + std::error_code ec; + if (!std::filesystem::exists(root, ec)) { + return std::nullopt; + } + + for (const auto &entry : std::filesystem::directory_iterator(root, ec)) { + if (readSysfsString(entry.path() / "model_name") != deviceName) { + continue; + } + + BatteryStatus status{}; + status.connected = true; + + const std::string capacity = readSysfsString(entry.path() / "capacity"); + if (!capacity.empty()) { + try { + status.percentage = std::stoi(capacity); + } catch (const std::exception &) { + status.percentage = -1; + } + } + + const std::string state = readSysfsString(entry.path() / "status"); + status.charging = (state == "Charging"); + status.externalPower = status.charging || state == "Full"; + + if (status.percentage >= 0) { + status.level = levelFromPercentage(status.percentage); + } else { + const std::string capacityLevel = readSysfsString(entry.path() / "capacity_level"); + if (capacityLevel == "Critical") + status.level = BatteryStatus::Level::CRITICAL; + else if (capacityLevel == "Low") + status.level = BatteryStatus::Level::LOW; + else if (capacityLevel == "Normal" || capacityLevel == "High") + status.level = BatteryStatus::Level::GOOD; + else if (capacityLevel == "Full") + status.level = BatteryStatus::Level::FULL; + } + + return status; + } + + return std::nullopt; +} + +bool BatteryReader::resolveEndpoint() { + closeEndpoint(); + + const std::filesystem::path root = "/sys/class/hidraw"; + + std::error_code ec; + if (!std::filesystem::exists(root, ec)) { + return false; + } + + for (const auto &entry : std::filesystem::directory_iterator(root, ec)) { + const std::string nodeName = entry.path().filename().string(); + if (!isLogitechNode(nodeName) || !nodeSpeaksHidpp(nodeName)) { + continue; + } + + const std::string devicePath = "/dev/" + nodeName; + + const int fd = ::open(devicePath.c_str(), O_RDWR | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + spdlog::debug("[hidpp] cannot open {}: {}", devicePath, std::strerror(errno)); + continue; + } + + for (const uint8_t deviceIndex : kProbeIndices) { + // Only an occupied pairing slot answers the root ping, and a + // sleeping device needs the long timeout to answer it at all. + const auto pong = request(fd, deviceIndex, kRootFeature, kRootGetProtocolVersion, + {0x00, 0x00, 0xAA}, kReportShort, kWakeTimeoutMs); + if (!pong) { + continue; + } + + if (getDeviceName(fd, deviceIndex) != deviceName) { + continue; + } + + for (const uint16_t featureId : {kFeatureUnifiedBattery, kFeatureBatteryStatus}) { + const auto featureIdx = getFeatureIndex(fd, deviceIndex, featureId, kProbeTimeoutMs); + if (!featureIdx) { + continue; + } + + endpoint = Endpoint{ + .fd = fd, + .deviceIndex = deviceIndex, + .featureIdx = *featureIdx, + .featureId = featureId, + }; + + spdlog::info("[hidpp] '{}' on {} slot {} via feature 0x{:04x}", deviceName, + devicePath, deviceIndex, featureId); + return true; + } + } + + ::close(fd); + } + + spdlog::debug("[hidpp] '{}' not found on any hidraw node", deviceName); + return false; +} + +std::optional BatteryReader::readFromHidpp() { + if (endpoint.fd < 0 && !resolveEndpoint()) { + return std::nullopt; + } + + const auto readFunction = [this] { + return endpoint.featureId == kFeatureUnifiedBattery ? kUnifiedBatteryGetStatus + : kBatteryStatusGetLevel; + }; + + auto reply = request(endpoint.fd, endpoint.deviceIndex, endpoint.featureIdx, readFunction()); + + // The device may have roamed to another slot or node since we cached it. + if (!reply) { + if (!resolveEndpoint()) { + return std::nullopt; + } + + reply = request(endpoint.fd, endpoint.deviceIndex, endpoint.featureIdx, readFunction()); + if (!reply) { + return std::nullopt; + } + } + + BatteryStatus status{}; + status.connected = true; + + if (endpoint.featureId == kFeatureUnifiedBattery) { + // get_status -> state_of_charge, battery_level mask, charging state, external power + if (reply->size() < 4) { + return std::nullopt; + } + + status.charging = (*reply)[2] != 0; + status.externalPower = (*reply)[3] != 0; + + // The coarse level mask disagrees with the percentage on this hardware + // (an MX Ergo S calls 65% "full"), so trust the percentage when the + // device reports one and fall back to the mask when it does not. + if ((*reply)[0] > 0) { + status.percentage = (*reply)[0]; + status.level = levelFromPercentage(status.percentage); + } else { + status.percentage = -1; + status.level = levelFromMask((*reply)[1]); + } + } else { + // BATTERY_STATUS -> discharge level, next level, charging state + if (reply->size() < 3) { + return std::nullopt; + } + + status.percentage = (*reply)[0]; + status.level = levelFromPercentage(status.percentage); + + const uint8_t chargingState = (*reply)[2]; + status.charging = (chargingState == 0x01 || chargingState == 0x02); + status.externalPower = (chargingState != 0x00); + } + + return status; +} + +} // namespace hidpp diff --git a/src/services/mouseBatteryService.cpp b/src/services/mouseBatteryService.cpp new file mode 100644 index 0000000..77faac3 --- /dev/null +++ b/src/services/mouseBatteryService.cpp @@ -0,0 +1,87 @@ +#include "services/mouseBatteryService.hpp" + +#include +#include + +namespace { +// The battery moves by single digits over hours; polling harder only keeps the +// device's radio awake and drains it faster. +constexpr auto kPollInterval = std::chrono::minutes(5); + +// Retry sooner while the mouse is off or out of range so it shows up promptly +// once it comes back, but back off rather than rescanning every hidraw node +// forever when there is simply no such device attached. +constexpr auto kRetryInterval = std::chrono::seconds(30); +constexpr auto kMaxRetryInterval = std::chrono::minutes(10); +} // namespace + +std::shared_ptr MouseBatteryService::getInstance() { + if (!instance) { + instance = std::shared_ptr(new MouseBatteryService(kDeviceName)); + } + + return instance; +} + +MouseBatteryService::MouseBatteryService(std::string deviceName) : reader(std::move(deviceName)) { + dispatcher.connect([this]() { updatedSignal.emit(); }); + + worker = std::thread(&MouseBatteryService::poll, this); +} + +MouseBatteryService::~MouseBatteryService() { + stop(); +} + +void MouseBatteryService::stop() { + { + std::lock_guard lock(mutex); + if (!running) { + return; + } + + running = false; + } + + wakeup.notify_all(); + + if (worker.joinable()) { + worker.join(); + } +} + +hidpp::BatteryStatus MouseBatteryService::getStatus() const { + std::lock_guard lock(mutex); + return status; +} + +void MouseBatteryService::poll() { + auto retryInterval = std::chrono::seconds(kRetryInterval); + + while (true) { + const hidpp::BatteryStatus latest = reader.read(); + + { + std::lock_guard lock(mutex); + status = latest; + } + + dispatcher.emit(); + + std::chrono::seconds interval; + if (latest.connected) { + interval = std::chrono::seconds(kPollInterval); + retryInterval = std::chrono::seconds(kRetryInterval); + } else { + interval = retryInterval; + retryInterval = std::min(retryInterval * 2, std::chrono::seconds(kMaxRetryInterval)); + } + + std::unique_lock lock(mutex); + wakeup.wait_for(lock, interval, [this] { return !running; }); + + if (!running) { + return; + } + } +} diff --git a/src/widgets/mouseBattery.cpp b/src/widgets/mouseBattery.cpp new file mode 100644 index 0000000..ab64858 --- /dev/null +++ b/src/widgets/mouseBattery.cpp @@ -0,0 +1,98 @@ +#include "widgets/mouseBattery.hpp" + +#include + +#include "components/types/icon.hpp" + +namespace { + +const char *cssClassFor(const hidpp::BatteryStatus &status) { + if (!status.connected) + return "mouse-battery-disconnected"; + if (status.charging) + return "mouse-battery-charging"; + + switch (status.level) { + case hidpp::BatteryStatus::Level::CRITICAL: + return "mouse-battery-critical"; + case hidpp::BatteryStatus::Level::LOW: + return "mouse-battery-low"; + default: + return "mouse-battery-good"; + } +} + +Icon::Type iconFor(const hidpp::BatteryStatus &status) { + if (!status.connected) + return Icon::BATTERY_UNKNOWN; + if (status.charging) + return Icon::BATTERY_CHARGING_FULL; + if (status.level == hidpp::BatteryStatus::Level::CRITICAL) + return Icon::BATTERY_ALERT; + + return Icon::BATTERY_FULL; +} + +std::string labelFor(const hidpp::BatteryStatus &status) { + if (!status.connected) + return "--"; + if (status.percentage >= 0) + return std::to_string(status.percentage) + "%"; + + switch (status.level) { + case hidpp::BatteryStatus::Level::CRITICAL: + return "crit"; + case hidpp::BatteryStatus::Level::LOW: + return "low"; + case hidpp::BatteryStatus::Level::GOOD: + return "ok"; + case hidpp::BatteryStatus::Level::FULL: + return "full"; + default: + return "?"; + } +} + +} // namespace + +MouseBatteryWidget::MouseBatteryWidget() : Gtk::Box(Gtk::Orientation::HORIZONTAL) { + set_valign(Gtk::Align::CENTER); + set_halign(Gtk::Align::CENTER); + set_spacing(4); + add_css_class("mouse-battery"); + + icon.add_css_class("material-icons"); + icon.set_valign(Gtk::Align::CENTER); + + label.set_valign(Gtk::Align::CENTER); + + append(icon); + append(label); + + this->service = MouseBatteryService::getInstance(); + + this->updatedConn = + this->service->updated().connect(sigc::mem_fun(*this, &MouseBatteryWidget::refresh)); + + refresh(); +} + +MouseBatteryWidget::~MouseBatteryWidget() { + if (this->updatedConn.connected()) { + this->updatedConn.disconnect(); + } +} + +void MouseBatteryWidget::refresh() { + const hidpp::BatteryStatus current = this->service->getStatus(); + + icon.set_text(Icon::toString(iconFor(current))); + label.set_text(labelFor(current)); + + for (const char *name : {"mouse-battery-good", "mouse-battery-low", "mouse-battery-critical", + "mouse-battery-charging", "mouse-battery-disconnected"}) { + remove_css_class(name); + } + + add_css_class(cssClassFor(current)); +}