mouse baterry
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"};
|
||||
|
||||
|
||||
@@ -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"},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<BatteryStatus> readFromSysfs() const;
|
||||
std::optional<BatteryStatus> 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
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <condition_variable>
|
||||
#include <glibmm/dispatcher.h>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <sigc++/sigc++.h>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#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<MouseBatteryService> 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<void()> &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<void()> updatedSignal;
|
||||
|
||||
std::thread worker;
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable wakeup;
|
||||
bool running = true;
|
||||
hidpp::BatteryStatus status{};
|
||||
|
||||
inline static std::shared_ptr<MouseBatteryService> instance = nullptr;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <gtkmm/box.h>
|
||||
#include <gtkmm/label.h>
|
||||
#include <memory>
|
||||
#include <sigc++/sigc++.h>
|
||||
|
||||
#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<MouseBatteryService> service;
|
||||
sigc::connection updatedConn;
|
||||
|
||||
void refresh();
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
#include "services/hidppBattery.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <poll.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <string_view>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t, 7> 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<uint8_t, 64> 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<std::vector<uint8_t>> request(int fd, uint8_t deviceIndex, uint8_t featureIdx,
|
||||
uint8_t function, const std::vector<uint8_t> ¶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<uint8_t>((function << 4) | kSwId);
|
||||
|
||||
std::vector<uint8_t> 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<std::chrono::milliseconds>(deadline - now).count();
|
||||
|
||||
pollfd pfd{.fd = fd, .events = POLLIN, .revents = 0};
|
||||
const int ready = ::poll(&pfd, 1, static_cast<int>(remaining));
|
||||
if (ready <= 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::array<uint8_t, 64> in{};
|
||||
const ssize_t n = ::read(fd, in.data(), in.size());
|
||||
if (n < static_cast<ssize_t>(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<uint8_t>(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<uint8_t> getFeatureIndex(int fd, uint8_t deviceIndex, uint16_t featureId, int timeoutMs) {
|
||||
const std::vector<uint8_t> params = {static_cast<uint8_t>(featureId >> 8),
|
||||
static_cast<uint8_t>(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<uint8_t>(name.size())}, kReportLong, kProbeTimeoutMs);
|
||||
if (!chunk || chunk->empty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
name.append(reinterpret_cast<const char *>(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=<bus>:<vendor>:<product>, 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<uint8_t> descriptor((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
|
||||
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<uint32_t>(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<BatteryStatus> 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<BatteryStatus> 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
|
||||
@@ -0,0 +1,87 @@
|
||||
#include "services/mouseBatteryService.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
|
||||
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> MouseBatteryService::getInstance() {
|
||||
if (!instance) {
|
||||
instance = std::shared_ptr<MouseBatteryService>(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<std::mutex> lock(mutex);
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = false;
|
||||
}
|
||||
|
||||
wakeup.notify_all();
|
||||
|
||||
if (worker.joinable()) {
|
||||
worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
hidpp::BatteryStatus MouseBatteryService::getStatus() const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return status;
|
||||
}
|
||||
|
||||
void MouseBatteryService::poll() {
|
||||
auto retryInterval = std::chrono::seconds(kRetryInterval);
|
||||
|
||||
while (true) {
|
||||
const hidpp::BatteryStatus latest = reader.read();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex);
|
||||
wakeup.wait_for(lock, interval, [this] { return !running; });
|
||||
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "widgets/mouseBattery.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
#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));
|
||||
}
|
||||
Reference in New Issue
Block a user